From d7c9d98493e5e0a91e4a6d5090cf8db5795cd367 Mon Sep 17 00:00:00 2001 From: Eric J Date: Wed, 19 Aug 2026 15:03:45 -0700 Subject: [PATCH 1/5] Port phase 5: Vulcanus helper layer (#225) Ports src/noise/expressions/vulcanusHelpers.ts - vulcanus_detail_noise, vulcanus_plasma, vulcanus_threshold, vulcanus_contrast, vulcanus_biome_noise, the vulcanus_scale_multiplier program constant and the six vulcanus_wobble_* fields. It lands first because climate, biomes, elevation and the resource stack all read it, so an error here would arrive blended into seven files rather than localised to one. Tier 1 grades the three leaf closures the oracle captured, as frozen exact f32 match counts out of 38: wobbleX 38/38, worst residual exactly 0 mountainPlasma 7/38, worst residual 2.807e-3 detailNoise 1/38, worst residual 7.778e-5 The TypeScript was measured against the same fixture and reaches the identical 38, 7 and 1 with the same residuals, so these describe the distance both ports sit from the game rather than a gap between them. detailNoise is the row worth keeping: it has the SMALLEST residual of the three and the FEWEST exact matches. A field can be uniformly close and almost never right, which is the whole argument for counting matches instead of bounding error. Also recorded: test/vulcanusHelpers.spec.ts bounds vulcanus_wobble_x at 4e-4 and its comment claims a measured worst of 2.32e-4. Re-measured here the worst residual is exactly 0 at all 38 positions, so that bound is inert - it would not notice the field losing four digits. Left alone rather than fixed, because it belongs to #256 with the other 86. Unit tests pin what a residual cannot see: the +12243 detail-noise seed offset against the plasma's 12643 and 13423 + seed, which differ by a transposed digit; that only the plasma's SECOND term depends on its seed parameter; that biome_noise uses its seed1 raw; and that each wobble pair is two independent fields. Prepared moves from expressions::fulgora_shared to multioctave_noise. It is a general multioctave-hoisting utility rather than anything planet-specific, and a Vulcanus module reaching into a Fulgora one for it would be the wrong shape. Four Fulgora importers updated; no behaviour change. The poison entry was watched going red, not assumed: under --features poison wobbleX drops 38 -> 6. engine.wasm is unchanged and still byte-matches its source. Nothing fmw-wasm exports reaches the new module yet, so it is dead-code-eliminated. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --- .../src/expressions/fulgora_elevation.rs | 2 +- .../src/expressions/fulgora_roads.rs | 3 +- .../src/expressions/fulgora_ruins.rs | 3 +- .../src/expressions/fulgora_shared.rs | 37 +- crates/fmw-noise/src/expressions/mod.rs | 1 + .../src/expressions/vulcanus_helpers.rs | 362 ++++++++++++++++++ crates/fmw-noise/src/fixtures.rs | 113 ++++++ crates/fmw-noise/src/multioctave_noise.rs | 40 ++ scripts/verify-rust.sh | 4 + 9 files changed, 526 insertions(+), 39 deletions(-) create mode 100644 crates/fmw-noise/src/expressions/vulcanus_helpers.rs diff --git a/crates/fmw-noise/src/expressions/fulgora_elevation.rs b/crates/fmw-noise/src/expressions/fulgora_elevation.rs index cd620dcc..8334bac7 100644 --- a/crates/fmw-noise/src/expressions/fulgora_elevation.rs +++ b/crates/fmw-noise/src/expressions/fulgora_elevation.rs @@ -25,9 +25,9 @@ use crate::eval::math::slider_rescale; use crate::eval::math::{lerp_f32, max2, min2}; use crate::expressions::fulgora_cells::CellFields; -use crate::expressions::fulgora_shared::Prepared; use crate::expressions::fulgora_shared::{FulgoraCtx, SharedFields}; use crate::multioctave_noise::MultioctaveParams; +use crate::multioctave_noise::Prepared; use crate::poison; /// `seed1` for each multioctave call: `crc32` of the Lua's string seed. diff --git a/crates/fmw-noise/src/expressions/fulgora_roads.rs b/crates/fmw-noise/src/expressions/fulgora_roads.rs index 35b10151..e2024416 100644 --- a/crates/fmw-noise/src/expressions/fulgora_roads.rs +++ b/crates/fmw-noise/src/expressions/fulgora_roads.rs @@ -34,8 +34,9 @@ use crate::eval::math::lerp; use crate::expressions::fulgora_cells::CellFields; -use crate::expressions::fulgora_shared::{FulgoraCtx, Prepared, SharedFields}; +use crate::expressions::fulgora_shared::{FulgoraCtx, SharedFields}; use crate::multioctave_noise::MultioctaveParams; +use crate::multioctave_noise::Prepared; use crate::voronoi_noise::{Voronoi, VoronoiDistanceType, VoronoiParams}; /// `seed1` values, computed with CRC32 over the UTF-8 bytes of the name in the diff --git a/crates/fmw-noise/src/expressions/fulgora_ruins.rs b/crates/fmw-noise/src/expressions/fulgora_ruins.rs index ab6c0daa..7893f9c0 100644 --- a/crates/fmw-noise/src/expressions/fulgora_ruins.rs +++ b/crates/fmw-noise/src/expressions/fulgora_ruins.rs @@ -20,8 +20,9 @@ use crate::eval::math::max2; use crate::expressions::fulgora_cells::CellFields; use crate::expressions::fulgora_masks::MaskFields; use crate::expressions::fulgora_roads::RoadFields; -use crate::expressions::fulgora_shared::{FulgoraCtx, Prepared}; +use crate::expressions::fulgora_shared::FulgoraCtx; use crate::multioctave_noise::MultioctaveParams; +use crate::multioctave_noise::Prepared; const SEED1_RUINS_WALLS: u32 = 2_307_136_174; // crc32("fulgora_ruins_walls") = 0x89841AAE const SEED1_RUINS_PAVING: u32 = 3_946_133_559; // crc32("fulgora_ruins_paving") = 0xEB353837 diff --git a/crates/fmw-noise/src/expressions/fulgora_shared.rs b/crates/fmw-noise/src/expressions/fulgora_shared.rs index d624c632..d8ee5a4d 100644 --- a/crates/fmw-noise/src/expressions/fulgora_shared.rs +++ b/crates/fmw-noise/src/expressions/fulgora_shared.rs @@ -22,10 +22,9 @@ //! evaluated, checked field by field, which is what makes the substitution //! legitimate; a field that read a neighbour would need the cache back. -use crate::basis_noise::{tables_from_seed, BasisNoiseTables}; use crate::eval::math::{clamp, slider_to_linear}; use crate::expressions::starting_spot_at_angle::{starting_spot_at_angle, AngleTrig, StartingSpot}; -use crate::multioctave_noise::{octave_terms, sum_octaves, MultioctaveParams, OctaveTerms}; +use crate::multioctave_noise::{MultioctaveParams, Prepared}; use crate::poison; /// `seed1` for `fulgora_wobble_x`: `crc32(utf8("fulgora_wobble_x"))`. @@ -84,40 +83,6 @@ pub struct SharedFields { pub starting_vault_mask: f64, } -/// One multioctave call with its seed tables and octave terms already derived. -/// -/// **This is the shape every renderer needs, and its absence was a measured -/// bug.** `multioctave_noise(x, y, ¶ms)` re-derives both on every call, and -/// `tables_from_seed` runs a PRNG over three 256-byte tables; Fulgora's chain -/// makes eight such calls per pixel. Building them per point measured **1.15x** -/// against the TypeScript, which builds them once in a closure. Hoisting is -/// what the ratio in phase 3's pull request is. -/// -/// Results are identical either way, so nothing in tiers 1 to 3 could see it. -/// -/// No `Debug` or `Clone`: `OctaveTerms` has neither, and deriving them here -/// would mean giving them to derived state whose shape is an implementation -/// detail. -pub struct Prepared { - terms: OctaveTerms, - tables: BasisNoiseTables, -} - -impl Prepared { - #[must_use] - pub fn new(params: &MultioctaveParams) -> Self { - Self { - terms: octave_terms(params), - tables: tables_from_seed(params.seed0, params.seed1), - } - } - - #[must_use] - pub fn eval(&self, x: f64, y: f64) -> f32 { - sum_octaves(x, y, &self.terms, &self.tables) - } -} - /// The per-render constants of Fulgora's shared layer. pub struct FulgoraShared { /// `fulgora_grid` - the Voronoi cell size in tiles. diff --git a/crates/fmw-noise/src/expressions/mod.rs b/crates/fmw-noise/src/expressions/mod.rs index ba23ef3e..dc285a66 100644 --- a/crates/fmw-noise/src/expressions/mod.rs +++ b/crates/fmw-noise/src/expressions/mod.rs @@ -15,4 +15,5 @@ pub mod fulgora_scrap; pub mod fulgora_shared; pub mod fulgora_stack; pub mod starting_spot_at_angle; +pub mod vulcanus_helpers; pub mod vulcanus_seed; diff --git a/crates/fmw-noise/src/expressions/vulcanus_helpers.rs b/crates/fmw-noise/src/expressions/vulcanus_helpers.rs new file mode 100644 index 00000000..dc4211e1 --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_helpers.rs @@ -0,0 +1,362 @@ +//! Vulcanus's reusable helper closures, ported from +//! `src/noise/expressions/vulcanusHelpers.ts`. +//! +//! `vulcanus_detail_noise`, `vulcanus_plasma`, `vulcanus_threshold`, +//! `vulcanus_contrast`, `vulcanus_biome_noise`, the `vulcanus_scale_multiplier` +//! program constant, and the six `vulcanus_wobble_*` fields built from +//! `vulcanus_detail_noise`. Transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` (helper section, +//! lines ~48-94 and ~399-425). +//! +//! These are the building blocks the rest of the Vulcanus tree calls, so they +//! land first: climate, biomes, elevation and the resource stack all read them, +//! and a transcription error here would arrive blended into every one of those +//! rather than localised. +//! +//! ## The two seed constants are one digit apart, and confusing them is silent +//! +//! `vulcanus_detail_noise` offsets its `seed1` parameter by `+ 12243`. +//! `vulcanus_plasma`'s FIRST basis term has a hardcoded `seed1 = 12643` that +//! does NOT depend on the function's `seed` parameter at all - only the SECOND +//! term uses `13423 + seed`. The two constants differ by a transposed digit. +//! +//! Getting either wrong produces a perfectly plausible noise field rather than +//! a slightly wrong one, so no residual-size check would flag it. They are +//! written down here and pinned by their own tests rather than trusted. +//! +//! ## No memo, for the reason the Fulgora layer records +//! +//! The TypeScript wraps each wobble in `memoXY` because both the biome offsets +//! and the spawn distortion sums read them, and it builds a DAG of lazy +//! closures. This port hands out a [`Prepared`] per wobble and lets the caller +//! evaluate it once per point into a local, which is what the memo achieves, +//! bit-identically and with no cache. Every read is at the SAME `(x, y)`. + +use crate::basis_noise::{tables_from_seed, BasisNoiseTables}; +use crate::eval::ctx::EvalCtx; +use crate::eval::math::{clamp, slider_rescale}; +use crate::eval::primitives::{basis_noise_expr, BasisExprParams}; +use crate::multioctave_noise::{MultioctaveParams, Prepared}; +use crate::poison; + +/// `vulcanus_detail_noise` adds this to the `seed1` it is given. +const DETAIL_NOISE_SEED_OFFSET: u32 = 12_243; +/// `vulcanus_plasma`'s first basis term, hardcoded and independent of `seed`. +const PLASMA_SEED1_A: u32 = 12_643; +/// `vulcanus_plasma`'s second basis term adds this to `seed`. +const PLASMA_SEED1_B_BASE: u32 = 13_423; + +/// `vulcanus_threshold(value, threshold) = (value - (1 - threshold)) * (1 / threshold)`. +/// +/// Pure arithmetic with no noise and no seed dependency, so there is no oracle +/// risk and no fixture grades it directly. Written in f64 exactly as the +/// TypeScript writes it, including the reciprocal-then-multiply rather than a +/// divide, because those are not the same operation once a narrowing lands +/// between them and this expression feeds fields that are graded. +#[must_use] +pub fn threshold(value: f64, threshold: f64) -> f64 { + (value - (1.0 - threshold)) * (1.0 / threshold) +} + +/// `vulcanus_contrast(value, c) = clamp(value, c, 1) - c`. +/// +/// Note the clamp's LOWER bound is `c` and the subtraction is of the same `c`, +/// so the result is zero below the knee rather than negative. A version that +/// clamped to `[0, 1]` and then subtracted would be a different function that +/// still looked plausible everywhere above the knee. +#[must_use] +pub fn contrast(value: f64, c: f64) -> f64 { + clamp(value, c, 1.0) - c +} + +/// `vulcanus_plasma(seed, scale, scale2, magnitude1, magnitude2)`, with both +/// basis terms' tables derived once. +/// +/// `abs(A - B)` of two `basis_noise` calls at different scales and seeds. The +/// subtraction and the absolute value are f64, matching the TypeScript's +/// `Math.abs(a - b)` on two `basisNoiseExpr` results - and `basisNoiseExpr` +/// itself returns an un-narrowed f64 product, which is #269. This port carries +/// the same approximation deliberately; see +/// [`basis_noise_expr`](crate::eval::primitives::basis_noise_expr). +pub struct Plasma { + a: BasisExprParams, + b: BasisExprParams, + tables_a: BasisNoiseTables, + tables_b: BasisNoiseTables, +} + +impl Plasma { + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> f64 { + let a = basis_noise_expr(x, y, &self.a, &self.tables_a); + let b = basis_noise_expr(x, y, &self.b, &self.tables_b); + poison::f64_result((a - b).abs()) + } +} + +/// Vulcanus's shared helper layer, with every per-render table already derived. +pub struct VulcanusHelpers { + seed0: u32, + /// `vulcanus_scale_multiplier = slider_rescale(control:vulcanus_volcanism:frequency, 3)`. + /// + /// A program CONSTANT, not a field. Held as the f64 widening of the f32 + /// `slider_rescale` returns, because it is the numerator of + /// `vulcanus_biome_noise`'s input scale and every biome field divides it. + /// + /// The neutral slider is `1`, giving exactly `1` here - confirmed against + /// the oracle at the game's default preset, and NOT `0`. + pub scale_multiplier: f64, + wobble_x: Prepared, + wobble_y: Prepared, + wobble_large_x: Prepared, + wobble_large_y: Prepared, + wobble_huge_x: Prepared, + wobble_huge_y: Prepared, +} + +impl VulcanusHelpers { + /// Build the layer for one evaluation context. + #[must_use] + pub fn new(ctx: &EvalCtx) -> Self { + let seed0 = ctx.seed0; + let scale_multiplier = f64::from(slider_rescale(ctx.vulcanus_volcanism_frequency, 3.0)); + let detail = |seed1: u32, scale: f64, octaves: f64, magnitude: f64| { + Prepared::new(&detail_noise_params( + seed0, seed1, scale, octaves, magnitude, + )) + }; + Self { + seed0, + scale_multiplier, + // The six wobbles, verbatim from the Lua. Each pair differs ONLY in + // its seed1 (`n` against `1000 + n`), so x and y are independent + // noise at identical parameters. + wobble_x: detail(10, 1.0 / 8.0, 2.0, 4.0), + wobble_y: detail(1010, 1.0 / 8.0, 2.0, 4.0), + wobble_large_x: detail(20, 1.0 / 2.0, 2.0, 50.0), + wobble_large_y: detail(1020, 1.0 / 2.0, 2.0, 50.0), + wobble_huge_x: detail(30, 2.0, 2.0, 800.0), + wobble_huge_y: detail(1030, 2.0, 2.0, 800.0), + } + } + + /// `vulcanus_detail_noise(seed1, scale, octaves, magnitude)`. + #[must_use] + pub fn detail_noise(&self, seed1: u32, scale: f64, octaves: f64, magnitude: f64) -> Prepared { + Prepared::new(&detail_noise_params( + self.seed0, seed1, scale, octaves, magnitude, + )) + } + + /// `vulcanus_biome_noise(seed1, scale)`. + /// + /// The Lua gives this no `output_scale`, so it takes the native op's + /// default of 1. Its `seed1` is used RAW - unlike + /// [`detail_noise`](Self::detail_noise), there is no `+ 12243` here. + #[must_use] + pub fn biome_noise(&self, seed1: u32, scale: f64) -> Prepared { + Prepared::new(&MultioctaveParams { + seed0: self.seed0, + seed1, + octaves: 5.0, + persistence: 0.65, + input_scale: self.scale_multiplier / scale, + output_scale: 1.0, + }) + } + + /// `vulcanus_plasma(seed, scale, scale2, magnitude1, magnitude2)`. + #[must_use] + pub fn plasma( + &self, + seed: u32, + scale: f64, + scale2: f64, + magnitude1: f64, + magnitude2: f64, + ) -> Plasma { + Plasma { + a: BasisExprParams { + seed0: self.seed0, + seed1: PLASMA_SEED1_A, + input_scale: 1.0 / 50.0 / scale, + output_scale: magnitude1, + offset_x: 0.0, + }, + b: BasisExprParams { + seed0: self.seed0, + seed1: PLASMA_SEED1_B_BASE + seed, + input_scale: 1.0 / 50.0 / scale2, + output_scale: magnitude2, + offset_x: 0.0, + }, + tables_a: tables_from_seed(self.seed0, PLASMA_SEED1_A), + tables_b: tables_from_seed(self.seed0, PLASMA_SEED1_B_BASE + seed), + } + } + + /// `vulcanus_wobble_x`. + #[must_use] + pub fn wobble_x(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_x.eval(x, y)) + } + /// `vulcanus_wobble_y`. + #[must_use] + pub fn wobble_y(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_y.eval(x, y)) + } + /// `vulcanus_wobble_large_x`. + #[must_use] + pub fn wobble_large_x(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_large_x.eval(x, y)) + } + /// `vulcanus_wobble_large_y`. + #[must_use] + pub fn wobble_large_y(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_large_y.eval(x, y)) + } + /// `vulcanus_wobble_huge_x`. + #[must_use] + pub fn wobble_huge_x(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_huge_x.eval(x, y)) + } + /// `vulcanus_wobble_huge_y`. + #[must_use] + pub fn wobble_huge_y(&self, x: f64, y: f64) -> f64 { + f64::from(self.wobble_huge_y.eval(x, y)) + } +} + +/// The `multioctave_noise{...}` parameters `vulcanus_detail_noise` expands to. +/// +/// Free rather than a method so the `+ 12243` offset has exactly one home; both +/// [`VulcanusHelpers::new`] and [`VulcanusHelpers::detail_noise`] go through it. +fn detail_noise_params( + seed0: u32, + seed1: u32, + scale: f64, + octaves: f64, + magnitude: f64, +) -> MultioctaveParams { + MultioctaveParams { + seed0, + seed1: seed1 + DETAIL_NOISE_SEED_OFFSET, + octaves, + persistence: 0.6, + input_scale: 1.0 / 50.0 / scale, + output_scale: magnitude, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> EvalCtx { + EvalCtx::new(123_456) + } + + /// The neutral volcanism slider puts the multiplier at exactly 1, which is + /// what the oracle fixture carries at every position. If the ctx neutral + /// were 0 in slider space this would not be 1. + #[test] + fn the_default_scale_multiplier_is_exactly_one() { + assert_eq!(VulcanusHelpers::new(&ctx()).scale_multiplier, 1.0); + } + + /// The detail-noise offset is `+ 12243` and the plasma constants are + /// `12643` and `13423 + seed`. Asserted against the numbers rather than + /// left to the fixture, because a wrong seed is a DIFFERENT field rather + /// than a slightly wrong one and no residual check would see it. + #[test] + fn the_seed_constants_are_the_ones_the_lua_writes() { + let p = detail_noise_params(123_456, 10, 1.0 / 8.0, 2.0, 4.0); + assert_eq!(p.seed1, 12_253); + + let plasma = VulcanusHelpers::new(&ctx()).plasma(102, 2.5, 10.0, 125.0, 625.0); + assert_eq!(plasma.a.seed1, 12_643); + assert_eq!(plasma.b.seed1, 13_525); + } + + /// The plasma's FIRST term ignores `seed` entirely. A port that threaded + /// `seed` into both terms would still produce plausible noise, so this is + /// the assertion that pins it. + #[test] + fn only_the_second_plasma_term_depends_on_the_seed() { + let h = VulcanusHelpers::new(&ctx()); + let one = h.plasma(102, 2.5, 10.0, 125.0, 625.0); + let two = h.plasma(777, 2.5, 10.0, 125.0, 625.0); + assert_eq!(one.a.seed1, two.a.seed1); + assert_ne!(one.b.seed1, two.b.seed1); + } + + /// `1/50/scale` is not `scale/50`, and the two agree only at `scale = 1`. + /// The wobbles use 1/8, 1/2 and 2, where they do not. + #[test] + fn the_input_scale_is_the_reciprocal_of_fifty_times_scale() { + let p = detail_noise_params(1, 0, 1.0 / 8.0, 2.0, 4.0); + assert_eq!(p.input_scale, 1.0 / 50.0 / (1.0 / 8.0)); + assert_ne!(p.input_scale, (1.0 / 8.0) / 50.0); + } + + /// `threshold` maps its own threshold to 0 and 1 to 1, which is the + /// property every caller relies on. + #[test] + fn threshold_maps_the_knee_to_zero_and_one_to_one() { + for t in [0.1, 0.25, 0.5, 0.9] { + assert!(threshold(1.0 - t, t).abs() < 1e-12); + assert!((threshold(1.0, t) - 1.0).abs() < 1e-12); + } + } + + /// `contrast` is zero at and below the knee and never negative, which a + /// clamp to `[0, 1]` followed by `- c` would get wrong. + #[test] + fn contrast_is_zero_below_the_knee_and_never_negative() { + let c = 0.3; + assert_eq!(contrast(0.0, c), 0.0); + assert_eq!(contrast(c, c), 0.0); + assert_eq!(contrast(-5.0, c), 0.0); + assert!((contrast(1.0, c) - 0.7).abs() < 1e-12); + for k in 0..100 { + assert!(contrast(f64::from(k) * 0.02 - 0.5, c) >= 0.0); + } + } + + /// The x and y members of each wobble pair are independent fields, not the + /// same field read twice. A copy-paste that built both from one seed shows + /// here. + /// + /// A count rather than "differs at every point": `basis_noise` returns + /// exactly zero on integer lattice points, so two independent fields CAN + /// agree, and an all-or-nothing assertion would be measuring the sample + /// grid rather than the seeds. + #[test] + fn each_wobble_pair_is_two_different_fields() { + let h = VulcanusHelpers::new(&ctx()); + let mut differ = 0usize; + for k in 0..64 { + let (x, y) = (f64::from(k) * 13.5 - 300.0, f64::from(k) * -7.25 + 90.0); + differ += usize::from(h.wobble_x(x, y) != h.wobble_y(x, y)); + differ += usize::from(h.wobble_large_x(x, y) != h.wobble_large_y(x, y)); + differ += usize::from(h.wobble_huge_x(x, y) != h.wobble_huge_y(x, y)); + } + assert!(differ > 180, "only {differ} of 192 wobble readings differ"); + } + + /// `biome_noise` uses its `seed1` RAW, where `detail_noise` offsets by + /// 12243. Reading the same number into both would be an easy and silent + /// mistake, so it is pinned. + #[test] + fn biome_noise_does_not_apply_the_detail_noise_seed_offset() { + let h = VulcanusHelpers::new(&ctx()); + let biome = h.biome_noise(10, 1.0); + let detail = h.detail_noise(10, 1.0, 5.0, 1.0); + let mut differ = 0usize; + for k in 1..33 { + let (x, y) = (f64::from(k) * 5.5, f64::from(k) * -3.0); + differ += usize::from(biome.eval(x, y) != detail.eval(x, y)); + } + assert!(differ > 28, "only {differ} of 32 readings differ"); + } +} diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index a41eca89..0d6df4ee 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2272,3 +2272,116 @@ fn puts_every_fulgora_tile_where_the_game_puts_it() { ); assert_eq!(exact, 4915, "tiles matching the game out of 5057"); } + +// --------------------------------------------------------------------------- +// Phase 5 (#225) - Vulcanus. The helper layer lands first, because climate, +// biomes, elevation and the resource stack all read it. +// +// The counts below are FROZEN EXACT NUMBERS, measured against the oracle and +// recorded rather than chosen. If one moves: read the number, do not adjust it. +// Up is a finding worth taking; down is a regression. +// --------------------------------------------------------------------------- + +use crate::expressions::vulcanus_helpers::VulcanusHelpers; + +/// Score one named field against its fixture column, at f32. +/// +/// The same comparator `score_fulgora` uses, and for the same reason: the game +/// reports f32 values and the chain models f32 arithmetic, so an f64 comparison +/// would measure the host's extra precision rather than the port. +fn score_vulcanus(got: &[f64], want: &[Json], label: &str) -> usize { + assert_eq!(got.len(), want.len(), "{label}: length mismatch"); + let mut exact = 0usize; + for (i, w) in want.iter().enumerate() { + if (got[i] as f32) == (w.as_f64() as f32) { + exact += 1; + } + } + exact +} + +/// Vulcanus's helper layer: the three leaf closures the oracle captured, plus +/// the `vulcanus_scale_multiplier` program constant. +/// +/// The three graded fields are deliberately one of each KIND rather than three +/// of the cheapest: `vulcanus_wobble_x` is a two-octave `detail_noise` at a +/// small magnitude, `mountain_plasma` is the `abs(A - B)` of two `basis_noise` +/// calls at magnitudes up to 625, and `detail_noise(837, 1/40, 4, 1.25)` is a +/// four-octave call at a fine input scale. A transcription error in the seed +/// offset, the input scale or the plasma's asymmetric seeds shows in a +/// different one of them. +/// +/// **The TypeScript spec for these same three fields asserts BOUNDS** +/// (`4e-4`, `4e-3`, `1e-4`), which is the #162 pathology and is what #256 +/// exists to remove. This side counts exact f32 matches instead, so a change +/// that moves a residual without moving a match is visible here and invisible +/// there. +/// +/// **And one of those bounds is already stale by six orders of magnitude.** +/// `test/vulcanusHelpers.spec.ts` bounds `vulcanus_wobble_x` at `4e-4` and its +/// comment records "measured worst 2.32e-4 (deep-field point)". Re-measured on +/// this tree the worst residual is **exactly 0** at all 38 positions - the +/// field is bit-exact and the bound would not notice it losing four digits. +/// Recorded here rather than fixed there: the bound is not wrong, it is inert, +/// and rewriting it belongs to #256 with the other 86. +#[test] +fn reproduces_the_vulcanus_helper_layer_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-helpers.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 38, "fixture size"); + let seed0 = fixture.get("seed0").as_f64() as u32; + + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let helpers = VulcanusHelpers::new(&ctx); + let mountain_plasma = helpers.plasma(102, 2.5, 10.0, 125.0, 625.0); + let detail = helpers.detail_noise(837, 1.0 / 40.0, 4.0, 1.25); + + let mut wobble_x = Vec::with_capacity(positions.len()); + let mut plasma = Vec::with_capacity(positions.len()); + let mut detail_out = Vec::with_capacity(positions.len()); + for p in positions { + let (x, y) = (p.get("x").as_f64(), p.get("y").as_f64()); + wobble_x.push(helpers.wobble_x(x, y)); + plasma.push(mountain_plasma.eval(x, y)); + detail_out.push(f64::from(detail.eval(x, y))); + } + + // Frozen exact counts, measured 2026-08-19 and matched field for field + // against the TypeScript on the same fixture - 38, 7 and 1, with the same + // worst residuals. They describe the distance BOTH ports still sit from the + // game, not a gap between them. + for (key, want_exact, got) in [ + // Bit-exact at all 38, worst residual exactly 0, deep-field point + // included. Two octaves at magnitude 4 is the shallowest call the + // fixture grades and nothing accumulates. + ("wobbleX", 38usize, &wobble_x), + // `abs(A - B)` of two basis calls at magnitudes 125 and 625; worst + // residual 2.807e-3. The output scales amplify the coordinate floor, + // which is why this is the loosest of the three in absolute terms while + // still beating `detailNoise` on exact matches. + ("mountainPlasma", 7, &plasma), + // Four octaves at input scale 0.8; worst residual 7.778e-5. The + // SMALLEST residual of the three and the FEWEST exact matches, which is + // the whole argument for counting matches rather than bounding error: + // a field can be uniformly close and almost never right. + ("detailNoise", 1, &detail_out), + ] { + assert_eq!( + score_vulcanus(got, fixture.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 38" + ); + } + + // `vulcanus_scale_multiplier` is a program constant and the fixture repeats + // it at every position, so the check is that it is ONE value and that it is + // ours. It is 1 at the game's default preset, which is what pins the + // neutral slider at 1 rather than 0. + let multipliers = fixture.get("scaleMultiplier").as_f64_array(); + assert!( + multipliers.iter().all(|m| *m == multipliers[0]), + "scale multiplier is not constant" + ); + assert_eq!(multipliers[0], 1.0); + assert_eq!(helpers.scale_multiplier, 1.0); +} diff --git a/crates/fmw-noise/src/multioctave_noise.rs b/crates/fmw-noise/src/multioctave_noise.rs index f664fd3f..f4f4d9f4 100644 --- a/crates/fmw-noise/src/multioctave_noise.rs +++ b/crates/fmw-noise/src/multioctave_noise.rs @@ -165,3 +165,43 @@ pub fn multioctave_noise(x: f64, y: f64, params: &MultioctaveParams) -> f32 { let tables = tables_from_seed(params.seed0, params.seed1); sum_octaves(x, y, &octave_terms(params), &tables) } + +/// One multioctave call with its seed tables and octave terms already derived. +/// +/// **This is the shape every renderer needs, and its absence was a measured +/// bug.** [`multioctave_noise`] re-derives both on every call, and +/// [`tables_from_seed`] runs a PRNG over three 256-byte tables; Fulgora's chain +/// makes eight such calls per pixel. Building them per point measured **1.15x** +/// against the TypeScript, which builds them once in a closure. Hoisting is +/// what the ratio in phase 3's pull request is. +/// +/// Results are identical either way, so nothing in tiers 1 to 3 could see it. +/// +/// No `Debug` or `Clone`: [`OctaveTerms`] has neither, and deriving them here +/// would mean giving them to derived state whose shape is an implementation +/// detail. +/// +/// Lives here rather than beside the first planet that needed it. It landed in +/// `expressions::fulgora_shared` in phase 3 and four Fulgora modules already +/// imported it from there; Vulcanus (#225) needs it too, and a Vulcanus module +/// reaching into a Fulgora one for a general multioctave utility would be the +/// wrong shape. Nothing about it is planet-specific. +pub struct Prepared { + terms: OctaveTerms, + tables: BasisNoiseTables, +} + +impl Prepared { + #[must_use] + pub fn new(params: &MultioctaveParams) -> Self { + Self { + terms: octave_terms(params), + tables: tables_from_seed(params.seed0, params.seed1), + } + } + + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> f32 { + sum_octaves(x, y, &self.terms, &self.tables) + } +} diff --git a/scripts/verify-rust.sh b/scripts/verify-rust.sh index 5f2f9a9a..dc066242 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -96,6 +96,10 @@ POISONED_TESTS=( fixtures::reproduces_the_fulgora_scrap_probability_at_every_captured_position fixtures::puts_every_fulgora_tile_where_the_game_puts_it + # Phase 5 (#225), Vulcanus. Its hook is `poison::f64_result` on the plasma + # output; the two multioctave fields inherit `basis_noise`'s own hook. + fixtures::reproduces_the_vulcanus_helper_layer_at_every_captured_position + tiles::fulgora_catalog::tests::an_exact_tie_resolves_to_the_earlier_tile_in_land_order ) for t in "${POISONED_TESTS[@]}"; do From 791c4b72bdd5769c28db5749c66373676db76b3c Mon Sep 17 00:00:00 2001 From: Eric J Date: Wed, 19 Aug 2026 15:18:43 -0700 Subject: [PATCH 2/5] Port phase 5: Vulcanus crack and climate layers (#225) Ports src/noise/expressions/vulcanusCracks.ts and vulcanusClimate.ts - the five crack and flood fields the elevation chain samples, and the two climate fields. Both stack on the helper layer that landed in the previous commit. Tier 1 grades all seven as frozen exact f32 match counts out of 61, each with its worst residual: hairlineCracks 3 1.853e-3 floodCracksA 15 4.440e-4 floodCracksB 40 1.122e-4 floodPaths 10 5.460e-4 floodBasaltsFunc 8 6.387e-4 aux 40 4.584e-4 moisture 20 1.117e-4 The TypeScript was measured against the same fixtures and reaches all seven identically, so these describe the distance both ports sit from the game rather than a gap between them. hairlineCracks at 3 of 61 is the finding, and it points at #269. It is the SHALLOWEST expression in the layer - a bare plasma with nothing composed on top - so its weakness cannot come from anything this file builds. plasma is two basis_noise_expr results subtracted, and that adapter returns the un-narrowed f64 product #269 is about. The scales here are 0.3 * 0.325 and 0.6 * 0.325, neither exact in f32, which is precisely the case #269 records the oracle-basis fixture as blind to by construction: that fixture was captured at output_scale 1 and input_scale 0.125, both exact. So this is a second independent fixture pointing at the same adapter. Do not chase these five counts before #269 is settled. The two climate counts are flattered by their clamp. Both fields clamp to [0, 1] and every position where the clamp saturates is exact for free, because both ports and the game all return the bound itself. Read 40 of 61 as an upper bound on what the arithmetic achieves rather than as a measure of it. vulcanus_temperature is deliberately not ported. It reads vulcanus_elev, which arrives with the elevation chain, and wiring it half-finished would mean a field graded against nothing. Every min and max goes through eval::math::min2/max2 with the argument order the TypeScript writes, per the signed-zero divergence recorded in CLAUDE.md. This layer has four such sites. One unit test earned its comment by failing first, and the port was right: aux is min(abs(noise), 0.3 - 0.6 * flood_paths), so nudging flood_paths DOWN raises the arm the min does not select and nothing moves. The dependency is real but observable in one direction only, and that failure looks exactly like a missing wire. Both new tier-1 tests were watched going red under --features poison. engine.wasm is unchanged and still byte-matches its source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --- crates/fmw-noise/src/expressions/mod.rs | 2 + .../src/expressions/vulcanus_climate.rs | 182 +++++++++++++++ .../src/expressions/vulcanus_cracks.rs | 215 ++++++++++++++++++ crates/fmw-noise/src/fixtures.rs | 121 ++++++++++ scripts/verify-rust.sh | 2 + 5 files changed, 522 insertions(+) create mode 100644 crates/fmw-noise/src/expressions/vulcanus_climate.rs create mode 100644 crates/fmw-noise/src/expressions/vulcanus_cracks.rs diff --git a/crates/fmw-noise/src/expressions/mod.rs b/crates/fmw-noise/src/expressions/mod.rs index dc285a66..be47f402 100644 --- a/crates/fmw-noise/src/expressions/mod.rs +++ b/crates/fmw-noise/src/expressions/mod.rs @@ -15,5 +15,7 @@ pub mod fulgora_scrap; pub mod fulgora_shared; pub mod fulgora_stack; pub mod starting_spot_at_angle; +pub mod vulcanus_climate; +pub mod vulcanus_cracks; pub mod vulcanus_helpers; pub mod vulcanus_seed; diff --git a/crates/fmw-noise/src/expressions/vulcanus_climate.rs b/crates/fmw-noise/src/expressions/vulcanus_climate.rs new file mode 100644 index 00000000..08a3b494 --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_climate.rs @@ -0,0 +1,182 @@ +//! Vulcanus's climate fields, ported from +//! `src/noise/expressions/vulcanusClimate.ts`. +//! +//! `vulcanus_aux` and `vulcanus_moisture`, transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` lines ~117-159. +//! +//! **`vulcanus_temperature` is deliberately absent**, on the same reasoning the +//! TypeScript records: it reads `vulcanus_elev`, which does not exist until the +//! elevation chain lands, and wiring it half-finished would mean a field graded +//! against nothing. It joins this module when elevation does. +//! +//! Both fields are bare `multioctave_noise` calls with no world-space offset - +//! unlike Nauvis, whose `moisture` and `aux` are built on +//! `quick_multioctave_noise` - so they go straight to [`Prepared`] rather than +//! through a helper wrapper. +//! +//! The two crack fields they read (`vulcanus_flood_paths` for `aux`, +//! `vulcanus_flood_cracks_a` for `moisture`) arrive as an already-evaluated +//! [`CrackFields`] rather than being recomputed, which is what keeps the whole +//! Vulcanus chain a single pass per point. + +use crate::eval::math::{clamp, min2}; +use crate::expressions::vulcanus_cracks::CrackFields; +use crate::multioctave_noise::{MultioctaveParams, Prepared}; +use crate::poison; + +/// Every named expression this layer defines, at one position. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct ClimateFields { + pub aux: f64, + pub moisture: f64, +} + +/// The per-render constants of Vulcanus's climate layer. +pub struct VulcanusClimate { + aux: Prepared, + moisture_a: Prepared, + moisture_b: Prepared, +} + +impl VulcanusClimate { + /// Build the layer for one seed. + #[must_use] + pub fn new(seed0: u32) -> Self { + Self { + aux: Prepared::new(&MultioctaveParams { + seed0, + seed1: 2, + octaves: 5.0, + persistence: 0.6, + input_scale: 0.2, + output_scale: 0.6, + }), + moisture_a: Prepared::new(&MultioctaveParams { + seed0, + seed1: 4, + octaves: 2.0, + persistence: 0.6, + input_scale: 0.025, + output_scale: 0.25, + }), + // The odd input scale is a literal in the Lua, not a rounded + // version of anything. Transcribed digit for digit. + moisture_b: Prepared::new(&MultioctaveParams { + seed0, + seed1: 400, + octaves: 3.0, + persistence: 0.62, + input_scale: 0.051_144_353, + output_scale: 0.25, + }), + } + } + + /// Evaluate both fields at one position, given this point's crack fields. + #[must_use] + pub fn eval(&self, x: f64, y: f64, cracks: &CrackFields) -> ClimateFields { + // `min` before `clamp`, and in this argument order - see the note in + // `vulcanus_cracks` on why `min2` rather than `f64::min`. + let aux = clamp( + min2( + f64::from(self.aux.eval(x, y)).abs(), + 0.3 - 0.6 * cracks.flood_paths, + ), + 0.0, + 1.0, + ); + + // Three subtractions from 1, left to right, then clamped. Each `abs` + // applies to its own noise term rather than to the running total. + let moisture = clamp( + 1.0 - f64::from(self.moisture_a.eval(x, y)).abs() + - f64::from(self.moisture_b.eval(x, y)).abs() + - 0.2 * cracks.flood_cracks_a, + 0.0, + 1.0, + ); + + ClimateFields { + aux: poison::f64_result(aux), + moisture, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::ctx::EvalCtx; + use crate::expressions::vulcanus_cracks::VulcanusCracks; + use crate::expressions::vulcanus_helpers::VulcanusHelpers; + + fn layers() -> (VulcanusCracks, VulcanusClimate) { + let helpers = VulcanusHelpers::new(&EvalCtx::new(123_456)); + let cracks = VulcanusCracks::new(&helpers); + (cracks, VulcanusClimate::new(123_456)) + } + + /// Both fields are clamped to `[0, 1]`, which every downstream biome + /// comparison assumes. + #[test] + fn both_fields_stay_inside_the_unit_interval() { + let (cracks, climate) = layers(); + for k in 0..200 { + let (x, y) = (f64::from(k) * 9.5 - 700.0, f64::from(k) * -4.75 + 250.0); + let f = climate.eval(x, y, &cracks.eval(x, y)); + assert!((0.0..=1.0).contains(&f.aux), "aux {} at {x},{y}", f.aux); + assert!( + (0.0..=1.0).contains(&f.moisture), + "moisture {} at {x},{y}", + f.moisture + ); + } + } + + /// `aux` reads `flood_paths` and `moisture` reads `flood_cracks_a`. Wiring + /// them the other way round would still produce plausible climate, so each + /// dependency is checked by moving one crack field and watching only the + /// field that reads it change. + /// + /// **The `flood_paths` nudge has to go UP**, and that is a property of the + /// expression rather than a detail of this test. `aux` is + /// `min(abs(noise), 0.3 - 0.6 * flood_paths)`, so lowering `flood_paths` + /// raises the second arm and the `min` simply keeps choosing the first - + /// the dependency is real but invisible in that direction. Written down + /// because the first draft moved it down, watched `aux` not move, and the + /// failure looks exactly like a missing wire. + #[test] + fn each_climate_field_reads_its_own_crack_field() { + let (cracks, climate) = layers(); + let (x, y) = (137.5, -244.25); + let base_cracks = cracks.eval(x, y); + let base = climate.eval(x, y, &base_cracks); + + let mut moved_paths = base_cracks; + moved_paths.flood_paths += 2.0; + let a = climate.eval(x, y, &moved_paths); + assert_ne!(a.aux, base.aux, "aux ignored flood_paths"); + assert_eq!(a.moisture, base.moisture, "moisture read flood_paths"); + + let mut moved_cracks_a = base_cracks; + moved_cracks_a.flood_cracks_a += 1.0; + let m = climate.eval(x, y, &moved_cracks_a); + assert_eq!(m.aux, base.aux, "aux read flood_cracks_a"); + assert_ne!(m.moisture, base.moisture, "moisture ignored flood_cracks_a"); + } + + /// The two moisture terms are different fields, not one read twice. Their + /// seeds are 4 and 400, which is an easy digit to drop. + #[test] + fn the_two_moisture_terms_are_different_fields() { + let climate = VulcanusClimate::new(123_456); + let mut differ = 0usize; + for k in 1..64 { + let (x, y) = (f64::from(k) * 7.5, f64::from(k) * -3.25); + if climate.moisture_a.eval(x, y) != climate.moisture_b.eval(x, y) { + differ += 1; + } + } + assert!(differ > 60, "only {differ} of 63 readings differ"); + } +} diff --git a/crates/fmw-noise/src/expressions/vulcanus_cracks.rs b/crates/fmw-noise/src/expressions/vulcanus_cracks.rs new file mode 100644 index 00000000..ec6966a3 --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_cracks.rs @@ -0,0 +1,215 @@ +//! Vulcanus's crack and flood helpers, ported from +//! `src/noise/expressions/vulcanusCracks.ts`. +//! +//! `vulcanus_hairline_cracks`, `vulcanus_flood_cracks_a`, +//! `vulcanus_flood_cracks_b`, `vulcanus_flood_paths` and +//! `vulcanus_flood_basalts_func`, transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` lines ~490-523. +//! +//! This is the lava and plate pattern the elevation chain samples. It is pure +//! noise with no spawn dependency, stacked entirely on +//! [`VulcanusHelpers`](super::vulcanus_helpers::VulcanusHelpers)'s `plasma` and +//! `detail_noise`. +//! +//! ## Every `min` and `max` here goes through `min2`/`max2` +//! +//! Not `f64::min`/`f64::max`, and the argument order is kept exactly as the +//! TypeScript writes it. The two differ on NaN and on signed zero, where IEEE +//! 754-2019 `maximumNumber` is explicitly allowed to return either operand - +//! and Fulgora's `tile_ruin_paving` folded to a different tier-2 checksum for +//! precisely that reason, with both arms zero at different signs. A tolerance +//! cannot see it and neither can tier 1; only an order-sensitive fold over raw +//! bits can. +//! +//! `flood_basalts_func` nests `min(max(a, b), c)` and `flood_paths` takes +//! `min(0, ...)`, so this layer has four such sites. +//! +//! ## No memo, evaluated top to bottom in one pass +//! +//! The TypeScript memoizes each field because `flood_basalts_func` reads all +//! four of the others and it builds a DAG of lazy closures. [`CrackFields`] +//! computes them in dependency order into locals instead, which is what the +//! memo achieves bit-identically. Every read is at the SAME `(x, y)`. + +use crate::eval::math::{clamp, lerp, max2, min2}; +use crate::expressions::vulcanus_helpers::{Plasma, VulcanusHelpers}; +use crate::multioctave_noise::Prepared; +use crate::poison; + +/// `vulcanus_cracks_scale`. +/// +/// A bare number literal in the Lua - the file's "used to be +/// segmentation_multiplier" constant. Every scale argument in this layer is +/// this constant times a per-call factor, transcribed as written. +pub const VULCANUS_CRACKS_SCALE: f64 = 0.325; + +/// Every named expression this layer defines, at one position. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CrackFields { + pub hairline_cracks: f64, + pub flood_cracks_a: f64, + pub flood_cracks_b: f64, + pub flood_paths: f64, + pub flood_basalts_func: f64, +} + +/// The per-render constants of Vulcanus's crack layer. +pub struct VulcanusCracks { + hairline: Plasma, + crack_a1: Plasma, + crack_a2: Plasma, + crack_a_mix: Prepared, + crack_b1: Plasma, + crack_b2: Plasma, + crack_b_mix: Prepared, + path_plasma: Plasma, + path_detail: Prepared, +} + +impl VulcanusCracks { + /// Build the layer from the helper closures it stacks on. + #[must_use] + pub fn new(helpers: &VulcanusHelpers) -> Self { + let cs = VULCANUS_CRACKS_SCALE; + Self { + hairline: helpers.plasma(15_223, 0.3 * cs, 0.6 * cs, 0.6, 1.0), + crack_a1: helpers.plasma(7_543, 2.5 * cs, 4.0 * cs, 0.5, 1.0), + crack_a2: helpers.plasma(7_443, 1.5 * cs, 3.5 * cs, 0.5, 1.0), + crack_a_mix: helpers.detail_noise(241, 2.0 * cs, 2.0, 0.25), + crack_b1: helpers.plasma(12_223, 2.0 * cs, 3.0 * cs, 0.5, 1.0), + crack_b2: helpers.plasma(152, 1.0 * cs, 1.5 * cs, 0.25, 0.5), + crack_b_mix: helpers.detail_noise(821, 6.0 * cs, 2.0, 0.5), + path_plasma: helpers.plasma(1_543, 1.5 * cs, 3.0 * cs, 0.5, 1.0), + path_detail: helpers.detail_noise(121, cs * 4.0, 2.0, 0.5), + } + } + + /// Evaluate every field of this layer at one position. + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> CrackFields { + let hairline_cracks = self.hairline.eval(x, y); + + // `lerp` toward 1, so a high mix value floods the cracks shut. The + // un-narrowed `lerp`, matching the TypeScript - this layer is not one + // of the per-operation narrowed ones. + let flood_cracks_a = lerp( + min2(self.crack_a1.eval(x, y), self.crack_a2.eval(x, y)), + 1.0, + clamp(f64::from(self.crack_a_mix.eval(x, y)), 0.0, 1.0), + ); + + // The other way round: `lerp` FROM 1 toward the crack pattern, and the + // `- 0.5` applies to the min rather than to the lerp's result. + let flood_cracks_b = lerp( + 1.0, + min2(self.crack_b1.eval(x, y), self.crack_b2.eval(x, y)) - 0.5, + clamp(0.2 + f64::from(self.crack_b_mix.eval(x, y)), 0.0, 1.0), + ); + + // `min(0, detail)` only ever subtracts, so the detail term deepens the + // paths and never fills them. + let flood_paths = + 0.4 - self.path_plasma.eval(x, y) + min2(0.0, f64::from(self.path_detail.eval(x, y))); + + let flood_basalts_func = min2(max2(flood_cracks_a - 0.125, flood_paths), flood_cracks_b) + + 0.3 * min2(0.5, hairline_cracks); + + CrackFields { + hairline_cracks, + flood_cracks_a, + flood_cracks_b, + flood_paths, + flood_basalts_func: poison::f64_result(flood_basalts_func), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::eval::ctx::EvalCtx; + + fn layer() -> (VulcanusHelpers, VulcanusCracks) { + let helpers = VulcanusHelpers::new(&EvalCtx::new(123_456)); + let cracks = VulcanusCracks::new(&helpers); + (helpers, cracks) + } + + fn grid() -> impl Iterator { + (0..80).map(|k| (f64::from(k) * 11.5 - 400.0, f64::from(k) * -6.25 + 150.0)) + } + + /// The scale constant is 0.325 and every call multiplies it rather than + /// replacing it. Pinned because it is a bare literal in the Lua with no + /// name to check it against. + #[test] + fn the_cracks_scale_is_the_literal_the_lua_carries() { + assert_eq!(VULCANUS_CRACKS_SCALE, 0.325); + } + + /// `flood_paths`'s detail term is `min(0, d)`, so it can only subtract. + /// Writing `max` there would brighten the paths and still look plausible. + #[test] + fn the_flood_path_detail_term_never_adds() { + let (helpers, cracks) = layer(); + let detail = helpers.detail_noise(121, VULCANUS_CRACKS_SCALE * 4.0, 2.0, 0.5); + let plasma = helpers.plasma( + 1_543, + 1.5 * VULCANUS_CRACKS_SCALE, + 3.0 * VULCANUS_CRACKS_SCALE, + 0.5, + 1.0, + ); + let mut saw_negative_detail = 0usize; + for (x, y) in grid() { + let d = f64::from(detail.eval(x, y)); + let got = cracks.eval(x, y).flood_paths; + assert!(got <= 0.4 - plasma.eval(x, y) + 1e-12); + if d < 0.0 { + saw_negative_detail += 1; + } + } + // Non-vacuity: the clamp is actually reached, so the assertion above is + // not holding for want of a negative sample. + assert!( + saw_negative_detail > 10, + "only {saw_negative_detail} of 80 samples had a negative detail term" + ); + } + + /// `flood_cracks_a` lerps TOWARD 1 and `flood_cracks_b` lerps FROM it. The + /// two are easy to transcribe the same way round, and the fixture would + /// catch it - but not say why. + #[test] + fn the_two_flood_cracks_lerp_in_opposite_directions() { + let (_, cracks) = layer(); + let mut a_above_b = 0usize; + for (x, y) in grid() { + let f = cracks.eval(x, y); + if f.flood_cracks_a > f.flood_cracks_b { + a_above_b += 1; + } + } + // They are different fields with different seeds, so this is a shape + // check rather than an identity: neither dominates everywhere. + assert!( + a_above_b > 0 && a_above_b < 80, + "flood_cracks_a exceeds flood_cracks_b at {a_above_b} of 80 points" + ); + } + + /// `flood_basalts_func`'s hairline term is capped at `0.3 * 0.5`, which is + /// what keeps the hairline pattern a texture rather than a feature. + #[test] + fn the_hairline_contribution_is_capped() { + let (_, cracks) = layer(); + for (x, y) in grid() { + let f = cracks.eval(x, y); + let base = min2( + max2(f.flood_cracks_a - 0.125, f.flood_paths), + f.flood_cracks_b, + ); + assert!(f.flood_basalts_func - base <= 0.3 * 0.5 + 1e-12); + } + } +} diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index 0d6df4ee..14e83bf4 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2385,3 +2385,124 @@ fn reproduces_the_vulcanus_helper_layer_at_every_captured_position() { assert_eq!(multipliers[0], 1.0); assert_eq!(helpers.scale_multiplier, 1.0); } + +use crate::expressions::vulcanus_climate::{ClimateFields, VulcanusClimate}; +use crate::expressions::vulcanus_cracks::{CrackFields, VulcanusCracks}; + +/// Evaluate the Vulcanus helper, crack and climate layers once at every +/// fixture position, in dependency order. +/// +/// One sweep rather than one per fixture, because climate reads the crack +/// fields at the SAME point and recomputing them would be both slower and a +/// second place for the wiring to be wrong. +fn vulcanus_sweep(seed0: u32, positions: &[Json]) -> (Vec, Vec) { + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let climate = VulcanusClimate::new(seed0); + + let mut c_out = Vec::with_capacity(positions.len()); + let mut k_out = Vec::with_capacity(positions.len()); + for p in positions { + let (x, y) = (p.get("x").as_f64(), p.get("y").as_f64()); + let c = cracks.eval(x, y); + k_out.push(climate.eval(x, y, &c)); + c_out.push(c); + } + (c_out, k_out) +} + +/// The crack and flood layer, all five named expressions. +/// +/// `flood_basalts_func` is the composite the elevation chain samples and the +/// other four are its inputs, so all five are graded rather than just the +/// composite: a transcription error in one input localises here instead of +/// arriving blended into the field elevation reads. +#[test] +fn reproduces_the_vulcanus_crack_layer_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-cracks.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 61, "fixture size"); + let (cracks, _) = vulcanus_sweep(fixture.get("seed0").as_f64() as u32, positions); + + // Frozen exact counts, measured 2026-08-19 against the oracle and matched + // field for field against the TypeScript on the same fixture - 3, 15, 40, + // 10 and 8, with the same worst residuals. They are the distance BOTH ports + // sit from the game, not a gap between them. + // + // Worst residuals, in the same order: 1.853e-3, 4.440e-4, 1.122e-4, + // 5.460e-4, 6.387e-4. + // + // `hairlineCracks` at 3 of 61 is the weakest and it is the SHALLOWEST + // expression in the layer - a bare `plasma` with nothing composed on top. + // That points at the plasma adapter rather than at anything this file + // builds, and specifically at #269: `basis_noise_expr` returns an + // un-narrowed f64 product where the game narrows to f32, and `plasma` is + // two of those subtracted. The crack layer's scales are 0.3 * 0.325 and + // 0.6 * 0.325, neither exact in f32, so this layer is exactly the case the + // #269 comment says the existing `oracle-basis` fixture is blind to. Do not + // chase these five counts before that one is settled. + type C = CrackFields; + for (key, want_exact, select) in [ + ( + "hairlineCracks", + 3usize, + &(|f: &C| f.hairline_cracks) as &dyn Fn(&C) -> f64, + ), + ("floodCracksA", 15, &|f| f.flood_cracks_a), + ("floodCracksB", 40, &|f| f.flood_cracks_b), + ("floodPaths", 10, &|f| f.flood_paths), + ("floodBasaltsFunc", 8, &|f| f.flood_basalts_func), + ] { + let got: Vec = cracks.iter().map(select).collect(); + assert_eq!( + score_vulcanus(&got, fixture.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 61" + ); + } +} + +/// The climate layer: `vulcanus_aux` and `vulcanus_moisture`. +/// +/// `vulcanus_temperature` is not here because it is not ported - it reads +/// `vulcanus_elev`, which arrives with the elevation chain. +#[test] +fn reproduces_the_vulcanus_climate_layer_at_every_captured_position() { + let cracks_fx = load("test/fixtures/oracle-vulcanus-cracks.seed123456.json"); + let fixture = load("test/fixtures/oracle-vulcanus-climate.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 61, "fixture size"); + + // Climate is graded against crack values evaluated at these positions, so + // if the two fixtures' position lists ever drift apart the comparison + // silently stops meaning anything. + let crack_positions = cracks_fx.get("positions").as_array(); + assert_eq!(positions.len(), crack_positions.len()); + for (a, b) in positions.iter().zip(crack_positions) { + assert_eq!(a.get("x").as_f64(), b.get("x").as_f64()); + assert_eq!(a.get("y").as_f64(), b.get("y").as_f64()); + } + + let (_, climate) = vulcanus_sweep(fixture.get("seed0").as_f64() as u32, positions); + + // Frozen exact counts, measured as above and matched by the TypeScript at + // 40 and 20. Worst residuals 4.584e-4 and 1.117e-4. + // + // Both are CLAMPED to [0, 1], which flatters them: every position the clamp + // saturates is exact for free, because both ports and the game all return + // the bound itself. Read 40 of 61 as an upper bound on what the arithmetic + // achieves rather than as a measure of it. + type K = ClimateFields; + for (key, want_exact, select) in [ + ("aux", 40usize, &(|f: &K| f.aux) as &dyn Fn(&K) -> f64), + ("moisture", 20, &|f| f.moisture), + ] { + let got: Vec = climate.iter().map(select).collect(); + assert_eq!( + score_vulcanus(&got, fixture.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 61" + ); + } +} diff --git a/scripts/verify-rust.sh b/scripts/verify-rust.sh index dc066242..ced0c447 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -99,6 +99,8 @@ POISONED_TESTS=( # Phase 5 (#225), Vulcanus. Its hook is `poison::f64_result` on the plasma # output; the two multioctave fields inherit `basis_noise`'s own hook. fixtures::reproduces_the_vulcanus_helper_layer_at_every_captured_position + fixtures::reproduces_the_vulcanus_crack_layer_at_every_captured_position + fixtures::reproduces_the_vulcanus_climate_layer_at_every_captured_position tiles::fulgora_catalog::tests::an_exact_tie_resolves_to_the_earlier_tile_in_land_order ) From 407a5b4ae47d95030a83f1f9c13c70859e08f26f Mon Sep 17 00:00:00 2001 From: Eric J Date: Wed, 19 Aug 2026 15:55:54 -0700 Subject: [PATCH 3/5] Port phase 5: Vulcanus spawn, biomes and elevation (#225) Completes the chain to vulcanus_elevation. Ports vulcanusSpawn.ts, vulcanusBiomes.ts and vulcanusElevation.ts, and closes the vulcanus_temperature the climate layer deferred, since it reads vulcanus_elev and could not be ported until that existed. Tier 1 grades fourteen more fields as frozen exact f32 match counts. Every one was measured again on the TypeScript side against the same fixture and all fourteen agree, so they are the distance both ports sit from the game: spawn, of 410 startingArea 371, startingCircle 247, ashlandsStart 61 biomes, of 434 mountain_volcano_spots 359, mountains_raw_volcano 163, mountains/ashlands/basalts_biome_full 128/107/127, mountains/ashlands/basalts_biome 403/402/408 elevation, of 434 elev 113, elevation 113, temperature 196 Three readings worth keeping. The oracle cannot see the -500 clamp, and that was checked rather than assumed. vulcanus_elevation is max(-500, elev), and the captured elev bottoms out at -58.77 - so the two columns are the same field at all 434 positions, 0 of 434 differ, and a port that dropped the max would score 113 as well. Both are graded anyway because they separate for free the day a capture reaches a deep lake, and the clamp's real test lives in the module, where it constructs the case the fixture does not. The clamp flatters a count, now measurably. The three clamped biomes score 403, 402 and 408 against their own unclamped sources at 128, 107 and 127. Same quantity, times two, clamped: nothing improved between them, the clamp saturates at 0 or 1 over most of the map, and a saturated position is exact for free. The *_biome_full row is the port's real score. A discrete output scores like a discrete output. mountain_volcano_spots at 359 is the highest unclamped count in the Vulcanus port, and it is dominated by which single candidate survives per region - a choice a sub-ULP error almost never changes. That is the same property CLAUDE.md records for voronoi_cell_id. Elevation's worst residual is 1.332e-1, which is only alarming until the scale is read: the field spans -58 to +1024 here, so it is about 1.3e-4 relative, the same order as every layer upstream. An absolute bound would need re-tuning per field for scale alone, which is a third argument for counting matches. Notes on the port itself: - vulcanus_biomes keeps a real cache, unlike every layer so far. raw_spots at one point reads selected spots from up to four neighbouring regions, which is cross-position state, so the region cache is a RefCell and eval stays &self. BTreeMap rather than HashMap because a determinism-critical port should not carry a container with unspecified iteration order, even where nothing iterates it yet. - volcano_area is evaluated at every spot candidate and pulls the whole pre-volcano chain there. The TypeScript memoizes those; this recomputes them. Nothing on the render path reaches this layer yet, so it is correct-first by choice, and the note says to measure before that changes. - The mountains pre-volcano split is a named method rather than an inline local. Collapsing the two stages would be an infinite recursion, which announces itself, but reading volcano_area off the POST-volcano raw would not. - Three numbers in spawn are transcription traps and each has its own test: basalts_start's distance is a bare 250 where both its siblings scale by r, mountains takes half the distortion coefficient of the other two, and the three results carry different multipliers. - min(a, a) in vulcanus_ashlands_func is transcribed as written. The two basis calls are byte-identical in the Lua, so it is the identity, and a test says so - collapsing it later should be deliberate. - cliff_elevation is a separate entry point because multisample's offsets are in the CONSUMING program's grid units: the cliff generator's 4-tile lattice moves the field 16 tiles for a dx of 4, which #83 measured. All seven Vulcanus tier-1 tests were watched going red under --features poison. engine.wasm is unchanged and still byte-matches its source. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --- crates/fmw-noise/src/expressions/mod.rs | 3 + .../src/expressions/vulcanus_biomes.rs | 553 ++++++++++++++++++ .../src/expressions/vulcanus_elevation.rs | 367 ++++++++++++ .../src/expressions/vulcanus_spawn.rs | 336 +++++++++++ crates/fmw-noise/src/fixtures.rs | 244 ++++++++ scripts/verify-rust.sh | 4 + 6 files changed, 1507 insertions(+) create mode 100644 crates/fmw-noise/src/expressions/vulcanus_biomes.rs create mode 100644 crates/fmw-noise/src/expressions/vulcanus_elevation.rs create mode 100644 crates/fmw-noise/src/expressions/vulcanus_spawn.rs diff --git a/crates/fmw-noise/src/expressions/mod.rs b/crates/fmw-noise/src/expressions/mod.rs index be47f402..0de873cd 100644 --- a/crates/fmw-noise/src/expressions/mod.rs +++ b/crates/fmw-noise/src/expressions/mod.rs @@ -15,7 +15,10 @@ pub mod fulgora_scrap; pub mod fulgora_shared; pub mod fulgora_stack; pub mod starting_spot_at_angle; +pub mod vulcanus_biomes; pub mod vulcanus_climate; pub mod vulcanus_cracks; +pub mod vulcanus_elevation; pub mod vulcanus_helpers; pub mod vulcanus_seed; +pub mod vulcanus_spawn; diff --git a/crates/fmw-noise/src/expressions/vulcanus_biomes.rs b/crates/fmw-noise/src/expressions/vulcanus_biomes.rs new file mode 100644 index 00000000..f9f8270c --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_biomes.rs @@ -0,0 +1,553 @@ +//! Vulcanus's biome system, ported from +//! `src/noise/expressions/vulcanusBiomes.ts`. +//! +//! The radial biome-noise chain - noise, raw, full, clamped - for all three +//! biomes, plus `mountain_volcano_spots` and the spot-noise pipeline behind it. +//! Transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` lines ~229-389. +//! +//! ## The mountains split exists to break a cycle, and it is load-bearing +//! +//! `mountain_volcano_spots` depends on the mountains biome, and the mountains +//! biome folds the volcano field back in. The Lua breaks that with a PRE-volcano +//! stage: `volcano_area` reads `mountains_biome_full_pre_volcano`, and only the +//! POST-volcano raw feeds the three `*_biome_full` fields. +//! +//! Collapsing the two stages into one would be an infinite recursion rather than +//! a wrong number, so it announces itself - but writing `volcano_area` against +//! the post-volcano raw by mistake would not. That is why the pre-volcano stage +//! is a named method here rather than an inline local. +//! +//! ## No memo, except where the TypeScript's memo is not a memo +//! +//! The rest of this port evaluates a layer top to bottom into locals, because +//! every read is at the same `(x, y)`. This layer has one genuine exception: +//! `regionSpots` caches SELECTED SPOTS PER REGION, and `raw_spots` at one point +//! reads spots from up to four neighbouring regions. That is cross-position +//! state, so it is a real cache and it is kept - as a `RefCell`, so +//! that `eval` can stay `&self` and the density and favorability closures handed +//! to `select_spots` can borrow it immutably alongside. +//! +//! `BTreeMap` rather than `HashMap` deliberately: nothing here iterates the +//! cache, but a determinism-critical port should not carry a container whose +//! iteration order is unspecified, in case someone later adds a sweep over it. +//! +//! ## Cost +//! +//! `volcano_area` is evaluated at every spot candidate, and it pulls the whole +//! pre-volcano chain - six biome-noise octave stacks and the three spawn cones - +//! at that candidate's position. The TypeScript memoizes each of those; this +//! recomputes them. Nothing on the render path reaches this yet (`fmw-wasm` +//! exports nothing that does), so it is correct-first by choice. If this layer +//! ever joins a per-pixel render, that is the measurement to take first, and +//! `multioctave_noise`'s own docs record what happened last time a per-call +//! rebuild went unmeasured: 20x. + +use std::cell::RefCell; +use std::collections::BTreeMap; + +use crate::distance_from_nearest_point::{distance_from_nearest_point, Point}; +use crate::eval::ctx::EvalCtx; +use crate::eval::math::{clamp, lerp, max2, slider_rescale}; +use crate::expressions::starting_spot_at_angle::{starting_spot_at_angle, AngleTrig, StartingSpot}; +use crate::expressions::vulcanus_helpers::VulcanusHelpers; +use crate::expressions::vulcanus_spawn::{ + VulcanusSpawn, WobbleSums, VULCANUS_STARTING_AREA_RADIUS, +}; +use crate::multioctave_noise::Prepared; +use crate::poison; +use crate::spot_candidates::SpotRegionKey; +use crate::spot_selection::{select_spots, SelectedSpot, SpotSelectParams}; + +/// `vulcanus_biome_contrast = 2`. Higher means sharper biome transitions. +pub const VULCANUS_BIOME_CONTRAST: f64 = 2.0; + +/// `region_size` for `mountain_volcano_spots`. +/// +/// **256, not the 1024 the resource spot fields use.** A region size that is +/// wrong by a factor of four still produces spots, just the wrong ones, and +/// nothing about the output announces the cause. +const VOLCANO_REGION_SIZE: u64 = 256; + +/// Region index for a coordinate. Regions are centred on multiples of the size, +/// which is why this offsets by half before dividing. +fn region_index(c: f64) -> i64 { + ((c + VOLCANO_REGION_SIZE as f64 / 2.0) / VOLCANO_REGION_SIZE as f64).floor() as i64 +} + +/// The pre-volcano stage: everything `volcano_area` needs, and nothing that +/// depends on the volcano field. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct PreVolcano { + pub ashlands_raw: f64, + pub basalts_raw: f64, + pub mountains_raw_pre_volcano: f64, + pub mountains_biome_full_pre_volcano: f64, + pub starting_area: f64, +} + +/// Every named expression the oracle fixture grades, at one position. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct BiomeFields { + pub mountain_volcano_spots: f64, + pub mountains_raw_volcano: f64, + pub mountains_biome_full: f64, + pub ashlands_biome_full: f64, + pub basalts_biome_full: f64, + pub mountains_biome: f64, + pub ashlands_biome: f64, + pub basalts_biome: f64, +} + +/// The per-render constants of Vulcanus's biome system. +pub struct VulcanusBiomes<'a> { + helpers: &'a VulcanusHelpers, + spawn: &'a VulcanusSpawn, + seed0: u32, + starting_positions: Vec, + + mountains_near: Prepared, + mountains_far: Prepared, + ashlands_near: Prepared, + ashlands_far: Prepared, + basalts_near: Prepared, + basalts_far: Prepared, + + /// `0.3 + 0.7 * slider_rescale(size, 3) / slider_rescale(scale_multiplier, 3)`. + /// + /// Two nested `slider_rescale`s, because `scale_multiplier` is itself one. + /// Both are 1 at the default preset, so volcanism is 1 there. + pub volcanism: f64, + volcanism_sq: f64, + /// `f32(200 * volcanism)`, and the cull radius as well as the cone radius: + /// `maximum_spot_basement_radius` and `spot_radius_expression` are the same + /// expression in the Lua, so they coincide. + radius: f64, + /// `f32(radius * radius)` - the spot QUANTITY, narrowed. + quantity: f64, + /// `radius * radius` WITHOUT the narrowing, which is what the TypeScript + /// computes for the cull test. Not a typo for `quantity`: one is narrowed + /// and the other is not, and they are used for different things. + cull_sq: f64, + spacing: f64, + + protector: StartingSpot, + volcano_spot: StartingSpot, + + region_cache: RefCell>>, +} + +impl<'a> VulcanusBiomes<'a> { + /// Build the layer, taking the two volcano discs' trig from the caller. + #[must_use] + pub fn new( + ctx: &EvalCtx, + helpers: &'a VulcanusHelpers, + spawn: &'a VulcanusSpawn, + volcano_spot_trig: AngleTrig, + protector_trig: AngleTrig, + ) -> Self { + let r = VULCANUS_STARTING_AREA_RADIUS; + let seed0 = ctx.seed0; + + // The near scale is `seed1` at `scale * 0.5`; the far scale is + // `seed1 + 1000` at `scale`. Both halves of every pair, written out, so + // a wrong seed offset is visible rather than hidden in a loop. + let biome = |seed1: u32, scale: f64| helpers.biome_noise(seed1, scale); + + let volcanism = 0.3 + + (0.7 * f64::from(slider_rescale(ctx.vulcanus_volcanism_size, 3.0))) + / f64::from(slider_rescale(helpers.scale_multiplier, 3.0)); + let radius = f64::from((200.0 * volcanism) as f32); + + Self { + helpers, + spawn, + seed0, + starting_positions: ctx.starting_positions.clone(), + + mountains_near: biome(342, 60.0 * 0.5), + mountains_far: biome(342 + 1000, 60.0), + ashlands_near: biome(12_416, 40.0 * 0.5), + ashlands_far: biome(12_416 + 1000, 40.0), + basalts_near: biome(42_416, 80.0 * 0.5), + basalts_far: biome(42_416 + 1000, 80.0), + + volcanism, + volcanism_sq: volcanism * volcanism, + radius, + quantity: f64::from((radius * radius) as f32), + cull_sq: radius * radius, + spacing: 1500.0 * volcanism, + + // Neither disc narrows its distance or radius, unlike the three in + // `vulcanus_spawn`. Transcribed as written. + protector: StartingSpot { + trig: protector_trig, + distance: (400.0 * r) / 2.0, + radius: 800.0 * r, + }, + volcano_spot: StartingSpot { + trig: volcano_spot_trig, + distance: 400.0 * r, + radius: 200.0, + }, + + region_cache: RefCell::new(BTreeMap::new()), + } + } + + /// As [`VulcanusBiomes::new`], but computing both bearings with Rust's libm. + /// + /// The volcano disc sits at the mountains bearing; the protector is mirrored + /// by `180 * starting_direction`, so the two swap sides with the seed. + #[must_use] + pub fn with_host_trig( + ctx: &EvalCtx, + helpers: &'a VulcanusHelpers, + spawn: &'a VulcanusSpawn, + ) -> Self { + let protector_angle = spawn.mountains_angle + 180.0 * spawn.starting_direction; + Self::new( + ctx, + helpers, + spawn, + AngleTrig::from_degrees(spawn.mountains_angle), + AngleTrig::from_degrees(protector_angle), + ) + } + + /// `distance_from_nearest_point{points = starting_positions}`, uncapped. + fn distance(&self, x: f64, y: f64) -> f64 { + f64::from(distance_from_nearest_point( + x, + y, + &self.starting_positions, + f64::INFINITY, + )) + } + + /// `biome_multiscale(seed1, scale, bias)` with `bias = 0` at all three call + /// sites: the near and far noise blended by `clamp(distance / 10000, 0, 1)`. + fn multiscale(&self, near: &Prepared, far: &Prepared, x: f64, y: f64, bias: f64) -> f64 { + bias + lerp( + f64::from(near.eval(x, y)), + f64::from(far.eval(x, y)), + clamp(self.distance(x, y) / 10_000.0, 0.0, 1.0), + ) + } + + /// The pre-volcano stage of the biome chain. + #[must_use] + pub fn pre_volcano(&self, x: f64, y: f64) -> PreVolcano { + let wobble = WobbleSums::at(self.helpers, x, y); + let spawn = self.spawn.eval(x, y, wobble); + + let mountains_noise = self.multiscale(&self.mountains_near, &self.mountains_far, x, y, 0.0); + let ashlands_noise = self.multiscale(&self.ashlands_near, &self.ashlands_far, x, y, 0.0); + let basalts_noise = self.multiscale(&self.basalts_near, &self.basalts_far, x, y, 0.0); + + let blend = clamp(2.0 * spawn.starting_area, 0.0, 1.0); + + // Each biome's starting weight is a SIGN PATTERN over the three start + // blobs: its own positive, the other two negative. Written out per + // biome rather than generated, because a sign error here is a plausible + // map rather than a broken one. + let ashlands_raw = lerp( + ashlands_noise, + -spawn.mountains_start + spawn.ashlands_start - spawn.basalts_start, + blend, + ); + let basalts_raw = lerp( + basalts_noise, + -spawn.mountains_start - spawn.ashlands_start + spawn.basalts_start, + blend, + ); + let mountains_raw_pre_volcano = lerp( + mountains_noise, + spawn.mountains_start - spawn.ashlands_start - spawn.basalts_start, + blend, + ); + + PreVolcano { + ashlands_raw, + basalts_raw, + mountains_raw_pre_volcano, + mountains_biome_full_pre_volcano: mountains_raw_pre_volcano + - max2(ashlands_raw, basalts_raw), + starting_area: spawn.starting_area, + } + } + + /// `volcano_area = lerp(mountains_biome_full_pre_volcano, 0, starting_area)`. + /// + /// Lerping TOWARD zero as the starting area rises is what keeps volcanoes + /// out of spawn. + #[must_use] + pub fn volcano_area(&self, x: f64, y: f64) -> f64 { + let pre = self.pre_volcano(x, y); + lerp(pre.mountains_biome_full_pre_volcano, 0.0, pre.starting_area) + } + + /// The spot-noise query offset. The SAME offset is used as the + /// `x_distortion` of both volcano discs below. + fn query_offset(&self, x: f64, y: f64) -> (f64, f64) { + let h = self.helpers; + ( + h.wobble_x(x, y) / 2.0 + h.wobble_large_x(x, y) / 12.0 + h.wobble_huge_x(x, y) / 80.0, + h.wobble_y(x, y) / 2.0 + h.wobble_large_y(x, y) / 12.0 + h.wobble_huge_y(x, y) / 80.0, + ) + } + + /// The selected spots of one region, computed once and cached. + fn region_spots(&self, region_x: i64, region_y: i64) -> Vec { + if let Some(hit) = self.region_cache.borrow().get(&(region_x, region_y)) { + return hit.clone(); + } + let key = SpotRegionKey { + seed0: self.seed0, + seed1: 1, + region_x, + region_y, + }; + let density = |x: f64, y: f64| self.volcano_area(x, y) / self.volcanism_sq; + let quantity = |_x: f64, _y: f64| self.quantity; + let favorability = |x: f64, y: f64| self.volcano_area(x, y); + let spots = select_spots( + &key, + &SpotSelectParams { + region_size: VOLCANO_REGION_SIZE, + candidate_spot_count: 1, + spacing: self.spacing, + skip_span: 1, + skip_offset: 0, + hard_region_target_quantity: false, + density: &density, + quantity: &quantity, + favorability: &favorability, + quantity_batch: None, + }, + ); + self.region_cache + .borrow_mut() + .insert((region_x, region_y), spots.clone()); + spots + } + + /// `raw_spots` - the spot-noise field, a max of cones over a basement of 0. + /// + /// With `quantity = radius^2` the cone peak `3q / (pi r^2)` collapses to the + /// constant `3 / pi`, so density and favorability only decide WHICH single + /// candidate per region survives, never how tall it is. The arithmetic is + /// still written out per operation in f32, because that constant is only + /// constant in exact arithmetic. + #[must_use] + pub fn raw_spots(&self, x: f64, y: f64) -> f64 { + let (off_x, off_y) = self.query_offset(x, y); + let (qx, qy) = (x + off_x, y + off_y); + + let mut best = 0.0; // basement_value = 0 + for region_x in region_index(qx - self.radius)..=region_index(qx + self.radius) { + for region_y in region_index(qy - self.radius)..=region_index(qy + self.radius) { + for s in self.region_spots(region_x, region_y) { + let dx = qx - s.x as f64; + let dy = qy - s.y as f64; + let d2 = dx * dx + dy * dy; + if d2 > self.cull_sq { + continue; + } + let numerator = f64::from((3.0 * s.quantity) as f32); + let area = f64::from( + (f64::from((std::f64::consts::PI * self.radius) as f32) * self.radius) + as f32, + ); + let peak = f64::from((numerator / area) as f32); + let slope = f64::from((peak / self.radius) as f32); + let cone = f64::from( + (peak - f64::from((f64::from(d2.sqrt() as f32) * slope) as f32)) as f32, + ); + if cone > best { + best = cone; + } + } + } + } + best + } + + /// `mountain_volcano_spots = max(starting_volcano_spot, raw_spots - starting_protector)`. + #[must_use] + pub fn mountain_volcano_spots(&self, x: f64, y: f64) -> f64 { + let (off_x, off_y) = self.query_offset(x, y); + let protector = clamp( + starting_spot_at_angle(&self.protector, x, y, off_x, off_y), + 0.0, + 1.0, + ); + let volcano_spot = clamp( + starting_spot_at_angle(&self.volcano_spot, x, y, off_x, off_y), + 0.0, + 1.0, + ); + max2(volcano_spot, self.raw_spots(x, y) - protector) + } + + /// Evaluate every graded field of this layer at one position. + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> BiomeFields { + let pre = self.pre_volcano(x, y); + let spots = self.mountain_volcano_spots(x, y); + + // The volcano field re-enters the mountains raw here, and this is the + // only place it does. The 0.5 halves the pre-volcano contribution; the + // max picks between a gentle ramp and a hard step at 0.33. + let mountains_raw_volcano = 0.5 * pre.mountains_raw_pre_volcano + + max2(2.0 * spots, 10.0 * clamp((spots - 0.33) * 3.0, 0.0, 1.0)); + + // Each `*_full` subtracts the max of the OTHER two raws, and mountains + // contributes its POST-volcano raw to its siblings. + let mountains_biome_full = mountains_raw_volcano - max2(pre.ashlands_raw, pre.basalts_raw); + let ashlands_biome_full = pre.ashlands_raw - max2(mountains_raw_volcano, pre.basalts_raw); + let basalts_biome_full = pre.basalts_raw - max2(mountains_raw_volcano, pre.ashlands_raw); + + BiomeFields { + mountain_volcano_spots: poison::f64_result(spots), + mountains_raw_volcano, + mountains_biome_full, + ashlands_biome_full, + basalts_biome_full, + mountains_biome: clamp(mountains_biome_full * VULCANUS_BIOME_CONTRAST, 0.0, 1.0), + ashlands_biome: clamp(ashlands_biome_full * VULCANUS_BIOME_CONTRAST, 0.0, 1.0), + basalts_biome: clamp(basalts_biome_full * VULCANUS_BIOME_CONTRAST, 0.0, 1.0), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ctx() -> EvalCtx { + EvalCtx::new(123_456) + } + + /// The default preset puts both nested `slider_rescale`s at 1, so volcanism + /// is exactly 1 - which makes the radius exactly 200 and the spacing 1500. + #[test] + fn the_default_volcanism_is_exactly_one() { + let c = ctx(); + let helpers = VulcanusHelpers::new(&c); + let spawn = VulcanusSpawn::with_host_trig(&c); + let biomes = VulcanusBiomes::with_host_trig(&c, &helpers, &spawn); + assert_eq!(biomes.volcanism, 1.0); + assert_eq!(biomes.radius, 200.0); + assert_eq!(biomes.spacing, 1500.0); + assert_eq!(biomes.quantity, 40_000.0); + } + + /// Regions are centred on multiples of 256, so the boundary sits at +/-128 + /// rather than at 0. An index that floored `c / 256` would put every region + /// half a region out. + #[test] + fn regions_are_centred_on_multiples_of_the_region_size() { + assert_eq!(region_index(0.0), 0); + assert_eq!(region_index(127.9), 0); + assert_eq!(region_index(128.0), 1); + assert_eq!(region_index(-128.1), -1); + assert_eq!(region_index(-129.0), -1); + assert_eq!(region_index(384.0), 2); + } + + /// The three clamped biomes are in `[0, 1]`, and the `*_full` variants are + /// the same quantity UNCLAMPED - so at least one position must show a full + /// value outside the range, or the clamp is doing nothing and the two sets + /// of fields are redundant. + #[test] + fn the_full_variants_are_unclamped_and_the_biomes_are_not() { + let c = ctx(); + let helpers = VulcanusHelpers::new(&c); + let spawn = VulcanusSpawn::with_host_trig(&c); + let biomes = VulcanusBiomes::with_host_trig(&c, &helpers, &spawn); + let mut outside = 0usize; + for k in 0..60 { + let (x, y) = (f64::from(k) * 43.5 - 1200.0, f64::from(k) * -27.25 + 700.0); + let f = biomes.eval(x, y); + for v in [f.mountains_biome, f.ashlands_biome, f.basalts_biome] { + assert!((0.0..=1.0).contains(&v), "clamped biome {v} at {x},{y}"); + } + for v in [ + f.mountains_biome_full, + f.ashlands_biome_full, + f.basalts_biome_full, + ] { + if !(0.0..=1.0).contains(&v) { + outside += 1; + } + } + } + assert!( + outside > 0, + "no full variant left [0, 1], so the clamp is inert" + ); + } + + /// At most one biome can win at a point: the `*_full` fields subtract the + /// max of the other two, so at most one can be positive. + #[test] + fn at_most_one_biome_full_is_positive() { + let c = ctx(); + let helpers = VulcanusHelpers::new(&c); + let spawn = VulcanusSpawn::with_host_trig(&c); + let biomes = VulcanusBiomes::with_host_trig(&c, &helpers, &spawn); + for k in 0..60 { + let (x, y) = (f64::from(k) * 31.5 - 900.0, f64::from(k) * 19.75 - 600.0); + let f = biomes.eval(x, y); + let positive = [ + f.mountains_biome_full, + f.ashlands_biome_full, + f.basalts_biome_full, + ] + .iter() + .filter(|v| **v > 0.0) + .count(); + assert!(positive <= 1, "{positive} biomes positive at {x},{y}"); + } + } + + /// `volcano_area` reads the PRE-volcano mountains full, which is what breaks + /// the cycle. If it read the post-volcano one this would not terminate, so + /// the test is that it returns at all - plus that it really is the + /// pre-volcano value rather than something merely close to it. + #[test] + fn volcano_area_reads_the_pre_volcano_stage() { + let c = ctx(); + let helpers = VulcanusHelpers::new(&c); + let spawn = VulcanusSpawn::with_host_trig(&c); + let biomes = VulcanusBiomes::with_host_trig(&c, &helpers, &spawn); + let (x, y) = (612.5, -318.25); + let pre = biomes.pre_volcano(x, y); + let want = lerp(pre.mountains_biome_full_pre_volcano, 0.0, pre.starting_area); + assert_eq!(biomes.volcano_area(x, y), want); + // And the post-volcano value really is different, so the distinction is + // not academic at this point. + assert_ne!( + biomes.eval(x, y).mountains_biome_full, + pre.mountains_biome_full_pre_volcano + ); + } + + /// The cull radius and the quantity are both built from `radius` but only + /// one of them is narrowed. Pinned because they look interchangeable. + #[test] + fn the_cull_radius_is_not_the_narrowed_quantity() { + let mut c = ctx(); + // A slider setting where 200 * volcanism is not exact in f32, so the + // two forms can differ at all. + c.vulcanus_volcanism_size = 3.0; + let helpers = VulcanusHelpers::new(&c); + let spawn = VulcanusSpawn::with_host_trig(&c); + let b = VulcanusBiomes::with_host_trig(&c, &helpers, &spawn); + assert_eq!(b.cull_sq, b.radius * b.radius); + assert_eq!(b.quantity, f64::from((b.radius * b.radius) as f32)); + } +} diff --git a/crates/fmw-noise/src/expressions/vulcanus_elevation.rs b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs new file mode 100644 index 00000000..ee06de38 --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs @@ -0,0 +1,367 @@ +//! Vulcanus's elevation surface, ported from +//! `src/noise/expressions/vulcanusElevation.ts`. +//! +//! `mountain_basis_noise`, `mountain_elevation`, `volcano_inverted_peak`, +//! `vulcanus_mountains_func`, `vulcanus_ashlands_func`, +//! `vulcanus_basalt_lakes`, the biome-weighted `vulcanus_elev` lerp of lerps, +//! and `vulcanus_elevation = max(-500, vulcanus_elev)`. Transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` lines ~428-562. +//! +//! This also closes the `vulcanus_temperature` the climate layer deferred: it +//! reads `vulcanus_elev`, so it could not be ported until this existed. +//! +//! ## Both elevations are exposed, and the difference is not cosmetic +//! +//! `vulcanus_temperature` reads the RAW `vulcanus_elev`, not the +//! `max(-500, ...)`-clamped `vulcanus_elevation`. Wiring temperature to the +//! clamped field would be invisible above -500, which is nearly everywhere, and +//! wrong in exactly the deep basalt lakes where temperature matters. +//! +//! ## `min(a, a)` is transcribed as written +//! +//! `vulcanus_ashlands_func` takes the min of two `basis_noise` calls that are +//! byte-identical in the Lua - same seed, same scales, same output scale. The +//! min of a value with itself is that value, so this could be one call. It is +//! written as two because the source writes two, and collapsing it is the kind +//! of tidy-up that is only safe until someone changes one of them upstream. +//! +//! ## The cliff channel: `multisample` offsets are in GRID UNITS (#83) +//! +//! `vulcanus_basalt_lakes_multisample` is a 2x2 min filter over integer +//! neighbours. Its footprint is one GRID STEP wide, and the grid belongs to the +//! CONSUMING noise program, not to the field being sampled. The tile and terrain +//! channels run a 1-tile grid; the cliff generator walks the 4-tile corner +//! lattice and needs 4. +//! +//! That was measured rather than assumed - routing `multisample(x, 4, 0)` onto +//! `cliff_elevation` moves the contour by **16 tiles, not 4**, which is 4 times +//! the 4-tile step. So [`VulcanusElevation::cliff_elevation`] exists as a +//! separate entry point taking the wider grid, and both go through the same +//! code with `g` as a parameter. + +use crate::basis_noise::{tables_from_seed, BasisNoiseTables}; +use crate::eval::ctx::EvalCtx; +use crate::eval::math::{clamp, lerp, max2, min, min2}; +use crate::eval::multisample::multisample; +use crate::eval::primitives::{basis_noise_expr, BasisExprParams}; +use crate::expressions::vulcanus_biomes::VulcanusBiomes; +use crate::expressions::vulcanus_climate::VulcanusClimate; +use crate::expressions::vulcanus_cracks::VulcanusCracks; +use crate::expressions::vulcanus_helpers::{contrast, Plasma, VulcanusHelpers}; +use crate::multioctave_noise::Prepared; +use crate::poison; + +/// `vulcanus_elevation_offset = 0`. +/// +/// Kept as a named constant rather than dropped, because it is a named program +/// constant in the Lua and a future map-gen preset could move it. +pub const VULCANUS_ELEVATION_OFFSET: f64 = 0.0; +/// `vulcanus_mountains_elevation_multiplier = 1.5`. +pub const VULCANUS_MOUNTAINS_ELEVATION_MULTIPLIER: f64 = 1.5; +/// `vulcanus_ashlands_func`'s `local_expressions.scale = 3`. +const ASHLANDS_SCALE: f64 = 3.0; +/// The grid step the tile and terrain channels use. +pub const TILE_MULTISAMPLE_GRID: f64 = 1.0; +/// The grid step the cliff generator uses. See the module docs and #83. +pub const CLIFF_MULTISAMPLE_GRID: f64 = 4.0; + +/// `volcano_inverted_peak(spot, inversion_point)`. +/// +/// A tent function peaking at 1 when `spot` equals the inversion point and +/// falling away linearly on both sides. Free rather than a method: it reads no +/// per-render state. +#[must_use] +pub fn volcano_inverted_peak(spot: f64, inversion_point: f64) -> f64 { + (inversion_point - (spot - inversion_point).abs()) / inversion_point +} + +/// A `basis_noise{...}` leaf with its tables derived once. +struct BasisLeaf { + params: BasisExprParams, + tables: BasisNoiseTables, +} + +impl BasisLeaf { + fn new(seed0: u32, seed1: u32, input_scale: f64, output_scale: f64) -> Self { + Self { + params: BasisExprParams { + seed0, + seed1, + input_scale, + output_scale, + offset_x: 0.0, + }, + tables: tables_from_seed(seed0, seed1), + } + } + + fn eval(&self, x: f64, y: f64) -> f64 { + basis_noise_expr(x, y, &self.params, &self.tables) + } +} + +/// The two elevations at one position. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct ElevationFields { + /// `vulcanus_elev`, raw and unclamped. This is what temperature reads. + pub elev: f64, + /// `vulcanus_elevation = max(-500, vulcanus_elev)`. + pub elevation: f64, +} + +/// The per-render constants of Vulcanus's elevation surface. +pub struct VulcanusElevation<'a> { + cracks: &'a VulcanusCracks, + biomes: &'a VulcanusBiomes<'a>, + climate: &'a VulcanusClimate, + + mountain_basis: BasisLeaf, + ashlands_basis: BasisLeaf, + mountain_plasma: Plasma, + /// The plasma behind the `(1 - clamp(...))` factor that flattens mountains. + mountain_elev_plasma: Plasma, + lakes_837: Prepared, + lakes_234: Prepared, + lakes_643: Prepared, +} + +impl<'a> VulcanusElevation<'a> { + /// Build the layer from the four it stacks on. + #[must_use] + pub fn new( + ctx: &EvalCtx, + helpers: &'a VulcanusHelpers, + cracks: &'a VulcanusCracks, + biomes: &'a VulcanusBiomes<'a>, + climate: &'a VulcanusClimate, + ) -> Self { + let seed0 = ctx.seed0; + Self { + cracks, + biomes, + climate, + mountain_basis: BasisLeaf::new(seed0, 13_423, 1.0 / 500.0, 250.0), + // Note the input scale divides by the ashlands LOCAL scale of 3, and + // by the volcanism scale multiplier - not by the crack scale. + ashlands_basis: BasisLeaf::new( + seed0, + 12_643, + helpers.scale_multiplier / 50.0 / ASHLANDS_SCALE, + 150.0, + ), + mountain_plasma: helpers.plasma(102, 2.5, 10.0, 125.0, 625.0), + mountain_elev_plasma: helpers.plasma(13, 2.5, 10.0, 0.15, 0.75), + lakes_837: helpers.detail_noise(837, 1.0 / 40.0, 4.0, 1.25), + lakes_234: helpers.detail_noise(234, 1.0 / 50.0, 4.0, 1.0), + lakes_643: helpers.detail_noise(643, 1.0 / 70.0, 4.0, 0.7), + } + } + + /// `mountain_elevation`. + #[must_use] + pub fn mountain_elevation(&self, x: f64, y: f64) -> f64 { + let mp = self.mountain_plasma.eval(x, y); + let mbn = self.mountain_basis.eval(x, y); + // The clamp's upper bound of 10000 is far above anything the plasma + // reaches; it is the Lua's, kept as written. + let base = lerp( + max2(clamp(mp, -100.0, 10_000.0), mbn), + mp, + clamp(0.7 * mbn, 0.0, 1.0), + ); + base * (1.0 - clamp(self.mountain_elev_plasma.eval(x, y), 0.0, 1.0)) + } + + /// `vulcanus_mountains_func`. + #[must_use] + pub fn mountains_func(&self, x: f64, y: f64, volcano_spots: f64, aux: f64) -> f64 { + lerp( + self.mountain_elevation(x, y), + 700.0 * volcano_inverted_peak(volcano_spots, 0.65), + clamp(volcano_spots * 3.0, 0.0, 1.0), + ) + 200.0 * (aux - 0.5) * (volcano_spots + 0.5) + } + + /// `vulcanus_ashlands_func`. See the module docs on `min(a, a)`. + #[must_use] + pub fn ashlands_func(&self, x: f64, y: f64) -> f64 { + 300.0 + + 0.001 + * min2( + self.ashlands_basis.eval(x, y), + self.ashlands_basis.eval(x, y), + ) + } + + /// `vulcanus_basalt_lakes`. + /// + /// Two `contrast`ed detail fields multiplied by a third, clamped to `[0, 3]` + /// and subtracted. The `contrast` knee of 0.95 makes both factors zero over + /// most of the map, so the whole term only bites where all three line up. + #[must_use] + pub fn basalt_lakes(&self, x: f64, y: f64) -> f64 { + let carve = contrast(f64::from(self.lakes_837.eval(x, y)), 0.95) + * contrast(f64::from(self.lakes_234.eval(x, y)), 0.95) + * f64::from(self.lakes_643.eval(x, y)); + min2( + 1.0, + -0.2 + self.cracks.eval(x, y).flood_basalts_func - 0.35 * clamp(carve, 0.0, 3.0), + ) + } + + /// `vulcanus_basalt_lakes_multisample` - a 2x2 min filter one grid step wide. + #[must_use] + pub fn basalt_lakes_multisample(&self, x: f64, y: f64, grid: f64) -> f64 { + let at = |dx: f64, dy: f64| multisample(|sx, sy| self.basalt_lakes(sx, sy), x, y, dx, dy); + min(&[at(0.0, 0.0), at(grid, 0.0), at(0.0, grid), at(grid, grid)]) + } + + /// `vulcanus_elev` and `vulcanus_elevation` at one position, on a given grid. + /// + /// `grid` is [`TILE_MULTISAMPLE_GRID`] for every tile and terrain consumer + /// and [`CLIFF_MULTISAMPLE_GRID`] for the cliff generator. It reaches only + /// the basalt-lakes min filter; everything else is grid-independent. + #[must_use] + pub fn eval_at_grid(&self, x: f64, y: f64, grid: f64) -> ElevationFields { + let biome = self.biomes.eval(x, y); + let climate = self.climate.eval(x, y, &self.cracks.eval(x, y)); + + let mountains_blend = lerp( + 120.0 * self.basalt_lakes_multisample(x, y, grid), + 20.0 + self.mountains_func(x, y, biome.mountain_volcano_spots, climate.aux) + * VULCANUS_MOUNTAINS_ELEVATION_MULTIPLIER, + biome.mountains_biome, + ); + let elev = VULCANUS_ELEVATION_OFFSET + + lerp( + mountains_blend, + self.ashlands_func(x, y), + biome.ashlands_biome, + ); + + ElevationFields { + elev: poison::f64_result(elev), + elevation: max2(-500.0, elev), + } + } + + /// The tile and terrain channel. + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> ElevationFields { + self.eval_at_grid(x, y, TILE_MULTISAMPLE_GRID) + } + + /// The cliff generator's channel, whose multisample grid is 4 tiles. + #[must_use] + pub fn cliff_elevation(&self, x: f64, y: f64) -> f64 { + self.eval_at_grid(x, y, CLIFF_MULTISAMPLE_GRID).elevation + } + + /// `vulcanus_temperature`, deferred out of the climate layer until + /// `vulcanus_elev` existed. + /// + /// **Reads the RAW elev**, not the clamped elevation. `min(e, e / 100)` + /// picks the raw value below zero and the hundredth above it, which is what + /// makes high ground cool slowly and deep lakes cool fast. + #[must_use] + pub fn temperature(&self, x: f64, y: f64, temperature_bias: f64) -> f64 { + let e = self.eval(x, y).elev; + let biome = self.biomes.eval(x, y); + let climate = self.climate.eval(x, y, &self.cracks.eval(x, y)); + 100.0 + 100.0 * temperature_bias + - min2(e, e / 100.0) + - 2.0 * climate.moisture + - 1.0 * climate.aux + - 20.0 * biome.ashlands_biome + + 200.0 * max2(0.0, biome.mountain_volcano_spots - 0.6) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::expressions::vulcanus_spawn::VulcanusSpawn; + + /// The tent peaks at exactly 1 on the inversion point and reaches 0 at both + /// 0 and twice the inversion point. A sign error inverts the volcano. + #[test] + fn the_inverted_peak_is_a_tent_centred_on_its_inversion_point() { + assert_eq!(volcano_inverted_peak(0.65, 0.65), 1.0); + assert_eq!(volcano_inverted_peak(0.0, 0.65), 0.0); + assert!((volcano_inverted_peak(1.3, 0.65) - 0.0).abs() < 1e-12); + // Symmetric about the peak. + assert!( + (volcano_inverted_peak(0.45, 0.65) - volcano_inverted_peak(0.85, 0.65)).abs() < 1e-12 + ); + // And it goes NEGATIVE outside, which the caller's clamp relies on. + assert!(volcano_inverted_peak(2.0, 0.65) < 0.0); + } + + /// The multisample grid reaches the basalt-lakes filter and nothing else, so + /// the two channels differ only where the filter's four samples disagree - + /// and they must differ somewhere, or the cliff channel is not a channel. + #[test] + fn the_cliff_grid_changes_the_answer_but_only_through_the_lakes_filter() { + let ctx = EvalCtx::new(123_456); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + let climate = VulcanusClimate::new(ctx.seed0); + let elevation = VulcanusElevation::new(&ctx, &helpers, &cracks, &biomes, &climate); + + let mut differ = 0usize; + for k in 0..40 { + let (x, y) = (f64::from(k) * 23.5 - 400.0, f64::from(k) * -17.25 + 300.0); + let tile = elevation.eval(x, y).elevation; + let cliff = elevation.cliff_elevation(x, y); + if tile != cliff { + differ += 1; + } + } + assert!( + differ > 0, + "the 4-tile grid never changed the answer, so the cliff channel is untested" + ); + } + + /// `elevation` clamps at -500 and `elev` does not. Temperature reads the + /// raw one, so the two must stay distinguishable. + #[test] + fn the_clamped_elevation_is_not_the_raw_elev() { + let ctx = EvalCtx::new(123_456); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + let climate = VulcanusClimate::new(ctx.seed0); + let elevation = VulcanusElevation::new(&ctx, &helpers, &cracks, &biomes, &climate); + + for k in 0..40 { + let (x, y) = (f64::from(k) * 37.5 - 700.0, f64::from(k) * 29.25 - 500.0); + let f = elevation.eval(x, y); + assert!(f.elevation >= -500.0); + assert_eq!(f.elevation, f.elev.max(-500.0)); + } + } + + /// `min(a, a)` is the identity, so the ashlands surface is the single basis + /// call plus 300. Asserted so that collapsing the duplicate later is a + /// deliberate act rather than an accident. + #[test] + fn the_ashlands_min_of_identical_calls_is_the_call_itself() { + let ctx = EvalCtx::new(123_456); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + let climate = VulcanusClimate::new(ctx.seed0); + let elevation = VulcanusElevation::new(&ctx, &helpers, &cracks, &biomes, &climate); + + for k in 0..20 { + let (x, y) = (f64::from(k) * 51.5, f64::from(k) * -33.25); + let single = elevation.ashlands_basis.eval(x, y); + assert_eq!(elevation.ashlands_func(x, y), 300.0 + 0.001 * single); + } + } +} diff --git a/crates/fmw-noise/src/expressions/vulcanus_spawn.rs b/crates/fmw-noise/src/expressions/vulcanus_spawn.rs new file mode 100644 index 00000000..71504a57 --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_spawn.rs @@ -0,0 +1,336 @@ +//! Vulcanus's seed-derived radial spawn geometry, ported from +//! `src/noise/expressions/vulcanusSpawn.ts`. +//! +//! Three biome "starts" - ashlands, basalts and mountains - placed at fixed +//! distances and radii around spawn, 120 degrees apart, rotated and mirrored by +//! the map seed. `starting_area` is their max clamped to `[0, 1]` and blends the +//! biomes near spawn; `starting_circle` is a related but separate UNCLAMPED +//! falloff that pushes random ore placement away from spawn. Transcribed from +//! `space-age/prototypes/planet/planet-vulcanus-map-gen.lua` lines ~162-225. +//! +//! ## Three numbers here are easy to get wrong and none of them is checkable by eye +//! +//! - **`basalts_start`'s distance is a bare `250`**, not `250 * r`, where both +//! its siblings scale by `r`. Transcribed as written. +//! - **Ashlands and basalts share a distortion coefficient of `0.1 * r`; +//! mountains uses half of it, `0.05 * r`.** +//! - **The three results carry different multipliers**: ashlands `4`, basalts +//! and mountains `2`. +//! +//! Each produces a plausible map when wrong, so each has its own test rather +//! than relying on the fixture to notice. +//! +//! ## The trig is an INPUT, for the reason #279 records +//! +//! All three bearings are per-render constants derived from the seed, so their +//! sines and cosines are computed once outside the per-pixel path and handed in. +//! A runtime `sin` in the shipped module is the one thing the determinism policy +//! forbids, and #270 measured that the wasm libm and V8 really do disagree by a +//! ULP. [`VulcanusSpawn::with_host_trig`] computes them with Rust's own libm, +//! for tests. + +use crate::distance_from_nearest_point::{distance_from_nearest_point, Point}; +use crate::eval::ctx::EvalCtx; +use crate::eval::math::{clamp, max}; +use crate::expressions::starting_spot_at_angle::{starting_spot_at_angle, AngleTrig, StartingSpot}; +use crate::expressions::vulcanus_helpers::VulcanusHelpers; +use crate::poison; + +/// `vulcanus_starting_area_radius = 0.7 * 0.75`. +pub const VULCANUS_STARTING_AREA_RADIUS: f64 = 0.7 * 0.75; + +/// The summed wobble distortion every `*_start` reads, at one position. +/// +/// All three starts read the SAME two sums, which is why they are computed once +/// here and passed in rather than recomputed per start. The TypeScript memoizes +/// them for exactly that reason. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct WobbleSums { + pub x: f64, + pub y: f64, +} + +impl WobbleSums { + /// Sum the three x wobbles and the three y wobbles at one position. + #[must_use] + pub fn at(helpers: &VulcanusHelpers, x: f64, y: f64) -> Self { + Self { + x: helpers.wobble_x(x, y) + helpers.wobble_large_x(x, y) + helpers.wobble_huge_x(x, y), + y: helpers.wobble_y(x, y) + helpers.wobble_large_y(x, y) + helpers.wobble_huge_y(x, y), + } + } +} + +/// Every named expression this layer defines, at one position. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct SpawnFields { + pub ashlands_start: f64, + pub basalts_start: f64, + pub mountains_start: f64, + pub starting_area: f64, + pub starting_circle: f64, +} + +/// The per-render constants of Vulcanus's spawn geometry. +pub struct VulcanusSpawn { + /// `-1 + 2 * (map_seed_small & 1)`, so either -1 or +1. It mirrors the + /// whole arrangement, which is why it multiplies the angle OFFSETS rather + /// than the base angle. + pub starting_direction: f64, + /// `map_seed_normalized * 3600` degrees. + pub ashlands_angle: f64, + /// `ashlands_angle + 120 * starting_direction` degrees. + pub mountains_angle: f64, + /// `ashlands_angle + 240 * starting_direction` degrees. + pub basalts_angle: f64, + ashlands: StartingSpot, + basalts: StartingSpot, + mountains: StartingSpot, + starting_positions: Vec, +} + +impl VulcanusSpawn { + /// Build the layer, taking the three bearings' trig from the caller. + #[must_use] + pub fn new( + ctx: &EvalCtx, + ashlands_trig: AngleTrig, + mountains_trig: AngleTrig, + basalts_trig: AngleTrig, + ) -> Self { + let r = VULCANUS_STARTING_AREA_RADIUS; + let (ashlands_angle, mountains_angle, basalts_angle) = Self::angles(ctx); + Self { + starting_direction: Self::starting_direction(ctx), + ashlands_angle, + mountains_angle, + basalts_angle, + ashlands: StartingSpot { + trig: ashlands_trig, + distance: f64::from((170.0 * r) as f32), + radius: f64::from((350.0 * r) as f32), + }, + basalts: StartingSpot { + trig: basalts_trig, + // A bare 250, NOT 250 * r. See the module docs. + distance: 250.0, + radius: f64::from((550.0 * r) as f32), + }, + mountains: StartingSpot { + trig: mountains_trig, + distance: f64::from((250.0 * r) as f32), + radius: f64::from((500.0 * r) as f32), + }, + starting_positions: ctx.starting_positions.clone(), + } + } + + /// As [`VulcanusSpawn::new`], but computing all three bearings with Rust's + /// libm. For tier-1 tests and anything that is not the shipped engine. + #[must_use] + pub fn with_host_trig(ctx: &EvalCtx) -> Self { + let (a, m, b) = Self::angles(ctx); + Self::new( + ctx, + AngleTrig::from_degrees(a), + AngleTrig::from_degrees(m), + AngleTrig::from_degrees(b), + ) + } + + /// `-1 + 2 * (map_seed_small & 1)`. + fn starting_direction(ctx: &EvalCtx) -> f64 { + -1.0 + 2.0 * f64::from(ctx.map_seed_small & 1) + } + + /// The three bearings in degrees, each narrowed the way the TypeScript + /// narrows them. + /// + /// `f32(120 * direction)` is narrowed SEPARATELY before being added, and + /// then the sum is narrowed again. Both roundings are in the TypeScript and + /// both are kept - see the two-case rule in `src/noise/eval/f32.ts`. + fn angles(ctx: &EvalCtx) -> (f64, f64, f64) { + let direction = Self::starting_direction(ctx); + let ashlands = f64::from((f64::from(ctx.map_seed_normalized) * 3600.0) as f32); + let mountains = f64::from((ashlands + f64::from((120.0 * direction) as f32)) as f32); + let basalts = f64::from((ashlands + f64::from((240.0 * direction) as f32)) as f32); + (ashlands, mountains, basalts) + } + + /// Evaluate every field of this layer at one position. + #[must_use] + pub fn eval(&self, x: f64, y: f64, wobble: WobbleSums) -> SpawnFields { + let r = VULCANUS_STARTING_AREA_RADIUS; + + // Ashlands and basalts share 0.1 * r; mountains uses half of it. + let wide_x = 0.1 * r * wobble.x; + let wide_y = 0.1 * r * wobble.y; + let tight_x = 0.05 * r * wobble.x; + let tight_y = 0.05 * r * wobble.y; + + let ashlands_start = 4.0 * starting_spot_at_angle(&self.ashlands, x, y, wide_x, wide_y); + let basalts_start = 2.0 * starting_spot_at_angle(&self.basalts, x, y, wide_x, wide_y); + let mountains_start = 2.0 * starting_spot_at_angle(&self.mountains, x, y, tight_x, tight_y); + + // The argument order is the TypeScript's - basalts, mountains, + // ashlands - and it is kept rather than sorted, per the signed-zero + // note in `CLAUDE.md`. + let starting_area = clamp( + max(&[basalts_start, mountains_start, ashlands_start]), + 0.0, + 1.0, + ); + + // NOT clamped, and that is the point of it being a separate field from + // `starting_area`: it goes on rising past 1 toward spawn and negative + // far from it, which is what makes it usable as an ore suppressor. + let distance = f64::from(distance_from_nearest_point( + x, + y, + &self.starting_positions, + f64::INFINITY, + )); + let starting_circle = 1.0 + (r * (300.0 - distance)) / 50.0; + + SpawnFields { + ashlands_start, + basalts_start, + mountains_start, + starting_area: poison::f64_result(starting_area), + starting_circle, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layer() -> (VulcanusHelpers, VulcanusSpawn) { + let ctx = EvalCtx::new(123_456); + ( + VulcanusHelpers::new(&ctx), + VulcanusSpawn::with_host_trig(&ctx), + ) + } + + /// `0.7 * 0.75` is written as the product rather than as `0.525`, because + /// the product is what the Lua writes and the two are not the same f64. + #[test] + fn the_starting_area_radius_is_the_product_the_lua_writes() { + assert_eq!(VULCANUS_STARTING_AREA_RADIUS, 0.7 * 0.75); + } + + /// The direction is exactly -1 or +1, never 0 or 2. It comes from one bit, + /// so an off-by-one in the formula shows immediately. + #[test] + fn the_starting_direction_is_a_sign() { + for seed in [0u32, 1, 2, 3, 123_456, 999_999, u32::MAX] { + let d = VulcanusSpawn::with_host_trig(&EvalCtx::new(seed)).starting_direction; + assert!(d == -1.0 || d == 1.0, "seed {seed} gave {d}"); + } + // The bit really does drive it: consecutive seeds differ in bit 0, and + // `map_seed_small` keeps the low 16 bits, so they flip the sign. + let even = VulcanusSpawn::with_host_trig(&EvalCtx::new(2)).starting_direction; + let odd = VulcanusSpawn::with_host_trig(&EvalCtx::new(3)).starting_direction; + assert_eq!(even, -1.0); + assert_eq!(odd, 1.0); + } + + /// The three bearings sit 120 degrees apart, in the order the direction + /// picks. Checked modulo 360 because the base angle is unbounded. + /// + /// **Not an exact comparison, and the reason is the expression rather than + /// the test.** Every angle is narrowed to f32, and `ashlands_angle` is + /// `map_seed_normalized * 3600`, so it sits near 3600 where one f32 ULP is + /// about 2.4e-4; the measured separation at seed 123456 is 239.999998. The + /// tolerance is on the SEPARATION of two f32 angles, a geometric property + /// of this layer. It is not a residual bound standing in for an exact + /// count - nothing here measures the port against the game. + #[test] + fn the_three_bearings_are_120_degrees_apart() { + let (_, spawn) = layer(); + let sep = |a: f64, b: f64| (a - b).rem_euclid(360.0); + let d = spawn.starting_direction; + let (near, far) = if d > 0.0 { + (120.0, 240.0) + } else { + (240.0, 120.0) + }; + let m = sep(spawn.mountains_angle, spawn.ashlands_angle); + let b = sep(spawn.basalts_angle, spawn.ashlands_angle); + assert!((m - near).abs() < 1e-3, "mountains separation {m}"); + assert!((b - far).abs() < 1e-3, "basalts separation {b}"); + } + + /// **`basalts_start`'s distance is a bare 250.** Its siblings scale by `r`, + /// so the natural transcription error is `250 * r` - which is 131.25, a + /// different place entirely, and still produces a plausible map. + #[test] + fn the_basalts_distance_is_not_scaled_by_the_radius() { + let (_, spawn) = layer(); + assert_eq!(spawn.basalts.distance, 250.0); + assert_ne!( + spawn.basalts.distance, + f64::from((250.0 * VULCANUS_STARTING_AREA_RADIUS) as f32) + ); + // Its siblings DO scale, which is what makes the exception an exception. + assert_eq!( + spawn.mountains.distance, + f64::from((250.0 * VULCANUS_STARTING_AREA_RADIUS) as f32) + ); + assert_eq!( + spawn.ashlands.distance, + f64::from((170.0 * VULCANUS_STARTING_AREA_RADIUS) as f32) + ); + } + + /// Mountains is distorted by half what the other two get. Feeding all three + /// the same coefficient still produces three blobs in the right places. + #[test] + fn mountains_takes_half_the_distortion_of_the_other_two() { + let (helpers, spawn) = layer(); + let (x, y) = (120.5, -80.25); + let wobble = WobbleSums::at(&helpers, x, y); + assert_ne!( + wobble.x, 0.0, + "need a nonzero wobble for this to say anything" + ); + + let r = VULCANUS_STARTING_AREA_RADIUS; + let got = spawn.eval(x, y, wobble); + // Rebuild mountains with the WIDE coefficient and confirm it differs. + let wrong = 2.0 + * starting_spot_at_angle( + &spawn.mountains, + x, + y, + 0.1 * r * wobble.x, + 0.1 * r * wobble.y, + ); + assert_ne!(got.mountains_start, wrong); + } + + /// `starting_area` is clamped and `starting_circle` is not. Clamping the + /// second would break its use as an ore suppressor far from spawn, where it + /// must be allowed to go negative. + #[test] + fn starting_area_is_clamped_and_starting_circle_is_not() { + let (helpers, spawn) = layer(); + let mut saw_above_one = 0usize; + let mut saw_negative = 0usize; + for k in 0..120 { + let (x, y) = (f64::from(k) * 37.5 - 2000.0, f64::from(k) * -21.25 + 900.0); + let f = spawn.eval(x, y, WobbleSums::at(&helpers, x, y)); + assert!((0.0..=1.0).contains(&f.starting_area)); + if f.starting_circle > 1.0 { + saw_above_one += 1; + } + if f.starting_circle < 0.0 { + saw_negative += 1; + } + } + assert!(saw_above_one > 0 && saw_negative > 0, + "starting_circle stayed inside [0, 1] over the whole sweep ({saw_above_one} above, {saw_negative} below), so this proves nothing"); + } +} diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index 14e83bf4..12b88c4b 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2506,3 +2506,247 @@ fn reproduces_the_vulcanus_climate_layer_at_every_captured_position() { ); } } + +use crate::expressions::vulcanus_spawn::{SpawnFields, VulcanusSpawn, WobbleSums}; + +/// Evaluate the spawn layer at every fixture position. +fn vulcanus_spawn_sweep(seed0: u32, positions: &[Json]) -> Vec { + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let helpers = VulcanusHelpers::new(&ctx); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + positions + .iter() + .map(|p| { + let (x, y) = (p.get("x").as_f64(), p.get("y").as_f64()); + spawn.eval(x, y, WobbleSums::at(&helpers, x, y)) + }) + .collect() +} + +/// The seed-derived radial spawn geometry. +/// +/// The fixture captures three of the five fields. `ashlands_start` is the one +/// that grades the `starting_spot_at_angle` call directly - the other two are +/// composites - so it is the field that would move if #279's narrowing were +/// ever undone. +#[test] +fn reproduces_the_vulcanus_spawn_layer_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-spawn.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 410, "fixture size"); + let fields = vulcanus_spawn_sweep(fixture.get("seed0").as_f64() as u32, positions); + + // Frozen exact counts, measured 2026-08-19 and matched by the TypeScript at + // 371, 247 and 61 with the same worst residuals: 7.153e-7, 7.019e-7 and + // 3.815e-6. + // + // Those residuals are two to three orders of magnitude SMALLER than the + // crack layer's and the exact counts are still far from full - 61 of 410 on + // `ashlandsStart`. It is the same reading `detailNoise` gives in the helper + // layer, from a completely different expression: closeness and exactness are + // not the same measurement, and only one of them is a port score. + // + // `startingArea` scores highest of the three and that is expected rather + // than good: it is CLAMPED to [0, 1] and most of the map sits at 0, where + // both ports and the game agree for free. Compare `ashlandsStart`, which is + // the same geometry unclamped and unmultiplied, for what the arithmetic + // actually reaches. + type S = SpawnFields; + for (key, want_exact, select) in [ + ( + "startingArea", + 371usize, + &(|f: &S| f.starting_area) as &dyn Fn(&S) -> f64, + ), + ("startingCircle", 247, &|f| f.starting_circle), + ("ashlandsStart", 61, &|f| f.ashlands_start), + ] { + let got: Vec = fields.iter().map(select).collect(); + assert_eq!( + score_vulcanus(&got, fixture.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 410" + ); + } +} + +use crate::expressions::vulcanus_biomes::{BiomeFields, VulcanusBiomes}; + +/// The biome system: the three-way radial chain and the volcano spot field. +/// +/// All eight captured fields are graded, including the two `*_raw_volcano` and +/// `*_biome_full` internals nothing outside the layer reads, so a transcription +/// error localises instead of arriving blended into `elevation`. +/// +/// The fixture nests its columns under `values`, unlike the other Vulcanus +/// fixtures which put them at the top level. +#[test] +fn reproduces_the_vulcanus_biome_layer_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-biomes.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 434, "fixture size"); + let values = fixture.get("values"); + + let ctx = crate::eval::ctx::EvalCtx::new(fixture.get("seed0").as_f64() as u32); + let helpers = VulcanusHelpers::new(&ctx); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + + let fields: Vec = positions + .iter() + .map(|p| biomes.eval(p.get("x").as_f64(), p.get("y").as_f64())) + .collect(); + + // Frozen exact counts, measured 2026-08-19 and matched by the TypeScript on + // all eight with the same worst residuals: 3.821e-5, 1.122e-4, 1.546e-4, + // 1.546e-4, 1.655e-4, 8.955e-5, 3.092e-4, 4.069e-5. + // + // **The three clamped biomes score roughly three times their own unclamped + // sources** - 403, 402 and 408 against 128, 107 and 127 - and they are the + // same quantity times 2, clamped. Nothing improved between them: the clamp + // saturates at 0 or 1 across most of the map, and a saturated position is + // exact for free because both ports and the game return the bound. Read the + // `*_biome_full` row as the port's real score and the `*_biome` row as what + // the consumer happens to need. If a future change moves `*_biome` without + // moving `*_biome_full`, it moved the clamp, not the arithmetic. + // + // `mountain_volcano_spots` at 359 is the highest UNCLAMPED count in the + // Vulcanus port so far, and that fits the pattern `CLAUDE.md` records for + // `voronoi_cell_id`: the spot pipeline's output is dominated by a DISCRETE + // choice - which candidate survives per region - and a sub-ULP error almost + // never changes which one that is. The residual it does carry comes from the + // cone arithmetic afterwards. + type B = BiomeFields; + for (key, want_exact, select) in [ + ( + "mountain_volcano_spots", + 359usize, + &(|f: &B| f.mountain_volcano_spots) as &dyn Fn(&B) -> f64, + ), + ("vulcanus_mountains_raw_volcano", 163, &|f| { + f.mountains_raw_volcano + }), + ("vulcanus_mountains_biome_full", 128, &|f| { + f.mountains_biome_full + }), + ("vulcanus_ashlands_biome_full", 107, &|f| { + f.ashlands_biome_full + }), + ("vulcanus_basalts_biome_full", 127, &|f| { + f.basalts_biome_full + }), + ("vulcanus_mountains_biome", 403, &|f| f.mountains_biome), + ("vulcanus_ashlands_biome", 402, &|f| f.ashlands_biome), + ("vulcanus_basalts_biome", 408, &|f| f.basalts_biome), + ] { + let got: Vec = fields.iter().map(select).collect(); + assert_eq!( + score_vulcanus(&got, values.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 434" + ); + } +} + +use crate::expressions::vulcanus_elevation::{ElevationFields, VulcanusElevation}; + +/// The elevation surface: `vulcanus_elev` and `vulcanus_elevation`. +/// +/// This is the deepest composite in the Vulcanus port - it reads the helper, +/// crack, climate, spawn and biome layers, all of which are graded separately +/// above. That is the point of grading them separately: a number that moves here +/// can be traced to the layer that moved rather than investigated from scratch. +#[test] +fn reproduces_the_vulcanus_elevation_surface_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-elevation.seed123456.json"); + let positions = fixture.get("positions").as_array(); + assert_eq!(positions.len(), 434, "fixture size"); + + let ctx = crate::eval::ctx::EvalCtx::new(fixture.get("seed0").as_f64() as u32); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + let climate = VulcanusClimate::new(ctx.seed0); + let elevation = VulcanusElevation::new(&ctx, &helpers, &cracks, &biomes, &climate); + + let fields: Vec = positions + .iter() + .map(|p| elevation.eval(p.get("x").as_f64(), p.get("y").as_f64())) + .collect(); + + // Frozen exact counts, measured 2026-08-19 and matched by the TypeScript at + // 113 and 113, worst residual 1.332e-1 on both. + // + // **The two columns are the same field in this fixture, and that is a gap + // in the fixture rather than a result.** `vulcanus_elevation` is + // `max(-500, elev)`, and the captured `elev` never goes below -500: its + // minimum over all 434 positions is -58.77. So `elev` equals `elevation` at + // every position, the clamp is never exercised, and a port that dropped the + // `max` entirely would score 113 here too. Checked rather than assumed - + // 0 of 434 positions differ between the two columns. + // + // Grading both anyway, because the cost is nothing and the day a capture + // reaches a deep enough lake the two counts separate on their own. The + // clamp itself is held up by `the_clamped_elevation_is_not_the_raw_elev` in + // the elevation module, which constructs the case the oracle does not. + // + // The residual is 1.332e-1, which looks alarming next to the other layers + // until you read the scale: elevation spans -58 to +1024 here, so that is + // about 1.3e-4 relative - the same order as everything upstream. An absolute + // bound would have to be re-tuned per field for that reason alone, which is + // the third argument for counting matches instead. + type E = ElevationFields; + for (key, want_exact, select) in [ + ("elev", 113usize, &(|f: &E| f.elev) as &dyn Fn(&E) -> f64), + ("elevation", 113, &|f| f.elevation), + ] { + let got: Vec = fields.iter().map(select).collect(); + assert_eq!( + score_vulcanus(&got, fixture.get(key).as_array(), key), + want_exact, + "{key} exact f32 matches out of 434" + ); + } +} + +/// `vulcanus_temperature`, which the climate layer deferred until `elev` existed. +/// +/// It reads the RAW `elev`, so a port that wired it to the clamped +/// `vulcanus_elevation` would agree everywhere above -500 and diverge only in +/// the deep lakes. The fixture spans those. +#[test] +fn reproduces_the_vulcanus_temperature_at_every_captured_position() { + let fixture = load("test/fixtures/oracle-vulcanus-temperature.seed123456.json"); + let positions = fixture.get("positions").as_array(); + + let ctx = crate::eval::ctx::EvalCtx::new(fixture.get("seed0").as_f64() as u32); + let helpers = VulcanusHelpers::new(&ctx); + let cracks = VulcanusCracks::new(&helpers); + let spawn = VulcanusSpawn::with_host_trig(&ctx); + let biomes = VulcanusBiomes::with_host_trig(&ctx, &helpers, &spawn); + let climate = VulcanusClimate::new(ctx.seed0); + let elevation = VulcanusElevation::new(&ctx, &helpers, &cracks, &biomes, &climate); + + let got: Vec = positions + .iter() + .map(|p| { + elevation.temperature( + p.get("x").as_f64(), + p.get("y").as_f64(), + ctx.temperature_bias, + ) + }) + .collect(); + + // Frozen exact count, matched by the TypeScript at 196 with the same worst + // residual of 1.327e-1. Higher than `elev`'s 113 despite reading it, + // because temperature scales it by 1/100 above zero - `min(e, e / 100)` - + // so most of `elev`'s residual is divided away before it lands here. + assert_eq!( + score_vulcanus(&got, fixture.get("temperature").as_array(), "temperature"), + 196, + "temperature exact f32 matches out of {}", + positions.len() + ); +} diff --git a/scripts/verify-rust.sh b/scripts/verify-rust.sh index ced0c447..d2070dd9 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -101,6 +101,10 @@ POISONED_TESTS=( fixtures::reproduces_the_vulcanus_helper_layer_at_every_captured_position fixtures::reproduces_the_vulcanus_crack_layer_at_every_captured_position fixtures::reproduces_the_vulcanus_climate_layer_at_every_captured_position + fixtures::reproduces_the_vulcanus_spawn_layer_at_every_captured_position + fixtures::reproduces_the_vulcanus_biome_layer_at_every_captured_position + fixtures::reproduces_the_vulcanus_elevation_surface_at_every_captured_position + fixtures::reproduces_the_vulcanus_temperature_at_every_captured_position tiles::fulgora_catalog::tests::an_exact_tie_resolves_to_the_earlier_tile_in_land_order ) From 58f6f352b92ed9d9ebc578f27c0792612f52b451 Mon Sep 17 00:00:00 2001 From: Eric J Date: Wed, 19 Aug 2026 16:34:44 -0700 Subject: [PATCH 4/5] Record phase 5 in CLAUDE.md, and correct two comments (#225) Three documentation changes. No behaviour, and engine.wasm is unchanged. CLAUDE.md's Rust engine section stopped at phase 4 and still listed Vulcanus as future work, so the repo's own map said six ported layers did not exist. Adds a phase 5 section with the 24 frozen counts' four readings, the biome layer's real cache and why it is the only one, the pre-volcano split, and the cliff multisample grid. Fixes an inverted comment on the ashlands basis leaf, found by review on #285. It said the input scale "divides by the volcanism scale multiplier" where the code has that multiplier in the NUMERATOR. The code was right and matched both the TypeScript and the Lua; only the comment was wrong. Worth more than a typo fix: scale_multiplier is exactly 1 at the default preset, which is what every fixture and the poison gate run at, so m/50/3 and 1/50/3/m are bit-identical throughout and an edit "fixing" the code to match the comment would diverge from the game only at a non-default volcanism frequency, with a green gate. Corrects the crack layer's #269 note, which blamed the wrong number. It said the layer is discriminating because its INPUT scales (0.3 * 0.325 and 0.6 * 0.325) are not f32-exact. That is wrong, and measurement says the discriminator is the OUTPUT scale alone: basis_noise returns an f32, so a power-of-two output scale is a pure exponent shift and narrowing the product is the identity, while any other output scale can leave the f32 grid. Over 90,000 samples at a fixed input scale, output scales 1, 0.5, 0.25, 2, 4 and 64 change 0.00% of products; 0.6 changes 79.88%, 0.75 and 3 change 56.32%, 150 changes 97.46%, 125 changes 98.38%. Holding the output scale at 1 and sweeping input scales 0.125, 0.205, 0.51, 0.6, 1.5 and 0.002 changes 0.00% every time. So exactly ONE of the crack layer's twelve basis calls is exposed: hairline_cracks' first term at output scale 0.6, where 80.10% of products differ. The other eleven sit at 1, 0.5 or 0.25. That is a one-term explanation for the one bad count in that layer, and why the four fields built only from power-of-two sites score 15, 40, 10 and 8 rather than 3. The wrong reasoning is recorded in the comment rather than quietly replaced, because it is the plausible guess: the input scale is the number that looks inexact. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --- CLAUDE.md | 81 ++++++++++++++++++- .../src/expressions/vulcanus_elevation.rs | 12 ++- crates/fmw-noise/src/fixtures.rs | 41 ++++++++-- 3 files changed, 123 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b2ddc29..31720f9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1202,7 +1202,7 @@ Field labels carry in-game tooltip text via `FInfo` (an `info` prop on `EnemyValueRow`, an `info:` entry in `controlCatalog.ts` for the enemy-base autoplace rows). -### The Rust/WASM noise engine (`crates/`) - phases 1-3 expressions landed +### The Rust/WASM noise engine (`crates/`) - phases 1-4 landed, phase 5 in progress A Cargo workspace at the repository root, landed empty on purpose (#219) so the gate was proven green on `main` before any port code depended on it. Two crates: @@ -1294,6 +1294,85 @@ been red whether or not the argmax had a control at all. `POISONED_TESTS` now carries FULL test paths rather than bare `fixtures::` names, so a control can live beside its op. +**Phase 5 (#225) ports Vulcanus, and its EXPRESSION chain down to elevation is +in.** Landed: `vulcanus_helpers`, `vulcanus_cracks`, `vulcanus_climate`, +`vulcanus_spawn`, `vulcanus_biomes`, `vulcanus_elevation`, plus +`vulcanus_temperature` on the elevation module. `vulcanus_shared` needed no +port - it is `starting_spot_at_angle`, done in #279 - and `vulcanus_seed` +landed in phase 2. Still out: `vulcanus_resources`, `tiles/vulcanus_catalog`, +and the cliff, resource and rock stacks. + +Tier 1 grades **24 named fields** across six fixtures. Every count was measured +again on the TypeScript side against the same fixture and all 24 agree, so they +are the distance BOTH ports sit from the game rather than a gap between them. + +**Four things this phase measured that are worth more than the counts:** + +- **A second, independent fixture points at #269.** `hairline_cracks` scores + **3 of 61** and is the shallowest expression in its layer - a bare `plasma`, + nothing composed on top - so the weakness cannot come from the crack file. + `plasma` subtracts two `basis_noise_expr` results, and that adapter returns + the un-narrowed f64 product #269 is about. The crack layer calls it at + `0.3 * 0.325` and `0.6 * 0.325`, neither exact in f32, which is exactly the + case #269 records `oracle-basis` as blind to by construction. Do not chase + the five crack counts before #269 is settled. +- **A clamp flatters a count, and here it is measurable.** The three clamped + biomes score 403, 402 and 408 of 434 against their own unclamped sources at + 128, 107 and 127 - the same quantity, times 2, clamped. Nothing improved + between them: the clamp saturates at 0 or 1 over most of the map and a + saturated position is exact for free. Read `*_biome_full` as the port's score + and `*_biome` as what the consumer needs. Same effect in `starting_area` + (371 of 410) against the unclamped `ashlands_start` (61) feeding it. +- **The oracle cannot see elevation's `-500` clamp**, and that was checked + rather than assumed. `vulcanus_elevation` is `max(-500, elev)` and the + captured `elev` bottoms out at **-58.77**, so the two columns are the same + field at all 434 positions - 0 of 434 differ - and a port that dropped the + `max` would score 113 either way. Both are graded anyway; the clamp's real + test lives in the module, constructing the case the fixture does not. +- **A discrete output scores like one.** `mountain_volcano_spots` at 359 of 434 + is the highest UNCLAMPED count in the Vulcanus port, because it is dominated + by which single candidate survives per region - a choice a sub-ULP error + almost never changes. The same property `voronoi_cell_id` has. + +**`detailNoise` is the reading to carry out of this phase.** It has the +SMALLEST residual of its three helper fields (7.778e-5) and the FEWEST exact +matches (**1 of 38**), where `mountainPlasma` has 2.807e-3 and 7 of 38. A field +can be uniformly close and almost never right, which is the argument for +counting matches rather than bounding error, stated in one number. + +Read elevation's worst residual of 1.332e-1 against its scale before reacting: +the field spans -58 to +1024, so that is ~1.3e-4 relative, the same order as +every layer above it. An absolute bound would need re-tuning per field for +scale alone - a third reason not to use one. + +**`vulcanus_biomes` keeps a REAL cache, and it is the only layer that does.** +Every other ported layer evaluates top to bottom into locals, because every read +is at the same `(x, y)`. `raw_spots` is not: it reads selected spots from up to +four neighbouring regions, which is genuine cross-position state. The region +cache is a `RefCell` so `eval` can stay `&self` while the density and +favorability closures handed to `select_spots` borrow it. `BTreeMap` rather than +`HashMap` deliberately - nothing iterates it today, but a determinism-critical +port should not carry a container whose iteration order is unspecified. + +`volcano_area` is evaluated at every spot candidate and pulls the whole +pre-volcano chain at that candidate; the TypeScript memoizes those and the port +recomputes them. **Nothing on the render path reaches this layer yet**, so it is +correct-first on purpose. If it ever joins a per-pixel render that is the first +measurement to take - `multioctave_noise`'s own docs record what happened last +time a per-call rebuild went unmeasured, which was 20x. + +**The mountains pre-volcano split is load-bearing.** `mountain_volcano_spots` +depends on the mountains biome and the mountains biome folds the volcano field +back in; the Lua breaks that with a PRE-volcano stage that `volcano_area` reads. +Collapsing the two is an infinite recursion, which announces itself - reading +`volcano_area` off the POST-volcano raw does not. + +**`cliff_elevation` is a separate entry point, not a convenience.** +`multisample`'s offsets are in the CONSUMING program's grid units, so the cliff +generator's 4-tile lattice moves the field 16 tiles for a `dx` of 4 (#83). The +tile and terrain channels pass 1; cliffs pass 4; both go through one code path +with the grid as a parameter. + **Tier 3 now covers both preview PNGs**, which is what #224's gate asks for. `test/wasmFulgoraRenderParity.spec.ts` renders through the real boundary and compares against the images Factorio itself produced: diff --git a/crates/fmw-noise/src/expressions/vulcanus_elevation.rs b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs index ee06de38..2517c92a 100644 --- a/crates/fmw-noise/src/expressions/vulcanus_elevation.rs +++ b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs @@ -141,8 +141,16 @@ impl<'a> VulcanusElevation<'a> { biomes, climate, mountain_basis: BasisLeaf::new(seed0, 13_423, 1.0 / 500.0, 250.0), - // Note the input scale divides by the ashlands LOCAL scale of 3, and - // by the volcanism scale multiplier - not by the crack scale. + // The input scale MULTIPLIES by the volcanism scale multiplier and + // divides by 50 and by the ashlands LOCAL scale of 3 - not by the + // crack scale, and the multiplier is not a divisor. + // + // Spelled out because no test here can tell the two apart: + // `scale_multiplier` is exactly 1 at the default preset, which is + // what every fixture and the poison gate run at, so + // `m / 50 / 3` and `1 / 50 / 3 / m` are bit-identical throughout. + // The forms only diverge at a non-default volcanism FREQUENCY + // slider, and they would diverge with the whole gate green. ashlands_basis: BasisLeaf::new( seed0, 12_643, diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index 12b88c4b..e56d804f 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2434,14 +2434,39 @@ fn reproduces_the_vulcanus_crack_layer_at_every_captured_position() { // 5.460e-4, 6.387e-4. // // `hairlineCracks` at 3 of 61 is the weakest and it is the SHALLOWEST - // expression in the layer - a bare `plasma` with nothing composed on top. - // That points at the plasma adapter rather than at anything this file - // builds, and specifically at #269: `basis_noise_expr` returns an - // un-narrowed f64 product where the game narrows to f32, and `plasma` is - // two of those subtracted. The crack layer's scales are 0.3 * 0.325 and - // 0.6 * 0.325, neither exact in f32, so this layer is exactly the case the - // #269 comment says the existing `oracle-basis` fixture is blind to. Do not - // chase these five counts before that one is settled. + // expression in the layer - a bare `plasma` with nothing composed on top - + // so the weakness cannot come from anything this file builds. It points at + // the plasma adapter, and specifically at #269: `basis_noise_expr` returns + // an un-narrowed f64 product where the game narrows to f32, and `plasma` is + // two of those subtracted. + // + // **Which call sites #269 can reach is decided by the OUTPUT scale alone, + // and it is a clean rule.** `basis_noise` returns an f32, so multiplying by + // a POWER OF TWO is a pure exponent shift and can never leave the f32 grid: + // narrowing that product is the identity. Any other output scale can. + // Measured over 90,000 samples at a fixed input scale, output scale + // 1 / 0.5 / 0.25 / 2 / 4 / 64 each change **0.00%** of products, while 0.6 + // changes 79.88%, 0.75 and 3 change 56.32%, 150 changes 97.46% and 125 + // changes 98.38%. Holding the output scale at 1 and sweeping the INPUT + // scale over 0.125, 0.205, 0.51, 0.6, 1.5 and 0.002 changes 0.00% every + // time - the input scale decides which noise value you get, never whether + // the product is representable. + // + // So of the twelve `basis_noise_expr` calls this layer makes, exactly ONE + // is exposed: `hairline_cracks`'s first term, at output scale 0.6, where + // 80.10% of products differ. The other eleven sit at 1, 0.5 or 0.25 and are + // blind by construction. That is a one-term explanation for the one bad + // count here, and it is why the four fields built only from power-of-two + // sites score 15, 40, 10 and 8 rather than 3. + // + // It also explains why `oracle-basis` cannot grade this: that fixture was + // captured at output scale 1. Do not chase `hairlineCracks` before #269 is + // settled, and do not expect the other four to move when it is. + // + // An earlier draft of this comment blamed the layer's INPUT scales + // (0.3 * 0.325 and 0.6 * 0.325, neither exact in f32). That was wrong, and + // it is recorded rather than quietly deleted because it is the plausible + // guess: the input scale is the number that looks inexact. type C = CrackFields; for (key, want_exact, select) in [ ( From 5c9836ee69382d651517d0615bac94e9b24ed0f7 Mon Sep 17 00:00:00 2001 From: Eric J Date: Wed, 19 Aug 2026 19:09:22 -0700 Subject: [PATCH 5/5] Re-score phase 5 against the landed #269, and correct the prediction (#225) Rebased onto `df3e39e`, which narrowed `basis_noise`'s output scale. Six of the 24 frozen counts moved. The point of this commit is not the counts - it is that this branch predicted, in writing and before it could check, which ones would. hairlineCracks 3 -> 2 directly exposed at output scale 0.6 floodCracksA 15 -> 15 not exposed floodCracksB 40 -> 40 not exposed floodPaths 10 -> 10 not exposed floodBasaltsFunc 8 -> 9 exposed VIA hairline_cracks mountainPlasma 7 -> 11 directly exposed at 125 and 625 elev 113 -> 115 directly exposed at 250 and 150 elevation 113 -> 115 same field as elev in this fixture temperature 196 -> 196 unmoved despite reading elev ## Correction one: exposure is transitive `fixtures.rs` said "do not expect the other four to move", reasoning that eleven of this layer's twelve DIRECT `basis_noise_expr` calls sit at power-of-two output scales and are blind by construction. Three of the four held. `floodBasaltsFunc` did not. The reason is in the layer's own verbatim transcription at the top of `src/noise/expressions/vulcanusCracks.ts`: flood_basalts_func = min(max(flood_cracks_a - 0.125, flood_paths), flood_cracks_b) + 0.3 * min(0.5, hairline_cracks) It READS `hairline_cracks`, so the single exposed term reaches it. The three fields that held are exactly the three that never touch it. The rule is that a field is exposed if it reads an exposed site directly OR through any field it composes - counting call sites misses that, and this is the correction. ## Correction two: hairlineCracks got WORSE, 3 to 2 That is not evidence against #269. The primitive is graded 196/196 against the game at five output scales in `test/basisOutputScale.spec.ts`, which is as settled as this project gets. It is the both-directions movement #273 measured: these are deep composed chains carrying other unported narrowings, so correcting one term shifts values slightly and a position that happened to land exactly right can stop doing so. A count falling by one at 61 positions says the field is still wrong for reasons #269 does not address - the `input_scale` question recorded on that issue is the next term to look at. Recorded rather than smoothed over, because a count that moves the wrong way is the kind of thing a port quietly re-baselines and then cannot explain later. Comments carrying the old numbers are updated in the same pass: the helper, crack, elevation and temperature blocks in `fixtures.rs`, and the phase 5 section of CLAUDE.md. Worst residuals are left labelled as measured before #269, since only the counts were re-measured after it. 175 Rust tests pass. Full gate green at VERIFY_RC=0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --- CLAUDE.md | 47 +++++++++++--- crates/fmw-noise/src/fixtures.rs | 103 ++++++++++++++++++++++--------- 2 files changed, 111 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 31720f9a..c070d666 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1308,14 +1308,41 @@ are the distance BOTH ports sit from the game rather than a gap between them. **Four things this phase measured that are worth more than the counts:** -- **A second, independent fixture points at #269.** `hairline_cracks` scores - **3 of 61** and is the shallowest expression in its layer - a bare `plasma`, - nothing composed on top - so the weakness cannot come from the crack file. - `plasma` subtracts two `basis_noise_expr` results, and that adapter returns - the un-narrowed f64 product #269 is about. The crack layer calls it at - `0.3 * 0.325` and `0.6 * 0.325`, neither exact in f32, which is exactly the - case #269 records `oracle-basis` as blind to by construction. Do not chase - the five crack counts before #269 is settled. +- **A second, independent fixture pointed at #269, and #269 has since landed.** + `hairline_cracks` is the shallowest expression in its layer - a bare `plasma`, + nothing composed on top - so its weakness could not come from the crack file. + `plasma` subtracts two `basis_noise_expr` results, and that adapter returned + the un-narrowed f64 product. Fixed in `df3e39e`, and this branch re-scored + against it: + + | field | before | after | exposed? | + | -------------------- | ------- | ----------- | ----------------------------- | + | `hairlineCracks` | 3/61 | **2/61** | directly, at output scale 0.6 | + | `floodCracksA` | 15/61 | 15/61 | no | + | `floodCracksB` | 40/61 | 40/61 | no | + | `floodPaths` | 10/61 | 10/61 | no | + | `floodBasaltsFunc` | 8/61 | **9/61** | via `hairline_cracks` | + | `mountainPlasma` | 7/38 | **11/38** | directly, at 125 and 625 | + | `elev` / `elevation` | 113/434 | **115/434** | directly, at 250 and 150 | + + **Two corrections came out of that, and both are worth more than the counts.** + + First, **exposure is transitive.** `fixtures.rs` predicted the four flood + fields would not move, on the grounds that eleven of the layer's twelve DIRECT + `basis_noise_expr` calls sit at power-of-two output scales. Three held. + `floodBasaltsFunc` did not, because it READS `hairline_cracks` - + `+ 0.3 * min(0.5, hairline_cracks)`, right there in the layer's own verbatim + transcription. The three that held are exactly the three that never touch it. + Count composition, not call sites. + + Second, **`hairlineCracks` went DOWN, 3 to 2.** That is not evidence against + the fix: the primitive is graded 196/196 against the game at five output + scales. It is the both-directions movement #273 measured. These chains carry + other unported narrowings, so correcting one term shifts values slightly and a + position that happened to land exactly right can stop doing so. A count + falling by one at 61 positions says the field is still wrong for reasons this + change does not address. + - **A clamp flatters a count, and here it is measurable.** The three clamped biomes score 403, 402 and 408 of 434 against their own unclamped sources at 128, 107 and 127 - the same quantity, times 2, clamped. Nothing improved @@ -1327,7 +1354,7 @@ are the distance BOTH ports sit from the game rather than a gap between them. rather than assumed. `vulcanus_elevation` is `max(-500, elev)` and the captured `elev` bottoms out at **-58.77**, so the two columns are the same field at all 434 positions - 0 of 434 differ - and a port that dropped the - `max` would score 113 either way. Both are graded anyway; the clamp's real + `max` would score 115 either way. Both are graded anyway; the clamp's real test lives in the module, constructing the case the fixture does not. - **A discrete output scores like one.** `mountain_volcano_spots` at 359 of 434 is the highest UNCLAMPED count in the Vulcanus port, because it is dominated @@ -1336,7 +1363,7 @@ are the distance BOTH ports sit from the game rather than a gap between them. **`detailNoise` is the reading to carry out of this phase.** It has the SMALLEST residual of its three helper fields (7.778e-5) and the FEWEST exact -matches (**1 of 38**), where `mountainPlasma` has 2.807e-3 and 7 of 38. A field +matches (**1 of 38**), where `mountainPlasma` has 2.815e-3 and 11 of 38. A field can be uniformly close and almost never right, which is the argument for counting matches rather than bounding error, stated in one number. diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index e56d804f..08b748ca 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2346,20 +2346,25 @@ fn reproduces_the_vulcanus_helper_layer_at_every_captured_position() { detail_out.push(f64::from(detail.eval(x, y))); } - // Frozen exact counts, measured 2026-08-19 and matched field for field - // against the TypeScript on the same fixture - 38, 7 and 1, with the same - // worst residuals. They describe the distance BOTH ports still sit from the - // game, not a gap between them. + // Frozen exact counts, matched field for field against the TypeScript on + // the same fixture - 38, 11 and 1. They describe the distance BOTH ports + // still sit from the game, not a gap between them. + // + // `mountainPlasma` was 7 before #269 narrowed `basis_noise`'s output scale; + // it reads two basis calls at magnitudes 125 and 625, neither a power of + // two, so it is directly exposed. The other two are unaffected: `wobbleX` + // and `detailNoise` are `detail_noise` calls and never touch that term. for (key, want_exact, got) in [ // Bit-exact at all 38, worst residual exactly 0, deep-field point // included. Two octaves at magnitude 4 is the shallowest call the // fixture grades and nothing accumulates. ("wobbleX", 38usize, &wobble_x), // `abs(A - B)` of two basis calls at magnitudes 125 and 625; worst - // residual 2.807e-3. The output scales amplify the coordinate floor, + // residual 2.815e-3 (2.807e-3 before #269). The output scales amplify + // the coordinate floor, // which is why this is the loosest of the three in absolute terms while // still beating `detailNoise` on exact matches. - ("mountainPlasma", 7, &plasma), + ("mountainPlasma", 11, &plasma), // Four octaves at input scale 0.8; worst residual 7.778e-5. The // SMALLEST residual of the three and the FEWEST exact matches, which is // the whole argument for counting matches rather than bounding error: @@ -2425,15 +2430,16 @@ fn reproduces_the_vulcanus_crack_layer_at_every_captured_position() { assert_eq!(positions.len(), 61, "fixture size"); let (cracks, _) = vulcanus_sweep(fixture.get("seed0").as_f64() as u32, positions); - // Frozen exact counts, measured 2026-08-19 against the oracle and matched - // field for field against the TypeScript on the same fixture - 3, 15, 40, - // 10 and 8, with the same worst residuals. They are the distance BOTH ports - // sit from the game, not a gap between them. + // Frozen exact counts, matched field for field against the TypeScript on + // the same fixture - 2, 15, 40, 10 and 9. They are the distance BOTH ports + // sit from the game, not a gap between them. Before #269 they were 3, 15, + // 40, 10 and 8; the block below is the full account of what moved and why. // - // Worst residuals, in the same order: 1.853e-3, 4.440e-4, 1.122e-4, - // 5.460e-4, 6.387e-4. + // Worst residuals as measured 2026-08-19, BEFORE #269, in the same order: + // 1.853e-3, 4.440e-4, 1.122e-4, 5.460e-4, 6.387e-4. Only the counts were + // re-measured after the fix. // - // `hairlineCracks` at 3 of 61 is the weakest and it is the SHALLOWEST + // `hairlineCracks` at 2 of 61 is the weakest and it is the SHALLOWEST // expression in the layer - a bare `plasma` with nothing composed on top - // so the weakness cannot come from anything this file builds. It points at // the plasma adapter, and specifically at #269: `basis_noise_expr` returns @@ -2452,16 +2458,53 @@ fn reproduces_the_vulcanus_crack_layer_at_every_captured_position() { // time - the input scale decides which noise value you get, never whether // the product is representable. // - // So of the twelve `basis_noise_expr` calls this layer makes, exactly ONE - // is exposed: `hairline_cracks`'s first term, at output scale 0.6, where - // 80.10% of products differ. The other eleven sit at 1, 0.5 or 0.25 and are - // blind by construction. That is a one-term explanation for the one bad - // count here, and it is why the four fields built only from power-of-two - // sites score 15, 40, 10 and 8 rather than 3. + // So of the twelve DIRECT `basis_noise_expr` calls this layer makes, + // exactly ONE is exposed: `hairline_cracks`'s first term, at output scale + // 0.6, where 80.10% of products differ. The other eleven sit at 1, 0.5 or + // 0.25 and are blind by construction. // // It also explains why `oracle-basis` cannot grade this: that fixture was - // captured at output scale 1. Do not chase `hairlineCracks` before #269 is - // settled, and do not expect the other four to move when it is. + // captured at output scale 1. + // + // ## What happened when #269 landed, and where the paragraph above was wrong + // + // This comment used to end "do not expect the other four to move when it + // is". Three of the four held. `floodBasaltsFunc` did not, and the reason + // is a real correction rather than noise: **exposure is transitive through + // composition, and counting DIRECT call sites misses that.** This layer's + // own verbatim transcription says so at the top of + // `src/noise/expressions/vulcanusCracks.ts`: + // + // flood_basalts_func = min(max(flood_cracks_a - 0.125, flood_paths), + // flood_cracks_b) + // + 0.3 * min(0.5, hairline_cracks) + // + // `flood_basalts_func` READS `hairline_cracks`, so the one exposed term + // reaches it. `flood_cracks_a`, `flood_cracks_b` and `flood_paths` do not + // read it, and those are exactly the three that did not move. The rule is + // therefore: a field is exposed if it reads an exposed site DIRECTLY OR + // THROUGH ANY FIELD IT COMPOSES. + // + // Measured across the #269 fix: + // + // hairlineCracks 3 -> 2 directly exposed (0.6) + // floodCracksA 15 -> 15 not exposed + // floodCracksB 40 -> 40 not exposed + // floodPaths 10 -> 10 not exposed + // floodBasaltsFunc 8 -> 9 exposed VIA hairline_cracks + // + // **`hairlineCracks` went DOWN, from 3 to 2.** That is not evidence the fix + // is wrong - the primitive is graded 196/196 against the game at five + // output scales in `test/basisOutputScale.spec.ts`, which is as settled as + // this project gets. It is the both-directions movement #273 measured: + // these are deep composed chains whose remaining error comes from other + // unported narrowings, so correcting one term shifts values slightly and a + // position that happened to land exactly right can stop doing so. A count + // dropping by one at 61 positions says the field is still wrong for + // reasons this change does not address, not that the change hurt it. + // + // Do not "fix" `hairlineCracks` by reverting anything here. The next term + // to look at is the `input_scale` question recorded on #269. // // An earlier draft of this comment blamed the layer's INPUT scales // (0.3 * 0.325 and 0.6 * 0.325, neither exact in f32). That was wrong, and @@ -2471,13 +2514,13 @@ fn reproduces_the_vulcanus_crack_layer_at_every_captured_position() { for (key, want_exact, select) in [ ( "hairlineCracks", - 3usize, + 2usize, &(|f: &C| f.hairline_cracks) as &dyn Fn(&C) -> f64, ), ("floodCracksA", 15, &|f| f.flood_cracks_a), ("floodCracksB", 40, &|f| f.flood_cracks_b), ("floodPaths", 10, &|f| f.flood_paths), - ("floodBasaltsFunc", 8, &|f| f.flood_basalts_func), + ("floodBasaltsFunc", 9, &|f| f.flood_basalts_func), ] { let got: Vec = cracks.iter().map(select).collect(); assert_eq!( @@ -2700,15 +2743,17 @@ fn reproduces_the_vulcanus_elevation_surface_at_every_captured_position() { .map(|p| elevation.eval(p.get("x").as_f64(), p.get("y").as_f64())) .collect(); - // Frozen exact counts, measured 2026-08-19 and matched by the TypeScript at - // 113 and 113, worst residual 1.332e-1 on both. + // Frozen exact counts, matched by the TypeScript at 115 and 115 (113 and + // 113 before #269), worst residual 1.332e-1 on both as measured before it. + // The chain reads basis calls at output scales 250 and 150 plus both + // `plasma` pairs, so it is directly exposed. // // **The two columns are the same field in this fixture, and that is a gap // in the fixture rather than a result.** `vulcanus_elevation` is // `max(-500, elev)`, and the captured `elev` never goes below -500: its // minimum over all 434 positions is -58.77. So `elev` equals `elevation` at // every position, the clamp is never exercised, and a port that dropped the - // `max` entirely would score 113 here too. Checked rather than assumed - + // `max` entirely would score 115 here too. Checked rather than assumed - // 0 of 434 positions differ between the two columns. // // Grading both anyway, because the cost is nothing and the day a capture @@ -2723,8 +2768,8 @@ fn reproduces_the_vulcanus_elevation_surface_at_every_captured_position() { // the third argument for counting matches instead. type E = ElevationFields; for (key, want_exact, select) in [ - ("elev", 113usize, &(|f: &E| f.elev) as &dyn Fn(&E) -> f64), - ("elevation", 113, &|f| f.elevation), + ("elev", 115usize, &(|f: &E| f.elev) as &dyn Fn(&E) -> f64), + ("elevation", 115, &|f| f.elevation), ] { let got: Vec = fields.iter().map(select).collect(); assert_eq!( @@ -2765,7 +2810,7 @@ fn reproduces_the_vulcanus_temperature_at_every_captured_position() { .collect(); // Frozen exact count, matched by the TypeScript at 196 with the same worst - // residual of 1.327e-1. Higher than `elev`'s 113 despite reading it, + // residual of 1.327e-1. Higher than `elev`'s 115 despite reading it, // because temperature scales it by 1/100 above zero - `min(e, e / 100)` - // so most of `elev`'s residual is divided away before it lands here. assert_eq!(