diff --git a/CLAUDE.md b/CLAUDE.md index 9b2ddc29..c070d666 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,112 @@ 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 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 + 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 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 + 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.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. + +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/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..0de873cd 100644 --- a/crates/fmw-noise/src/expressions/mod.rs +++ b/crates/fmw-noise/src/expressions/mod.rs @@ -15,4 +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_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/expressions/vulcanus_elevation.rs b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs new file mode 100644 index 00000000..2517c92a --- /dev/null +++ b/crates/fmw-noise/src/expressions/vulcanus_elevation.rs @@ -0,0 +1,375 @@ +//! 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), + // 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, + 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_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/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 a41eca89..08b748ca 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -2272,3 +2272,551 @@ 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, 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.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", 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: + // 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); +} + +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, 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 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 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 + // 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 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. + // + // ## 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 + // 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 [ + ( + "hairlineCracks", + 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", 9, &|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" + ); + } +} + +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, 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 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 + // 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", 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!( + 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 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!( + score_vulcanus(&got, fixture.get("temperature").as_array(), "temperature"), + 196, + "temperature exact f32 matches out of {}", + positions.len() + ); +} 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..d2070dd9 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -96,6 +96,16 @@ 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 + 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 ) for t in "${POISONED_TESTS[@]}"; do