From 55e8688764e342c2c562144ab5d0b4ab6728474d Mon Sep 17 00:00:00 2001 From: Eric J Date: Mon, 24 Aug 2026 11:49:04 -0700 Subject: [PATCH 1/3] Port the Vulcanus cliff stack to Rust, graded end to end (#225) The engine-generic cliff machinery plus Vulcanus's two cliff fields and its ore rejection. Nothing renders through this yet - the ABI and the render path are the next commit - so `engine.wasm` is unchanged apart from two panic-location line numbers (see below). What landed: - `cliffs/catalog.rs` - the placement grid, the 20 placing codes, the code -> orientation bijection, and the per-orientation collision boxes. The boxes ship as quantised literals with `rotbb_box` kept live beside them and a test asserting the two agree bit-for-bit, so the render path does no floating-point rounding for them while the derivation stays checkable against the Lua. - `cliffs/placement.rs` - `crossesCliff`, the per-chunk `fixImpossibleCells` sweep including its self-set retry flag, the `cliff_smoothing` knot model, and the chunk-structured enumeration that keeps worker tiling byte-identical. - `cliffs/connections.rs` - `Cliff::updateConnections` and `onDestroy`. Not on any render path; it is the model #84's investigation is scored with, ported so that investigation can run against the engine too. - `cliffs/vulcanus_fields.rs` - `cliffiness_basic` and the cliff-channel elevation, plus the lava tile gate. - `cliffs/vulcanus_ore_rejection.rs` and `resources/vulcanus_catalog.rs` - the ORE -> CLIFF removal and the solid-ore footprint it asks about. Tier 1, against the game's own cliff entities across three regions: | arm | game | ours | matched | orientation | | --- | ---: | ---: | ---: | ---: | | lava only | 1569 | 1570 | 1525 | 1492 | | shipping | 1569 | 1547 | 1525 | 1504 | All 24 of those numbers were measured on the TypeScript side with the same two arms against the same fixture and agree exactly, so they describe the distance both ports sit from the game rather than a gap between them. Because the orientation column agrees too, the ports produce the same cell CODES and not merely the same positions. The lava-only rows also reproduce the figures `vulcanusCliffEntities.spec.ts` publishes in its own header. The ore rule removes 23 cells, none of them a cliff the game kept, and turns 12 wrong orientations right - wrong orientations go 33 -> 21, which is exactly what `renderVulcanusCliffs.ts` records having measured, reached here through a separate implementation. `cliffiness` is exact at all 12,675 captured corners. The fixture's `elevation` column is the TILE channel, so grading `cliff_elevation` against it is a category error worth 60.6 tiles - that is issue #83, and the test now asserts the two grids DISAGREE at 2,519 of the corners rather than leaving it a comment. Three poison hooks, because three ops here can be wrong independently: `crossing_result` for the tri-state crossing, `sweep_order` for a pass with no value to bend at all, and the existing `f64_result` / `bool_result`. Under poison the crossing hook moves every edge in the lattice, so the end-to-end test would be red whether or not the sweep had a control - hence its own test in `POISONED_TESTS`. `engine.wasm` changes by exactly two bytes and they are both a `core::panic::Location` line number - the `RefCell` borrow sites in `vulcanus_resources.rs`, 427 -> 436 and 469 -> 478, because `OreRegions` added nine lines above them. Every wasm section keeps its exact size and no code byte moves. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj --- crates/fmw-noise/src/cliffs/catalog.rs | 444 +++++++++ crates/fmw-noise/src/cliffs/connections.rs | 578 ++++++++++++ crates/fmw-noise/src/cliffs/mod.rs | 18 + crates/fmw-noise/src/cliffs/placement.rs | 886 ++++++++++++++++++ .../fmw-noise/src/cliffs/vulcanus_fields.rs | 257 +++++ .../src/cliffs/vulcanus_ore_rejection.rs | 278 ++++++ .../src/expressions/vulcanus_resources.rs | 104 ++ .../src/expressions/vulcanus_stack.rs | 32 +- crates/fmw-noise/src/fixtures.rs | 375 ++++++++ crates/fmw-noise/src/lib.rs | 2 + crates/fmw-noise/src/poison.rs | 48 + crates/fmw-noise/src/resources/mod.rs | 13 + .../src/resources/vulcanus_catalog.rs | 74 ++ scripts/verify-rust.sh | 20 + src/noise/wasm/engine.wasm | Bin 138642 -> 138642 bytes 15 files changed, 3128 insertions(+), 1 deletion(-) create mode 100644 crates/fmw-noise/src/cliffs/catalog.rs create mode 100644 crates/fmw-noise/src/cliffs/connections.rs create mode 100644 crates/fmw-noise/src/cliffs/mod.rs create mode 100644 crates/fmw-noise/src/cliffs/placement.rs create mode 100644 crates/fmw-noise/src/cliffs/vulcanus_fields.rs create mode 100644 crates/fmw-noise/src/cliffs/vulcanus_ore_rejection.rs create mode 100644 crates/fmw-noise/src/resources/mod.rs create mode 100644 crates/fmw-noise/src/resources/vulcanus_catalog.rs diff --git a/crates/fmw-noise/src/cliffs/catalog.rs b/crates/fmw-noise/src/cliffs/catalog.rs new file mode 100644 index 00000000..1ec40cef --- /dev/null +++ b/crates/fmw-noise/src/cliffs/catalog.rs @@ -0,0 +1,444 @@ +//! Cliff catalog: the engine-level tables the placement pass keys on, ported +//! from `src/noise/cliffs/cliffCatalog.ts`. +//! +//! Everything here is `CliffGenerator` / `CellCliffCrossing` behaviour rather +//! than planet behaviour, so it is shared by every planet that places cliffs. +//! The Nauvis lever math that shares the TypeScript file (`sliderToLinear`, +//! `getModifiedElevationInterval`, `getModifiedRichness`) is deliberately NOT +//! here: `slider_to_linear` already lives in [`crate::eval::math`], and the +//! other two only serve the Nauvis cliff control, which arrives with #226. +//! Vulcanus has no cliff autoplace control at all. +//! +//! ## What was read out of the binary, and where +//! +//! The TypeScript file carries the full disassembly recipe and the addresses; +//! this repeats only what a reader needs to judge the tables: +//! +//! - [`CLIFF_PLACING_CODES`] and [`CLIFF_CODE_TO_ORIENTATION`] are the low and +//! high halves of the one 64-bit word `CellCliffCrossing::toMaybeCliffOrienta +//! tion` returns. The mapping is a BIJECTION - 20 codes onto 20 orientations, +//! none used twice and none unused - and [`tests::the_code_to_orientation_map +//! _is_a_bijection`] asserts that rather than trusting it. +//! - [`CLIFF_ORIENTATION_NAMES`] came from `CliffOrientationName::buildMapping`, +//! which registers name/value pairs in ascending value order, so the index +//! into that array IS the id the engine uses. The connection tables in +//! [`super::connections`] are derived from these names, which is what makes a +//! transcription slip fail rather than shift the model. +//! - [`CLIFF_ORIENTATION_COLLISION_BOX`] is the table the engine loads into +//! `proto + 0x5c0 + id * 0x48`, and `tryToAddCliff` hands it to `wouldCollide` +//! with `Direction = 0` - the identity arm, which copies the rectangle +//! verbatim and discards `rotbb`'s `1/8` orientation tag. So the collision +//! shape is the RAW stored rectangle, not a rotated one. +//! +//! ## The boxes are literals here and computed in the TypeScript +//! +//! The TypeScript builds this table at module load by calling `rotbbBox`. This +//! port ships the 20 rectangles as constants and keeps [`rotbb_box`] live +//! beside them, with [`tests::the_rotbb_derivation_reproduces_every_shipped_box`] +//! asserting the two agree bit-for-bit. +//! +//! That is strictly stronger than either half alone, and it is not a style +//! choice: every edge is an exact multiple of `1/256`, because `MapPosition` is +//! 8-bit fixed point and `rotbb`'s `sqrt(2)` cannot survive into the engine at +//! full precision. Shipping the quantised values means the render path does no +//! floating-point rounding for the boxes at all, while the derivation stays +//! checkable against the Lua it came from. + +/// Cliff placement grid cell size, in tiles. +pub const CLIFF_GRID_SIZE: f64 = 4.0; + +/// Cliff cell centre x, in cell-local tiles: `grid_size/2 + grid_offset.x`. +/// +/// **`grid_offset` belongs on the CENTRE and nowhere else.** The prototype's +/// `grid_offset` is `{0, 0.5}` for both `cliff` and `cliff-vulcanus`, and +/// `entity-util.lua:305` says what it is for in as many words: "cliffs are +/// auto-placed with centers at (0, 0.5) offset from the grid". The FIELDS are +/// sampled at the bare lattice `(i*4, j*4)` - `crossingsForChunk` reads +/// `grid_size` and never `grid_offset`. Adding it to the sample position too +/// was a real bug in the TypeScript until 2026-07-30, and it was invisible +/// because it moves no placed cliff. +pub const CLIFF_CELL_CENTER_X: f64 = 2.0; + +/// Cliff cell centre y - carries the prototype's `grid_offset.y` of 0.5. +/// +/// See [`CLIFF_CELL_CENTER_X`]. Every dumped cliff satisfies `x mod 4 == 2` and +/// `y mod 4 == 2.5`, which the oracle spec checks on the fixture itself. +pub const CLIFF_CELL_CENTER_Y: f64 = 2.5; + +/// Cells (and corners) per chunk axis: a 32-tile chunk over the 4-tile grid. +pub const CHUNK_CELLS: usize = 8; + +/// The 20 cell codes for which `toMaybeCliffOrientation` returns a real +/// orientation rather than "none". +/// +/// A `code` is `(enc(L) << 6) | (enc(R) << 4) | (enc(T) << 2) | enc(B)`, where +/// each 2-bit field encodes one edge crossing: `0 -> 0`, `+1 -> 1`, `-1 -> 3`. +pub const CLIFF_PLACING_CODES: [u8; 20] = [ + 1, 3, 4, 5, 12, 15, 16, 17, 28, 48, 51, 52, 64, 67, 68, 80, 192, 193, 204, 240, +]; + +/// True iff cell `code` places a cliff. +/// +/// A `match` rather than a materialised 256-entry table: the codes are a +/// compile-time constant set, so this compiles to a jump table with no static +/// to keep in step with [`CLIFF_PLACING_CODES`]. +#[inline] +#[must_use] +pub fn is_cliff_placed(code: u8) -> bool { + cliff_orientation_for_code(code).is_some() +} + +/// The 20 `CliffOrientation` enum values in enum order, so the index IS the id. +pub const CLIFF_ORIENTATION_NAMES: [&str; 20] = [ + "west-to-east", + "north-to-south", + "east-to-west", + "south-to-north", + "west-to-north", + "north-to-east", + "east-to-south", + "south-to-west", + "west-to-south", + "north-to-west", + "east-to-north", + "south-to-east", + "west-to-none", + "none-to-east", + "east-to-none", + "none-to-west", + "north-to-none", + "none-to-south", + "south-to-none", + "none-to-north", +]; + +/// `(cell code, orientation id)` - the full result of +/// `toMaybeCliffOrientation`, whose high 32 bits carry the id the low word's +/// tri-state only says exists. +pub const CLIFF_CODE_TO_ORIENTATION: [(u8, u8); 20] = [ + (1, 17), + (3, 18), + (4, 16), + (5, 1), + (12, 19), + (15, 3), + (16, 14), + (17, 6), + (28, 10), + (48, 13), + (51, 11), + (52, 5), + (64, 15), + (67, 7), + (68, 9), + (80, 2), + (192, 12), + (193, 8), + (204, 4), + (240, 0), +]; + +/// The `CliffOrientation` id cell `code` places, or `None` when it places +/// nothing. Agrees with [`is_cliff_placed`] by construction - that function is +/// defined in terms of this one. +#[inline] +#[must_use] +pub fn cliff_orientation_for_code(code: u8) -> Option { + let mut i = 0; + while i < CLIFF_CODE_TO_ORIENTATION.len() { + let (c, id) = CLIFF_CODE_TO_ORIENTATION[i]; + if c == code { + return Some(id); + } + i += 1; + } + None +} + +/// A cell code that produces `orientation`. The mapping is a bijection, so this +/// is the exact inverse of [`cliff_orientation_for_code`]. +#[must_use] +pub fn cliff_code_for_orientation(orientation: u8) -> Option { + let mut i = 0; + while i < CLIFF_CODE_TO_ORIENTATION.len() { + let (c, id) = CLIFF_CODE_TO_ORIENTATION[i]; + if id == orientation { + return Some(c); + } + i += 1; + } + None +} + +/// An axis-aligned box in cell-centre-relative tiles: `[left, top, right, bottom]`. +pub type CliffCollisionBox = [f64; 4]; + +/// `CliffOrientation` id -> the orientation's `collision_bounding_box` at +/// `scale = 1.0`, relative to the cliff's centre. +/// +/// Transcribed from `create_cliff_data_specification` +/// (`base/prototypes/entity/entity-util.lua:85`) by way of [`rotbb_box`], and +/// pinned bit-for-bit against the TypeScript's own computation. Every edge is a +/// multiple of `1/256` - see the module docs for why that is the format rather +/// than a coincidence. +pub const CLIFF_ORIENTATION_COLLISION_BOX: [CliffCollisionBox; 20] = [ + [-2.0, -1.5, 2.0, 1.5], // 0 west-to-east + [-1.0, -2.0, 1.0, 2.0], // 1 north-to-south + [-2.0, -0.5, 2.0, 0.5], // 2 east-to-west + [-1.0, -2.0, 1.0, 2.0], // 3 south-to-north + [-2.3125, -2.87109375, -0.1875, 1.37109375], // 4 west-to-north + [-0.87109375, -1.8125, 3.37109375, 0.3125], // 5 north-to-east + [0.04296875, -0.51953125, 1.45703125, 3.01953125], // 6 east-to-south + [-2.51953125, 0.54296875, 1.01953125, 1.95703125], // 7 south-to-west + [-3.37109375, -0.3125, 0.87109375, 1.8125], // 8 west-to-south + [-1.45703125, -3.01953125, -0.04296875, 0.51953125], // 9 north-to-west + [-1.01953125, -1.95703125, 2.51953125, -0.54296875], // 10 east-to-north + [0.1875, -1.37109375, 2.3125, 2.87109375], // 11 south-to-east + [-2.20703125, -1.4140625, -0.79296875, 1.4140625], // 12 west-to-none + [0.0859375, -0.70703125, 2.9140625, 0.70703125], // 13 none-to-east + [0.89453125, -0.6640625, 1.60546875, 2.1640625], // 14 east-to-none + [-2.66796875, 0.40234375, 0.17578125, 1.109375], // 15 none-to-west + [-0.9140625, -1.70703125, 1.9140625, -0.29296875], // 16 north-to-none + [0.14453125, -0.76953125, 0.85546875, 2.76953125], // 17 none-to-south + [-2.26953125, 0.64453125, 1.26953125, 1.35546875], // 18 south-to-none + [-1.20703125, -2.4140625, 0.20703125, 0.4140625], // 19 none-to-north +]; + +/// The four straight orientations, written as plain boxes in the Lua rather +/// than through `rotbb`. +/// +/// Public because it is half the derivation record, not an implementation +/// detail: [`CLIFF_ORIENTATION_COLLISION_BOX`]'s first four entries come from +/// here and its other sixteen from [`rotbb_box`], and a reader checking the +/// table against `create_cliff_data_specification` needs both halves. +pub const CLIFF_STRAIGHT_COLLISION_BOX: [CliffCollisionBox; 4] = [ + [-2.0, -1.5, 2.0, 1.5], // 0 west-to-east + [-1.0, -2.0, 1.0, 2.0], // 1 north-to-south + [-2.0, -0.5, 2.0, 0.5], // 2 east-to-west + [-1.0, -2.0, 1.0, 2.0], // 3 south-to-north +]; + +/// `rotbb(x, y, size, intersect)`'s four arguments per orientation id, verbatim +/// from `create_cliff_data_specification`, or `None` for the four straight +/// orientations. +pub const CLIFF_ORIENTATION_ROTBB: [Option<[f64; 4]>; 20] = [ + None, // 0 west-to-east + None, // 1 north-to-south + None, // 2 east-to-west + None, // 3 south-to-north + Some([-3.5, -3.0, 4.5, 3.0]), // 4 west-to-north + Some([-1.0, -3.0, 4.5, 1.5]), // 5 north-to-east + Some([-1.0, -0.5, 3.5, 2.5]), // 6 east-to-south + Some([-2.5, -0.5, 3.5, 1.0]), // 7 south-to-west + Some([-3.5, -1.5, 4.5, 1.5]), // 8 west-to-south + Some([-2.5, -3.0, 3.5, 2.5]), // 9 north-to-west + Some([-1.0, -3.0, 3.5, 1.0]), // 10 east-to-north + Some([-1.0, -1.5, 4.5, 3.0]), // 11 south-to-east + Some([-3.0, -1.5, 3.0, 2.0]), // 12 west-to-none + Some([0.0, -1.5, 3.0, 1.0]), // 13 none-to-east + Some([0.0, -0.5, 2.5, 2.0]), // 14 east-to-none + Some([-2.5, -0.5, 2.51, 0.5]), // 15 none-to-west + Some([-1.0, -2.5, 3.0, 1.0]), // 16 north-to-none + Some([-1.0, -0.5, 3.0, 2.5]), // 17 none-to-south + Some([-2.0, -0.5, 3.0, 0.5]), // 18 south-to-none + Some([-2.0, -2.5, 3.0, 2.0]), // 19 none-to-north +]; + +/// `Math.sqrt(2)`, which `std::f64::consts::SQRT_2` is bit-for-bit. +/// +/// Aliased rather than used inline so the identity has somewhere to be asserted: +/// [`rotbb_box`] must evaluate the same arithmetic the TypeScript does, and the +/// TypeScript writes the literal `1.4142135623730951`. Both are the correctly +/// rounded binary64 value, and +/// [`tests::the_square_root_constant_is_the_one_the_typescript_writes`] pins it. +const SQRT2: f64 = std::f64::consts::SQRT_2; + +/// `Math.round`, which is NOT `f64::round`. +/// +/// JavaScript rounds a half UP (toward `+inf`), so `Math.round(-0.5)` is `-0`; +/// Rust rounds a half AWAY FROM ZERO, so `(-0.5f64).round()` is `-1`. +/// +/// Every edge below is far from a half in practice - `rotbb`'s `sqrt(2)` sees +/// to that - but "in practice" is not a reason to write the other function, and +/// [`tests::the_rounding_is_javascripts_and_not_rusts`] plants the case that +/// separates them. +#[inline] +fn js_round(v: f64) -> f64 { + (v + 0.5).floor() +} + +/// `rotbb(x, y, size, intersect)` as the ENGINE reads it back +/// (`entity-util.lua:9`), returning the RAW rectangle. +/// +/// `rotbb` builds a rectangle centred at `(x + size/2, y + size/2)` with +/// half-extents `((1 - intersect/size) * d, (intersect/size) * d)` where +/// `d = size/2 * sqrt(2)`, and tags it with an orientation of `1/8`. **The tag +/// is discarded for collision** - three steps of disassembly establish it, and +/// the module docs name them - so this returns the rectangle unrotated. +/// +/// Two wrong shapes shipped in the TypeScript before this one, and the more +/// accurate-looking of them was the wrong one: a 45-degree separating-axis test +/// scored better on every metric because it also absorbed an unrelated +/// orientation defect. See `test/cliffCollisionBox.spec.ts`. +/// +/// Edges are quantised to `1/256` because `MapPosition` is 8-bit fixed point. +#[must_use] +pub fn rotbb_box(x: f64, y: f64, size: f64, intersect: f64) -> CliffCollisionBox { + let dist = (size / 2.0) * SQRT2; + let y_ratio = intersect / size; + let x_dist = (1.0 - y_ratio) * dist; + let y_dist = y_ratio * dist; + let cx = x + size / 2.0; + let cy = y + size / 2.0; + let q = |v: f64| js_round(v * 256.0) / 256.0; + [ + q(cx - x_dist), + q(cy - y_dist), + q(cx + x_dist), + q(cy + y_dist), + ] +} + +/// An inclusive tile-index rectangle: every tile in it is tested for collision. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CliffTileBox { + pub left: i64, + pub top: i64, + pub right: i64, + pub bottom: i64, +} + +/// The tile rectangle `EntityMapGenerationTask::wouldCollide` scans for a cliff +/// of cell `code` centred at `(center_x, center_y)`. +/// +/// Both ends are **inclusive** and both come from a **floor**, because the +/// engine works in `MapPosition`'s 8-bit fixed point and takes +/// `(box + position) >> 8` - an arithmetic shift, so a box edge landing exactly +/// on a tile boundary still pulls that tile in. The straight orientations' boxes +/// are 4 tiles wide and land on integers, so an exclusive right edge would test +/// a 4-wide span where the game tests 5. +#[must_use] +pub fn cliff_collision_tile_box(code: u8, center_x: f64, center_y: f64) -> Option { + let orientation = cliff_orientation_for_code(code)?; + let [l, t, r, b] = CLIFF_ORIENTATION_COLLISION_BOX[orientation as usize]; + Some(CliffTileBox { + left: (center_x + l).floor() as i64, + top: (center_y + t).floor() as i64, + right: (center_x + r).floor() as i64, + bottom: (center_y + b).floor() as i64, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_placing_codes_and_the_orientation_map_describe_the_same_set() { + for code in 0..=255u8 { + assert_eq!( + CLIFF_PLACING_CODES.contains(&code), + is_cliff_placed(code), + "code {code} disagrees between the two tables" + ); + } + } + + /// The 20 placing codes map onto the 20 orientations one-for-one, with no id + /// used twice and none unused. Asserted rather than trusted - it is what + /// lets [`cliff_code_for_orientation`] exist at all. + #[test] + fn the_code_to_orientation_map_is_a_bijection() { + let mut seen = [false; 20]; + for (code, id) in CLIFF_CODE_TO_ORIENTATION { + assert!( + is_cliff_placed(code), + "code {code} maps to an id but places nothing" + ); + assert!(!seen[id as usize], "orientation {id} is used twice"); + seen[id as usize] = true; + assert_eq!(cliff_code_for_orientation(id), Some(code)); + } + assert!( + seen.iter().all(|s| *s), + "an orientation id is never produced" + ); + } + + /// The shipped literal table against the `rotbb` derivation it came from. + /// A transcription slip in either fails here rather than moving a cliff. + #[test] + fn the_rotbb_derivation_reproduces_every_shipped_box() { + for (id, want) in CLIFF_ORIENTATION_COLLISION_BOX.iter().enumerate() { + let got = match CLIFF_ORIENTATION_ROTBB[id] { + None => CLIFF_STRAIGHT_COLLISION_BOX[id], + Some([x, y, size, intersect]) => rotbb_box(x, y, size, intersect), + }; + assert_eq!( + got.map(f64::to_bits), + want.map(f64::to_bits), + "orientation {id} ({}) box", + CLIFF_ORIENTATION_NAMES[id] + ); + } + } + + /// Every edge is an exact multiple of `1/256`, which is what makes the + /// literal table above bit-exact rather than approximately right. + #[test] + fn every_box_edge_lands_on_the_eight_bit_fixed_point_grid() { + for (id, box_) in CLIFF_ORIENTATION_COLLISION_BOX.iter().enumerate() { + for edge in box_ { + let scaled = edge * 256.0; + assert_eq!( + scaled, + scaled.trunc(), + "orientation {id} edge {edge} is not a 1/256 multiple" + ); + } + } + } + + /// `Math.round` and `f64::round` disagree on a negative half, and this port + /// needs JavaScript's. Planted, because no real box edge lands on one. + /// The TypeScript writes `1.4142135623730951`; Rust's constant must be the + /// same bits, or `rotbb_box` evaluates different arithmetic. + /// + /// `approx_constant` is allowed here precisely because the spelled-out + /// literal IS the assertion - the lint's advice, "use the constant + /// directly", would turn this into `SQRT_2 == SQRT_2` and check nothing. + #[test] + #[allow(clippy::approx_constant)] + fn the_square_root_constant_is_the_one_the_typescript_writes() { + assert_eq!(SQRT2.to_bits(), 1.414_213_562_373_095_1_f64.to_bits()); + } + + #[test] + fn the_rounding_is_javascripts_and_not_rusts() { + assert_eq!(js_round(-0.5), 0.0); + assert_eq!((-0.5f64).round(), -1.0); + assert_eq!(js_round(0.5), 1.0); + assert_eq!(js_round(1.5), 2.0); + assert_eq!(js_round(-1.5), -1.0); + } + + /// The floor is inclusive at both ends, so a straight orientation's 4-tile + /// box scans FIVE tiles across. Getting this exclusive would silently + /// shrink every collision test. + #[test] + fn a_straight_orientations_box_scans_five_tiles_across() { + // Code 240 is `west-to-east`, whose box is [-2, -1.5, 2, 1.5]. + let b = cliff_collision_tile_box(240, 2.0, 2.5).expect("240 places a cliff"); + assert_eq!(b.left, 0); + assert_eq!(b.right, 4); + assert_eq!(b.right - b.left + 1, 5); + assert_eq!(b.top, 1); + assert_eq!(b.bottom, 4); + } + + #[test] + fn a_code_that_places_nothing_has_no_tile_box() { + assert_eq!(cliff_collision_tile_box(0, 2.0, 2.5), None); + assert_eq!(cliff_collision_tile_box(0x51, 2.0, 2.5), None); + } +} diff --git a/crates/fmw-noise/src/cliffs/connections.rs b/crates/fmw-noise/src/cliffs/connections.rs new file mode 100644 index 00000000..de483172 --- /dev/null +++ b/crates/fmw-noise/src/cliffs/connections.rs @@ -0,0 +1,578 @@ +//! `Cliff::updateConnections` and `Cliff::onDestroy` - the APPLY-time pass that +//! trims a cliff run back to where its connections actually resolve. +//! +//! Ported from `src/noise/cliffs/cliffConnections.ts`. Where +//! [`super::placement`] models `crossingsForChunk` and `generateCliffs` - +//! deciding the crossings and queueing a cliff per placing cell - this models +//! what happens **after** that, in `EntityMapGenerationTask::applyCliffs`: +//! +//! ```text +//! for each queued CliffAddition {u16 protoId, u8 orientation, MapPosition, bool}: +//! collided = Surface::wouldCollide(proto, position, orientation) +//! entity = proto->createEntity(spec) +//! addEntityToSurface(surface, entity) +//! if (collided) -> list A +//! else if (!record.bool) -> list B // record.bool is !onChunkBorder +//! for e in list A: e->forceDestroy() +//! for e in list B: e->updateConnections() +//! ``` +//! +//! Two things follow that are easy to miss: +//! +//! **The fifth argument of `tryToAddCliff` is what selects list B.** +//! `generateCliffs` computes `onChunkBorder = (cx==0 || cy==0 || cx==7 || +//! cy==7)` over the chunk's 8x8 cells and passes `!onChunkBorder`; `applyCliffs` +//! skips `updateConnections` when that byte is set. So this whole pass runs on +//! the chunk's outer ring and nowhere else. An earlier note read the flag as +//! "measured not to matter for placement" - true of `tryToAddCliff`, which +//! stores it and never reads it, and false of the queue's consumer. +//! +//! **List B is drained after the whole chunk is on the surface**, so within a +//! chunk there is no placement-order dependence. +//! +//! ## The orientation is read twice, and that is the whole reason a cell can +//! lose two ends in one pass +//! +//! `Cliff::updateConnections` reads the orientation it ITERATES once, before +//! the loop, and re-reads the one it COMPARES from `this+0x80` inside it. So a +//! `destroyEnd` earlier in the loop is visible to the sides after it. This port +//! does the same, and [`tests::a_cell_can_lose_both_ends_in_one_pass`] is what +//! keeps that from silently collapsing into a snapshot. +//! +//! ## The `this+0x83` gate is `proto->place_as_crater == nullptr` +//! +//! The constructor computes it in one instruction on `proto + 0xb90`, and +//! `CliffPrototype` has exactly one optional pointer-valued property. The same +//! byte gates `getNeighbor`, `destroyEnd`, `onDestroy`'s cascade, `connectEnd` +//! and `getConnections`. So `cliff-vulcanus` runs all of it and +//! **`crater-cliff` runs none of it** - craters are outside the connection +//! system entirely, which is worth knowing before attributing anything +//! crater-shaped to these rules. +//! +//! ## What is NOT on the render path +//! +//! Nothing in the shipped Vulcanus cliff overlay calls this. It is the model +//! #84's investigation is scored with, and it is ported so that investigation +//! can run against the engine rather than only against the TypeScript. Read +//! [`apply_cliff_connections`]'s own note on the halo before using it for +//! anything else. + +use std::collections::BTreeMap; + +use crate::cliffs::catalog::{ + cliff_code_for_orientation, cliff_orientation_for_code, CHUNK_CELLS, CLIFF_CELL_CENTER_X, + CLIFF_CELL_CENTER_Y, CLIFF_GRID_SIZE, +}; +use crate::cliffs::placement::PlacedCliffCell; +use crate::poison; + +/// `CellSide`, in the engine's enum order. +/// +/// Read off `getNeighborPosition`, whose four arms add `-grid.y`, `+grid.x`, +/// `+grid.y`, `-grid.x` to the cliff's position in that order. `NONE` is 4 and +/// is what the end tables store for the `A-to-none` half of a terminating +/// orientation. +pub const SIDE_NORTH: u8 = 0; +pub const SIDE_EAST: u8 = 1; +pub const SIDE_SOUTH: u8 = 2; +pub const SIDE_WEST: u8 = 3; +pub const SIDE_NONE: u8 = 4; + +/// `(from, to)` side per `CliffOrientation` id: the two byte tables at +/// `0x102ed8ff8` and `0x102ed9020` that `isCliffConnected` indexes. +/// +/// The bytes turned out to be exactly what the orientation NAMES say - +/// `west-to-east` is `(west, east)`, all 20, with `none` for the halves - so +/// [`tests::the_end_table_is_what_the_orientation_names_say`] re-derives this +/// from the names and asserts it matches. A transcription slip fails rather than +/// shifting the model. +pub const CLIFF_ORIENTATION_ENDS: [(u8, u8); 20] = [ + (SIDE_WEST, SIDE_EAST), // 0 west-to-east + (SIDE_NORTH, SIDE_SOUTH), // 1 north-to-south + (SIDE_EAST, SIDE_WEST), // 2 east-to-west + (SIDE_SOUTH, SIDE_NORTH), // 3 south-to-north + (SIDE_WEST, SIDE_NORTH), // 4 west-to-north + (SIDE_NORTH, SIDE_EAST), // 5 north-to-east + (SIDE_EAST, SIDE_SOUTH), // 6 east-to-south + (SIDE_SOUTH, SIDE_WEST), // 7 south-to-west + (SIDE_WEST, SIDE_SOUTH), // 8 west-to-south + (SIDE_NORTH, SIDE_WEST), // 9 north-to-west + (SIDE_EAST, SIDE_NORTH), // 10 east-to-north + (SIDE_SOUTH, SIDE_EAST), // 11 south-to-east + (SIDE_WEST, SIDE_NONE), // 12 west-to-none + (SIDE_NONE, SIDE_EAST), // 13 none-to-east + (SIDE_EAST, SIDE_NONE), // 14 east-to-none + (SIDE_NONE, SIDE_WEST), // 15 none-to-west + (SIDE_NORTH, SIDE_NONE), // 16 north-to-none + (SIDE_NONE, SIDE_SOUTH), // 17 none-to-south + (SIDE_SOUTH, SIDE_NONE), // 18 south-to-none + (SIDE_NONE, SIDE_NORTH), // 19 none-to-north +]; + +/// `N<->S`, `E<->W`, and `none -> none`. +/// +/// In the binary this is the immediate `0x01000302` shifted right by +/// `side * 8`, appearing identically in `isCliffConnected` and +/// `Cliff::onDestroy`. +#[must_use] +pub fn opposite_side(side: u8) -> u8 { + if side < 4 { + ((0x0100_0302u32 >> (side * 8)) & 0xff) as u8 + } else { + SIDE_NONE + } +} + +/// `Cliff::neighborSidesForOrientation`: the orientation's ends, `none` +/// dropped. +/// +/// Its 20-entry jump table collapses to 10 blocks - `west-to-east` and +/// `east-to-west` share one, and so on - which is the binary saying outright +/// that only the SET of ends matters here, not their direction. +#[must_use] +pub fn connected_sides(orientation: u8) -> Vec { + let Some(&(from, to)) = CLIFF_ORIENTATION_ENDS.get(orientation as usize) else { + return Vec::new(); + }; + [from, to].into_iter().filter(|s| *s != SIDE_NONE).collect() +} + +/// `Cliff::destroyEnd(side)` as a pure function on the orientation: `side` +/// becomes `none`, and `None` means the cliff is destroyed because nothing is +/// left. A side the orientation does not have is a no-op. +#[must_use] +pub fn destroy_end(orientation: u8, side: u8) -> DestroyEnd { + let Some(&(from, to)) = CLIFF_ORIENTATION_ENDS.get(orientation as usize) else { + return DestroyEnd::Unchanged; + }; + let next = if from == side { + (SIDE_NONE, to) + } else if to == side { + (from, SIDE_NONE) + } else { + return DestroyEnd::Unchanged; + }; + if next.0 == SIDE_NONE && next.1 == SIDE_NONE { + return DestroyEnd::Destroyed; + } + CLIFF_ORIENTATION_ENDS + .iter() + .position(|e| *e == next) + .map_or(DestroyEnd::Unchanged, |i| DestroyEnd::Became(i as u8)) +} + +/// What [`destroy_end`] did to an orientation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DestroyEnd { + /// The orientation does not have that side; nothing happens. + Unchanged, + /// The orientation lost that end and became this one. + Became(u8), + /// Nothing is left, so the cliff itself goes. + Destroyed, +} + +/// `isCliffConnected(CellSide, CliffOrientation, CliffOrientation)`, which is a +/// **parity** test rather than a "do they touch" test. +/// +/// A cliff run is directed: `A-to-B` leaves through `B` and the next cell must +/// ENTER through `opposite(B)`, i.e. that side must be its `from`. So my `to` +/// end pairs with their `from` end and my `from` end with their `to` end, and a +/// neighbour presenting the right side with the wrong parity does not count as +/// connected. The `csel` at `0x1007a94c8` is what picks which arm applies, on +/// whether `side` is my `from`. +/// +/// **This is the op's poison hook.** The output is a classification, and the +/// whole pass is a chain of them, so a numeric perturbation could not reach it. +#[must_use] +pub fn is_cliff_connected(side: u8, mine: u8, theirs: u8) -> bool { + poison::bool_result(is_cliff_connected_inner(side, mine, theirs)) +} + +fn is_cliff_connected_inner(side: u8, mine: u8, theirs: u8) -> bool { + let (Some(&a), Some(&b)) = ( + CLIFF_ORIENTATION_ENDS.get(mine as usize), + CLIFF_ORIENTATION_ENDS.get(theirs as usize), + ) else { + return false; + }; + let opp = opposite_side(side); + if a.0 == side { + return b.0 != opp && b.1 == opp; + } + a.1 == side && b.0 == opp && b.1 != opp +} + +/// Cell-centre delta, in tiles, of the neighbour on `side`. +const SIDE_STEP: [(f64, f64); 4] = [ + (0.0, -CLIFF_GRID_SIZE), + (CLIFF_GRID_SIZE, 0.0), + (0.0, CLIFF_GRID_SIZE), + (-CLIFF_GRID_SIZE, 0.0), +]; + +/// The cell index a placed cliff centre sits at. Exact, because centres are +/// `cx * 4 + 2` and `cy * 4 + 2.5`. +#[must_use] +pub fn cell_index(x: f64, y: f64) -> (i64, i64) { + ( + ((x - CLIFF_CELL_CENTER_X) / CLIFF_GRID_SIZE).round() as i64, + ((y - CLIFF_CELL_CENTER_Y) / CLIFF_GRID_SIZE).round() as i64, + ) +} + +/// The centre of a cell index - the exact inverse of [`cell_index`]. +#[must_use] +pub fn cell_centre(cx: i64, cy: i64) -> (f64, f64) { + #[allow(clippy::cast_precision_loss)] + ( + cx as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X, + cy as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y, + ) +} + +/// True when the cell is on its chunk's outer ring, which is list B's domain. +#[must_use] +pub fn on_chunk_border(x: f64, y: f64) -> bool { + let (cx, cy) = cell_index(x, y); + let n = CHUNK_CELLS as i64; + let ix = cx.rem_euclid(n); + let iy = cy.rem_euclid(n); + ix == 0 || ix == n - 1 || iy == 0 || iy == n - 1 +} + +/// A placed cell carrying the orientation the connection pass left it with. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ConnectedCliffCell { + pub x: f64, + pub y: f64, + pub code: u8, + pub orientation: u8, +} + +/// `Surface::wouldCollide(CliffPrototype const&, MapPosition const&, +/// CliffOrientation)` - the collision test `applyCliffs` runs per queued cliff. +/// Return `true` to destroy it. +/// +/// **This, not `tryToAddCliff`, is where map generation rejects a cliff**, and +/// the note in `cliffs-NOTES.md` that said otherwise had the two modes the wrong +/// way round. `tryToAddCliff` tests collisions only when the task's mode byte is +/// `2`, and the constructors say which is which: real map generation stores +/// **1**, and the MAP PREVIEW generator stores **2**. So on a real map +/// `tryToAddCliff` runs no collision test at all. +/// +/// That matters because the two stages differ in what they do to the +/// NEIGHBOURS. A `tryToAddCliff` rejection simply never queues the cliff. The +/// `applyCliffs` rejection creates the cliff, adds it to the surface, and then +/// `forceDestroy()`s it - which runs `Cliff::onDestroy` and takes the facing end +/// of every connected neighbour with it. +pub trait ApplyCollision { + fn collides(&self, orientation: u8, x: f64, y: f64) -> bool; +} + +/// Levers on the connection pass. [`Default`] is the game. +#[derive(Default)] +pub struct CliffConnectionOptions<'a> { + /// See [`ApplyCollision`]. + pub collides: Option<&'a dyn ApplyCollision>, + /// Run `updateConnections` on every cell rather than only on the chunk's + /// outer ring. **Not the game's rule** - `applyCliffs` gates it on the fifth + /// argument of `tryToAddCliff` - and here only so a spec can measure what + /// the gate is worth. A rule that scored the same either way would not have + /// been read out of `generateCliffs` at all. + pub every_cell: bool, + /// Skip the `onDestroy` cascade, i.e. destroy a cliff without telling its + /// neighbours. Also not the game's rule - `Cliff::destroyWithoutCorrection` + /// exists precisely because the ordinary destroy DOES correct - and again + /// only here so the cascade can be scored separately. + pub no_cascade: bool, + /// Skip the `updateConnections` pass, leaving only the collision destroys. + pub no_update_connections: bool, +} + +/// The live cell set, keyed by cell index so the map needs no float keys. +type Live = BTreeMap<(i64, i64), u8>; + +/// Apply the connection pass to a set of placed cells and return the survivors. +/// +/// **Callers must supply a halo.** A cell on the query's outer chunk ring reads +/// its neighbour across the boundary, so cells are needed for one chunk beyond +/// whatever is to be kept, and the `onDestroy` cascade can in principle reach +/// further still. +/// +/// The chunk-generated test is modelled as "every chunk in the supplied set is +/// generated". That is the one place this is not a transcription: the game skips +/// a side whose neighbouring chunk has status `<= 0x31`, so during a real +/// generation sweep a cliff pointing into a not-yet-generated chunk keeps its +/// end, and this model destroys it. It is therefore an UPPER bound on how much +/// the rule removes. +#[must_use] +pub fn apply_cliff_connections( + cells: &[PlacedCliffCell], + opts: &CliffConnectionOptions<'_>, +) -> Vec { + let mut live: Live = BTreeMap::new(); + for c in cells { + if let Some(orientation) = cliff_orientation_for_code(c.code) { + live.insert(cell_index(c.x, c.y), orientation); + } + } + + // Chunk order is row-major over the supplied cells, and within a chunk the + // cells are visited in the order `generateCliffs` queues them (`cy` outer). + // The real order is the surface's chunk-generation order, which is not + // knowable from here; the spec's arms are what check the answer does not + // depend on it. + let n = CHUNK_CELLS as i64; + let mut order: Vec<(i64, i64)> = live.keys().copied().collect(); + order.sort_by_key(|&(cx, cy)| (cy.div_euclid(n), cx.div_euclid(n), cy, cx)); + + // `applyCliffs`' own two-phase shape, per chunk: every cliff is tested with + // the orientation it was queued with, and only then are the hits destroyed - + // so a destroy in this chunk cannot change what its neighbour was tested as. + if let Some(collides) = opts.collides { + let mut chunk: Option<(i64, i64)> = None; + let mut doomed: Vec<(i64, i64)> = Vec::new(); + for &k in &order { + let id = (k.0.div_euclid(n), k.1.div_euclid(n)); + if chunk != Some(id) { + for d in doomed.drain(..) { + force_destroy(&mut live, d, opts.no_cascade); + } + chunk = Some(id); + } + let Some(&orientation) = live.get(&k) else { + continue; + }; + let (x, y) = cell_centre(k.0, k.1); + if collides.collides(orientation, x, y) { + doomed.push(k); + } + } + for d in doomed { + force_destroy(&mut live, d, opts.no_cascade); + } + } + + if !opts.no_update_connections { + for &k in &order { + // It may have been destroyed by an earlier cell's cascade. + let Some(&at_entry) = live.get(&k) else { + continue; + }; + let (x, y) = cell_centre(k.0, k.1); + if !opts.every_cell && !on_chunk_border(x, y) { + continue; + } + // The sides come from the orientation read ONCE, before the loop; + // the comparison re-reads it. See the module docs. + for side in connected_sides(at_entry) { + let Some(&mine) = live.get(&k) else { break }; + let neighbour = neighbour_of(&live, k, side); + if neighbour.is_none_or(|theirs| !is_cliff_connected(side, mine, theirs)) { + do_destroy_end(&mut live, k, side, opts.no_cascade); + } + } + } + } + + emit(&live) +} + +fn neighbour_key(k: (i64, i64), side: u8) -> (i64, i64) { + let (dx, dy) = SIDE_STEP[side as usize]; + #[allow(clippy::cast_possible_truncation)] + ( + k.0 + (dx / CLIFF_GRID_SIZE) as i64, + k.1 + (dy / CLIFF_GRID_SIZE) as i64, + ) +} + +fn neighbour_of(live: &Live, k: (i64, i64), side: u8) -> Option { + live.get(&neighbour_key(k, side)).copied() +} + +/// `destroyEnd` plus the `onDestroy` cascade. Recursive because that is what the +/// engine does: `forceDestroy` calls `onDestroy`, which calls `destroyEnd` on +/// the neighbours, either of which can destroy again. +fn do_destroy_end(live: &mut Live, k: (i64, i64), side: u8, no_cascade: bool) { + let Some(&orientation) = live.get(&k) else { + return; + }; + match destroy_end(orientation, side) { + DestroyEnd::Unchanged => (), + DestroyEnd::Became(next) => { + live.insert(k, next); + } + DestroyEnd::Destroyed => { + // `Cliff::onDestroy` reads the sides of the orientation it still had + // at that moment, then tells each existing neighbour to lose its + // facing end. + cascade(live, k, orientation, no_cascade); + } + } +} + +/// `Entity::forceDestroy` on a cliff: it leaves, and its neighbours lose the +/// ends facing it. +fn force_destroy(live: &mut Live, k: (i64, i64), no_cascade: bool) { + let Some(&orientation) = live.get(&k) else { + return; + }; + cascade(live, k, orientation, no_cascade); +} + +fn cascade(live: &mut Live, k: (i64, i64), orientation: u8, no_cascade: bool) { + let sides = connected_sides(orientation); + live.remove(&k); + if no_cascade { + return; + } + for s in sides { + let nk = neighbour_key(k, s); + if live.contains_key(&nk) { + do_destroy_end(live, nk, opposite_side(s), no_cascade); + } + } +} + +fn emit(live: &Live) -> Vec { + live.iter() + .map(|(&(cx, cy), &orientation)| { + let (x, y) = cell_centre(cx, cy); + ConnectedCliffCell { + x, + y, + code: cliff_code_for_orientation(orientation).unwrap_or(0), + orientation, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cliffs::catalog::CLIFF_ORIENTATION_NAMES; + + fn side_of(name: &str) -> u8 { + match name { + "north" => SIDE_NORTH, + "east" => SIDE_EAST, + "south" => SIDE_SOUTH, + "west" => SIDE_WEST, + "none" => SIDE_NONE, + other => panic!("unknown side {other}"), + } + } + + /// The transcribed end table against the orientation NAMES it should be a + /// restatement of. This is the check that the bytes were read in the right + /// order rather than assumed. + #[test] + fn the_end_table_is_what_the_orientation_names_say() { + for (id, name) in CLIFF_ORIENTATION_NAMES.iter().enumerate() { + let (from, to) = name.split_once("-to-").expect("every name is A-to-B"); + assert_eq!( + CLIFF_ORIENTATION_ENDS[id], + (side_of(from), side_of(to)), + "orientation {id} ({name})" + ); + } + } + + #[test] + fn opposite_pairs_north_with_south_and_east_with_west() { + assert_eq!(opposite_side(SIDE_NORTH), SIDE_SOUTH); + assert_eq!(opposite_side(SIDE_SOUTH), SIDE_NORTH); + assert_eq!(opposite_side(SIDE_EAST), SIDE_WEST); + assert_eq!(opposite_side(SIDE_WEST), SIDE_EAST); + assert_eq!(opposite_side(SIDE_NONE), SIDE_NONE); + // An involution, which the shift table gives for free and a hand-written + // one would not. + for s in 0..5u8 { + assert_eq!(opposite_side(opposite_side(s)), s); + } + } + + /// Connection is a PARITY test. `west-to-east` meeting `west-to-east` on the + /// east side connects, because my `to` pairs with their `from`; meeting + /// `east-to-west` there does not, even though both present a west end. + #[test] + fn connection_is_a_parity_test_and_not_a_do_they_touch_test() { + let wte = 0; // west-to-east + let etw = 2; // east-to-west + assert!(is_cliff_connected(SIDE_EAST, wte, wte)); + assert!(!is_cliff_connected(SIDE_EAST, wte, etw)); + assert!(is_cliff_connected(SIDE_WEST, etw, etw)); + assert!(!is_cliff_connected(SIDE_WEST, etw, wte)); + } + + /// A two-ended orientation loses one end and becomes a terminator; the + /// terminator loses its last end and the cliff goes. + #[test] + fn destroying_both_ends_destroys_the_cliff() { + let wte = 0; // west-to-east + let DestroyEnd::Became(next) = destroy_end(wte, SIDE_EAST) else { + panic!("west-to-east should survive losing its east end"); + }; + assert_eq!(CLIFF_ORIENTATION_NAMES[next as usize], "west-to-none"); + assert_eq!(destroy_end(next, SIDE_WEST), DestroyEnd::Destroyed); + assert_eq!(destroy_end(next, SIDE_NORTH), DestroyEnd::Unchanged); + } + + /// The orientation the loop COMPARES is re-read inside it, so a cell whose + /// first side is destroyed is re-examined with its NEW orientation on the + /// second - which is how it can lose both ends in one pass. A snapshot of + /// the orientation would leave the cell alive. + #[test] + fn a_cell_can_lose_both_ends_in_one_pass() { + // One isolated `west-to-east` on the chunk's outer ring, with no + // neighbours at all: both ends fail to connect and it must vanish. + let (x, y) = cell_centre(0, 0); + assert!(on_chunk_border(x, y), "cell (0,0) is on its chunk's ring"); + let cells = [PlacedCliffCell { + x, + y, + code: cliff_code_for_orientation(0).expect("west-to-east has a code"), + }]; + let out = apply_cliff_connections(&cells, &CliffConnectionOptions::default()); + assert!( + out.is_empty(), + "an unconnected two-ended cliff loses both ends" + ); + } + + /// Off the ring, `updateConnections` never runs, so the same cell survives. + /// That is the gate the fifth argument of `tryToAddCliff` selects. + #[test] + fn a_cell_off_the_chunk_ring_is_left_alone() { + let (x, y) = cell_centre(3, 3); + assert!(!on_chunk_border(x, y)); + let cells = [PlacedCliffCell { + x, + y, + code: cliff_code_for_orientation(0).expect("west-to-east has a code"), + }]; + let out = apply_cliff_connections(&cells, &CliffConnectionOptions::default()); + assert_eq!(out.len(), 1); + assert_eq!(out[0].orientation, 0); + + // ...and `every_cell` is the lever that says what the gate is worth. + let opts = CliffConnectionOptions { + every_cell: true, + ..Default::default() + }; + assert!(apply_cliff_connections(&cells, &opts).is_empty()); + } + + #[test] + fn cell_index_and_centre_are_exact_inverses() { + for cx in -5..5i64 { + for cy in -5..5i64 { + let (x, y) = cell_centre(cx, cy); + assert_eq!(cell_index(x, y), (cx, cy)); + } + } + } +} diff --git a/crates/fmw-noise/src/cliffs/mod.rs b/crates/fmw-noise/src/cliffs/mod.rs new file mode 100644 index 00000000..f38931d4 --- /dev/null +++ b/crates/fmw-noise/src/cliffs/mod.rs @@ -0,0 +1,18 @@ +//! The cliff layer: the placement grid, the crossing rule, the repair sweep, +//! the apply-time connection pass, and Vulcanus's two cliff fields. +//! +//! Ported from `src/noise/cliffs/` in phase 5's second half (#225). What lives +//! here is `CliffGenerator` / `Cliff` behaviour rather than planet behaviour: +//! the 4-tile corner lattice, `crossesCliff`, `fixImpossibleCells`, the +//! orientation tables and the connection rules are the same on every planet. +//! Only [`vulcanus_fields`] and [`vulcanus_ore_rejection`] know which planet +//! they are on. +//! +//! Nauvis's own cliff fields (`cliffFields.ts`) are NOT here. They need +//! `nauvis_shared` and `elevation_nauvis`, which arrive with #226. + +pub mod catalog; +pub mod connections; +pub mod placement; +pub mod vulcanus_fields; +pub mod vulcanus_ore_rejection; diff --git a/crates/fmw-noise/src/cliffs/placement.rs b/crates/fmw-noise/src/cliffs/placement.rs new file mode 100644 index 00000000..3d5255bf --- /dev/null +++ b/crates/fmw-noise/src/cliffs/placement.rs @@ -0,0 +1,886 @@ +//! Cliff placement: the 4-tile corner lattice, `CliffGenerator::crossesCliff`, +//! the per-chunk repair sweep, and the `toMaybeCliffOrientation` filter that +//! turns four edge crossings into a placed cliff. +//! +//! Ported from `src/noise/cliffs/cliffPlacement.ts`. Everything here is engine +//! behaviour; the planet enters only through the two fields and the two band +//! numbers. +//! +//! ## What is a lever and what is the game +//! +//! The TypeScript carries several options that are explicitly **not** the +//! game's rule, added so a spec could measure what a rule is worth (#84). They +//! are ported because a control that only exists in the other language is not a +//! control for this one - the whole point of `sweep_edge_order` is that a +//! residual which does NOT move when the order is permuted is not caused by the +//! order, and that argument has to be runnable here too. +//! +//! [`CliffBands::default`] is the game: `L, T, R, B`, the repair sweep on, no +//! cascade, and the rejections acting on the crossing. + +use crate::cliffs::catalog::{ + cliff_collision_tile_box, is_cliff_placed, CHUNK_CELLS, CLIFF_CELL_CENTER_X, + CLIFF_CELL_CENTER_Y, CLIFF_GRID_SIZE, +}; +use crate::poison; + +/// The two fields the placement pass samples at the corner lattice. +/// +/// A trait rather than a pair of closures because `cliffiness` is evaluated at +/// every corner of every chunk the query touches and dominates the pass - the +/// TypeScript's own measurement says so - so it is the one call here worth +/// keeping static. +pub trait CliffFields { + /// `cliff_elevation`, which band a cliff sits on. + /// + /// **Not the same field as the tile generator's `elevation`.** + /// `multisample`'s offsets are in the CONSUMING program's grid units, and + /// the cliff generator walks a 4-tile lattice where every per-tile consumer + /// walks 1, so a 2x2 min-filter spans 4 tiles here and 1 there (#83). + fn cliff_elevation(&self, x: f64, y: f64) -> f64; + + /// `cliffiness`, the gate on whether a cell may carry a cliff at all. + /// + /// Its SHAPE is planet-specific: Nauvis's `cliffiness_nauvis` is a hard 0 + /// or 10, Vulcanus's `cliffiness_basic` is continuous on `[0.5, 1.5]`. The + /// comparison below is the same either way. + fn cliffiness(&self, x: f64, y: f64) -> f64; +} + +/// A tile-collision rejection: `true` for a tile a cliff cannot occupy. +/// +/// `tryToAddCliff` looks up the cell's orientation, takes that orientation's +/// `collision_bounding_box`, and scans the inclusive tile rectangle against the +/// tile mask grid. Which tiles collide is planet-specific; the rule is not - a +/// tile collides when its `CollisionMask` shares a layer with the cliff's, and +/// the cliff mask holds `water_tile`. +pub trait TileCollision { + fn collides(&self, x: i64, y: i64) -> bool; +} + +/// An additional per-cell rejection, called with the cell's crossing code and +/// its centre. Return `true` to drop the cell. +/// +/// **Deliberately opaque.** This module is planet-agnostic, and the one rule +/// that uses this hook is planet-specific and only partly explained - Vulcanus's +/// ORE -> CLIFF suppression, whose mechanism is +/// `ResourceEntityPrototype::cliff_removal_probability` but whose geometry is +/// still an empirical fit. Keeping it a bare predicate is what stops such a rule +/// from leaking into the shared core. +pub trait CellRejection { + fn rejects(&self, code: u8, x: f64, y: f64) -> bool; +} + +/// The order the repair sweep tries edges in, as indices +/// `0 = L (west)`, `1 = T (north)`, `2 = R (east)`, `3 = B (south)`. +/// +/// The engine's order, and the default. **A permutation is not the game's +/// rule** - it exists only so a residual concentrated on one edge can be asked +/// whether it MOVES with the order. One that relocates is caused by the order; +/// one that does not is not. +pub const SWEEP_EDGE_ORDER_LTRB: [usize; 4] = [0, 1, 2, 3]; + +/// Band phase and spacing, plus the levers. +#[derive(Debug, Clone, Copy)] +pub struct CliffBands { + /// `cliff_elevation_0`: the elevation of the first cliff band. + pub elevation0: f64, + /// `cliff_elevation_interval`, already divided by the frequency lever. + pub interval: f64, + /// `cliff_smoothing`, 0..1. + /// + /// **A planet-level constant, and getting it wrong is invisible on Nauvis + /// and catastrophic on Vulcanus.** Nauvis, Fulgora and Gleba all set 0 + /// explicitly; Vulcanus sets nothing and takes the prototype default of 1. + /// With Nauvis's 0 the Vulcanus port reproduced 57-69% of real cliffs while + /// placing 1.1-1.6x too many (#18). + pub smoothing: f64, + /// Run `CellEdgeCliffCrossingArray::fixImpossibleCells`. The game always + /// does - `crossingsForChunk` calls it unconditionally at its tail - so + /// `false` is a measurement lever, not a configuration. + pub fix_impossible_cells: bool, + /// When true, [`CliffPlacement::placed_cells`] returns nothing. + pub disabled: bool, + /// Apply the rejections by zeroing the rejected cell's four edge registers + /// after the repair sweep, instead of filtering the emitted cell. A + /// neighbour sharing one of those edges therefore loses it too, and its + /// orientation changes. + /// + /// **The post-filter reading is refuted as a description of the output.** + /// It came from `tryToAddCliff` ignoring `wouldCollide`'s return value, and + /// `test/vulcanusCliffRejectionStage.spec.ts` measured it: under a + /// post-filter a surviving cell keeps an edge whose neighbour was rejected, + /// which the model predicts 1,662 times and the game shows 0 times. + pub reject_at_crossing_stage: bool, + /// Re-run the rejection pass until it finds nothing, so a cell whose + /// ORIENTATION changed because a neighbour's edges were zeroed is re-tested + /// with its new collision box. + /// + /// Measured and **rejected**: a bit-for-bit no-op at the shipping settings + /// and net harmful on the collapsed rule. Rejected cells do not turn + /// neighbours rejectable. + pub rejection_cascades: bool, + /// See [`SWEEP_EDGE_ORDER_LTRB`]. + pub sweep_edge_order: [usize; 4], +} + +impl Default for CliffBands { + /// The game's rules, with the two band numbers left at Nauvis's defaults. + /// Callers set `elevation0`, `interval` and `smoothing` for their planet. + fn default() -> Self { + Self { + elevation0: 10.0, + interval: 40.0, + smoothing: 0.0, + fix_impossible_cells: true, + disabled: false, + reject_at_crossing_stage: false, + rejection_cascades: false, + sweep_edge_order: SWEEP_EDGE_ORDER_LTRB, + } + } +} + +/// A placed cliff: the cell centre, plus the 8-bit edge-crossing `code`. +/// +/// The code is carried out rather than discarded because it is the only thing +/// that names the cliff's ORIENTATION, and therefore its collision box. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PlacedCliffCell { + pub x: f64, + pub y: f64, + pub code: u8, +} + +/// `CliffGenerator::crossesCliff(a, b, cliffinessAvg, elevation_0, interval)`: +/// does the edge between two corners cross a cliff band, and which way? +/// +/// Returns `0` (no crossing), `+1` (crossing up, low-to-high in `a`/`b` order) +/// or `-1` (crossing down). Both elevations must be non-negative and their max +/// must reach `elevation_0`; the cliffiness gate compares the AVERAGE of the two +/// corners' cliffiness against `0.5`, not against zero. +/// +/// **This is the op's poison hook**, and it has to be here rather than on a +/// numeric field: the output is a tri-state classification, and a one-ULP nudge +/// to an input changes which side of a comparison a value falls on essentially +/// never. Rotating the crossing is the smallest wrong answer this op can give - +/// the same argument [`poison::index_result`] carries for an argmax. +#[must_use] +pub fn crosses_cliff(a: f64, b: f64, cliff_avg: f64, e0: f64, interval: f64) -> i8 { + poison::crossing_result(crosses_cliff_inner(a, b, cliff_avg, e0, interval)) +} + +fn crosses_cliff_inner(a: f64, b: f64, cliff_avg: f64, e0: f64, interval: f64) -> i8 { + if a < 0.0 || b < 0.0 { + return 0; + } + let boundary = e0 + interval * ((crate::eval::math::max2(a, b) - e0) / interval).floor(); + if boundary < e0 { + return 0; + } + let d_a = a - boundary; + let d_b = b - boundary; + if cliff_avg > 0.5 { + if d_a < 0.0 && d_b > 0.0 { + return 1; + } + if d_a > 0.0 && d_b < 0.0 { + return -1; + } + } + 0 +} + +/// Packs the four edge crossings into the cell code the orientation table keys +/// on. `-1` encodes as `3`, which `& 3` on a two's-complement value gives for +/// free - the same identity the TypeScript relies on. +#[inline] +#[must_use] +pub fn cell_code(l: i8, r: i8, t: i8, b: i8) -> u8 { + let f = |v: i8| (i32::from(v)) & 3; + ((f(l) << 6) | (f(r) << 4) | (f(t) << 2) | f(b)) as u8 +} + +/// The knot pair and blend fraction `cliff_smoothing` interpolates a corner +/// between, for one axis. +/// +/// `crossingsForChunk` walks each chunk's own 9x9 corner block and, per axis, +/// takes `lo = i & ~3`, `hi = min(lo + 4, CHUNK_CELLS - 1)`, `t = (i & 3) / +/// (hi - lo)` on the IN-CHUNK index. So the knots land at in-chunk indices 0, 4 +/// and 7 - the second span is three corners wide, not four, because `hi` clamps +/// to 7 rather than to the block edge at 8. +/// +/// That asymmetry is not a misreading. It is what makes smoothing "inaccurate" +/// in the prototype docs' own words, and it anchors the smoothed field to the +/// chunk grid, so it is deliberately discontinuous every 32 tiles. Index 8 +/// falls out with `t = 0` on itself, which is the same world point as the next +/// chunk's index 0 - also a knot - so the two chunks agree there and this +/// reduces to a function of the GLOBAL corner index with no chunk loop. +#[must_use] +pub fn smoothing_knots(index: i64) -> (i64, i64, f64) { + let n = CHUNK_CELLS as i64; + let i = index.rem_euclid(n); + let base = index - i; + let lo = i & !3; + let hi = (lo + 4).min(n - 1); + #[allow(clippy::cast_precision_loss)] + let t = ((i & 3) as f64) / ((hi - lo) as f64); + (base + lo, base + hi, t) +} + +/// `CellEdgeCliffCrossingArray::fixImpossibleCells`, the pass that runs at the +/// tail of `crossingsForChunk`. +/// +/// It is a **single forward sweep** over one chunk's 8x8 cells (row-major, `cy` +/// outer), not a fixpoint: clearing an edge changes the two cells that share it, +/// and cells already visited are never revisited. Porting it as a +/// relax-until-stable loop would be a different algorithm. +/// +/// Per cell it clears edges until the code is one the orientation table accepts, +/// choosing the first **clearable** edge in `order`. An edge is clearable only +/// if it is not on the chunk's outer boundary, so the chunk cannot disturb its +/// neighbours - which is what keeps the pass chunk-local and lets it run with no +/// chunk-ordering dependence. +/// +/// The legality predicate needs no new table: the accepted set is exactly +/// [`is_cliff_placed`] plus code `0`, which is what extracting both of the +/// disassembly's jump tables and comparing against the placing codes showed. +/// +/// **The `bool` parameter is a retry flag the function sets on ITSELF**, not a +/// caller-supplied mode - which is how it was read until 2026-07-30. +/// `crossingsForChunk` passes `false`, and an earlier note concluded from that +/// alone that the corner step never runs. It does: when the sweep reaches a cell +/// it cannot fix it turns the flag on and **restarts the whole pass**, which +/// this time begins by zeroing the eight outer edges of the chunk's four corner +/// cells. A second failure logs and abandons the rest of the chunk. Note the +/// restart re-sweeps the arrays **as already mutated** by the abandoned pass - +/// it is not a fresh start from the raw crossings. +pub fn fix_impossible_cells_sweep( + v: &mut [i8], + h: &mut [i8], + w: usize, + hh: usize, + order: [usize; 4], +) { + let v_index = |cx: usize, cy: usize| cy * (w + 1) + cx; + let h_index = |cx: usize, cy: usize| cy * w + cx; + // The sweep's own poison hook - see `poison::sweep_order` for why the choice + // of edge, and not the crossings feeding it, is what this op can get wrong. + let order = poison::sweep_order(order); + + let mut retry = 0usize; + loop { + if retry > 0 { + // The eight edges: the two outer edges of each corner cell. Zeroing + // these is what can make an otherwise unfixable corner cell legal, + // since its only remaining crossings were the ones the sweep is + // forbidden to clear. + v[v_index(0, 0)] = 0; + h[h_index(0, 0)] = 0; + v[v_index(w, 0)] = 0; + h[h_index(w - 1, 0)] = 0; + v[v_index(0, hh - 1)] = 0; + h[h_index(0, hh)] = 0; + v[v_index(w, hh - 1)] = 0; + h[h_index(w - 1, hh)] = 0; + } + + let mut stuck = false; + 'sweep: for cy in 0..hh { + for cx in 0..w { + let li = v_index(cx, cy); + let ri = v_index(cx + 1, cy); + let ti = h_index(cx, cy); + let bi = h_index(cx, cy + 1); + + loop { + let code = cell_code(v[li], v[ri], h[ti], h[bi]); + // The engine first counts non-zero edges and only consults + // the table when the count is below 3. That is pure + // optimisation: every placing code has one or two + // crossings, so 3 or 4 can never be legal. + if code == 0 || is_cliff_placed(code) { + break; + } + let mut cleared = false; + for e in order { + match e { + 0 if v[li] != 0 && cx != 0 => v[li] = 0, + 1 if h[ti] != 0 && cy != 0 => h[ti] = 0, + 2 if v[ri] != 0 && cx < w - 1 => v[ri] = 0, + 3 if h[bi] != 0 && cy < hh - 1 => h[bi] = 0, + _ => continue, + } + cleared = true; + break; + } + if !cleared { + stuck = true; + break 'sweep; + } + } + } + } + + // Not stuck -> the pass completed. Stuck on the retry -> the engine logs + // and abandons the chunk, leaving the arrays as they are. + if !stuck || retry > 0 { + return; + } + retry += 1; + } +} + +/// The placed-cliff-cell query for one cliff configuration. +/// +/// Built from the planet-agnostic geometry plus whatever the caller supplies: +/// the two fields, the two band numbers, and up to two rejections. +pub struct CliffPlacement<'a, F: CliffFields> { + fields: &'a F, + bands: CliffBands, + tile_collides: Option<&'a dyn TileCollision>, + cell_rejects: Option<&'a dyn CellRejection>, +} + +impl<'a, F: CliffFields> CliffPlacement<'a, F> { + #[must_use] + pub fn new(fields: &'a F, bands: CliffBands) -> Self { + Self { + fields, + bands, + tile_collides: None, + cell_rejects: None, + } + } + + #[must_use] + pub fn with_tile_collision(mut self, t: &'a dyn TileCollision) -> Self { + self.tile_collides = Some(t); + self + } + + #[must_use] + pub fn with_cell_rejection(mut self, c: &'a dyn CellRejection) -> Self { + self.cell_rejects = Some(c); + self + } + + /// `tryToAddCliff`'s rejection as a predicate on an already-placed cell: + /// scan the orientation's collision box and drop the cell if any tile in it + /// collides. With no [`TileCollision`] supplied this is a constant `false` + /// and costs nothing - the box is never even resolved. + /// + /// Nothing narrows the box: `wouldCollide` floors the stored rectangle with + /// `(box + position) >> 8` and scans the inclusive tile rect, with the box's + /// own `1/8` orientation tag discarded. + fn rejected(&self, code: u8, x: f64, y: f64) -> bool { + let Some(t) = self.tile_collides else { + return false; + }; + // `None` only for a code that places nothing, which cannot reach here. + let Some(b) = cliff_collision_tile_box(code, x, y) else { + return false; + }; + for tx in b.left..=b.right { + for ty in b.top..=b.bottom { + if t.collides(tx, ty) { + return true; + } + } + } + false + } + + fn cell_rejected(&self, code: u8, x: f64, y: f64) -> bool { + self.cell_rejects.is_some_and(|c| c.rejects(code, x, y)) + } + + /// Enumerate the 4-tile placement grid over a world box and return the + /// centre of every cell whose crossing code places a cliff. + /// + /// The chunk-structured path is the game's: each chunk builds its own edge + /// arrays and runs the repair sweep in isolation, **including recomputing + /// the edges it shares with its neighbours**, which both chunks own a + /// private copy of. That is what makes the result independent of the query + /// box, so worker tiling stays byte-identical. + #[must_use] + pub fn placed_cells(&self, x0: f64, y0: f64, x1: f64, y1: f64) -> Vec { + if self.bands.disabled { + return Vec::new(); + } + + // The INCLUSIVE cell-index range whose centres land in the query box. + // Cell `cx` sits at `cx * G + CX`, and the emit filter keeps it when + // that is in `[x0, x1)`, so the range is `ceil((x0 - CX) / G)` through + // `ceil((x1 - CX) / G) - 1`. + // + // These used to be `floor`/`ceil` in the TypeScript, which overshot by + // one cell at each end. Every extra cell was discarded by the emit + // filter, so the OUTPUT was correct - but the chunk loop rounds this + // range out to whole chunks, and one extra cell pulls in a whole extra + // 8-cell chunk on each side. That is a fixed +2 chunks per axis per + // call, measured at 1.83x the cliffiness samples when tiled. + let ceil_cell = |v: f64, centre: f64| ((v - centre) / CLIFF_GRID_SIZE).ceil() as i64; + let cx_min = ceil_cell(x0, CLIFF_CELL_CENTER_X); + let cx_max = ceil_cell(x1, CLIFF_CELL_CENTER_X) - 1; + let cy_min = ceil_cell(y0, CLIFF_CELL_CENTER_Y); + let cy_max = ceil_cell(y1, CLIFF_CELL_CENTER_Y) - 1; + if cx_max < cx_min || cy_max < cy_min { + return Vec::new(); + } + + let n = CHUNK_CELLS; + let (chunk_x0, chunk_x1, chunk_y0, chunk_y1) = if self.bands.fix_impossible_cells { + ( + cx_min.div_euclid(n as i64), + cx_max.div_euclid(n as i64), + cy_min.div_euclid(n as i64), + cy_max.div_euclid(n as i64), + ) + } else { + // The unswept path walks the cell range directly, so its corner + // rectangle is the cells' own, one wider on each high side. + (cx_min, cx_max, cy_min, cy_max) + }; + + // The corner indices actually sampled. The swept path walks each + // chunk's own 9x9 block, so it reaches one corner past the last chunk. + let (ci0, ci1, cj0, cj1) = if self.bands.fix_impossible_cells { + ( + chunk_x0 * n as i64, + (chunk_x1 + 1) * n as i64, + chunk_y0 * n as i64, + (chunk_y1 + 1) * n as i64, + ) + } else { + (cx_min, cx_max + 1, cy_min, cy_max + 1) + }; + let mut corners = CornerCache::new( + self.fields, + self.bands.smoothing, + CornerRect::covering(ci0, ci1, cj0, cj1), + ); + + let e0 = self.bands.elevation0; + let interval = self.bands.interval; + + if !self.bands.fix_impossible_cells { + let mut result = Vec::new(); + for cy in cy_min..=cy_max { + for cx in cx_min..=cx_max { + let a = corners.get(cx, cy); + let b = corners.get(cx, cy + 1); + let c = corners.get(cx + 1, cy); + let d = corners.get(cx + 1, cy + 1); + let l = cross(a, b, e0, interval); + let r = cross(c, d, e0, interval); + let t = cross(a, c, e0, interval); + let bo = cross(b, d, e0, interval); + let code = cell_code(l, r, t, bo); + if !is_cliff_placed(code) { + continue; + } + #[allow(clippy::cast_precision_loss)] + let x = cx as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; + #[allow(clippy::cast_precision_loss)] + let y = cy as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; + if x < x0 || x >= x1 || y < y0 || y >= y1 { + continue; + } + if self.rejected(code, x, y) || self.cell_rejected(code, x, y) { + continue; + } + result.push(PlacedCliffCell { x, y, code }); + } + } + return result; + } + + let mut result = Vec::new(); + let mut v = vec![0i8; (n + 1) * n]; + let mut h = vec![0i8; n * (n + 1)]; + + for ch_y in chunk_y0..=chunk_y1 { + for ch_x in chunk_x0..=chunk_x1 { + let base_x = ch_x * n as i64; + let base_y = ch_y * n as i64; + + for cy in 0..n { + for cx in 0..=n { + let a = corners.get(base_x + cx as i64, base_y + cy as i64); + let b = corners.get(base_x + cx as i64, base_y + cy as i64 + 1); + v[cy * (n + 1) + cx] = cross(a, b, e0, interval); + } + } + for cy in 0..=n { + for cx in 0..n { + let a = corners.get(base_x + cx as i64, base_y + cy as i64); + let b = corners.get(base_x + cx as i64 + 1, base_y + cy as i64); + h[cy * n + cx] = cross(a, b, e0, interval); + } + } + + fix_impossible_cells_sweep(&mut v, &mut h, n, n, self.bands.sweep_edge_order); + + if self.bands.reject_at_crossing_stage { + self.apply_crossing_stage_rejections(&mut v, &mut h, n, base_x, base_y); + } + + for cy in 0..n { + for cx in 0..n { + let code = cell_code( + v[cy * (n + 1) + cx], + v[cy * (n + 1) + cx + 1], + h[cy * n + cx], + h[(cy + 1) * n + cx], + ); + if !is_cliff_placed(code) { + continue; + } + #[allow(clippy::cast_precision_loss)] + let x = (base_x + cx as i64) as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; + #[allow(clippy::cast_precision_loss)] + let y = (base_y + cy as i64) as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; + // Bounds-test BEFORE the collision test: the rejection + // resolves tiles and is the expensive half, and a chunk + // always overhangs the query box. + if x < x0 || x >= x1 || y < y0 || y >= y1 { + continue; + } + if !self.bands.reject_at_crossing_stage + && (self.rejected(code, x, y) || self.cell_rejected(code, x, y)) + { + continue; + } + result.push(PlacedCliffCell { x, y, code }); + } + } + } + } + result + } + + /// Collect first, then clear: a cell's rejection is decided from the code + /// the repair left, not from a code a previous cell's clearing has already + /// eaten into. + /// + /// The zeroing runs over the whole chunk, including cells outside the query + /// box, which is what keeps worker tiling byte-identical. + fn apply_crossing_stage_rejections( + &self, + v: &mut [i8], + h: &mut [i8], + n: usize, + base_x: i64, + base_y: i64, + ) { + let mut pass = 0usize; + loop { + let mut kill: Vec<(usize, usize)> = Vec::new(); + for cy in 0..n { + for cx in 0..n { + let code = cell_code( + v[cy * (n + 1) + cx], + v[cy * (n + 1) + cx + 1], + h[cy * n + cx], + h[(cy + 1) * n + cx], + ); + if !is_cliff_placed(code) { + continue; + } + #[allow(clippy::cast_precision_loss)] + let x = (base_x + cx as i64) as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_X; + #[allow(clippy::cast_precision_loss)] + let y = (base_y + cy as i64) as f64 * CLIFF_GRID_SIZE + CLIFF_CELL_CENTER_Y; + if self.rejected(code, x, y) || self.cell_rejected(code, x, y) { + kill.push((cx, cy)); + } + } + } + for (cx, cy) in &kill { + v[cy * (n + 1) + cx] = 0; + v[cy * (n + 1) + cx + 1] = 0; + h[cy * n + cx] = 0; + h[(cy + 1) * n + cx] = 0; + } + pass += 1; + // One pass is the shipping model; the cascade stops when a pass + // finds nothing, and `pass` is bounded by the cell count anyway. + if !self.bands.rejection_cascades || kill.is_empty() || pass > 64 { + return; + } + } + } +} + +/// One corner's two field samples. +#[derive(Clone, Copy)] +struct CornerSample { + elev: f64, + cliff: f64, +} + +fn cross(p: CornerSample, q: CornerSample, e0: f64, interval: f64) -> i8 { + crosses_cliff(p.elev, q.elev, (p.cliff + q.cliff) / 2.0, e0, interval) +} + +/// The inclusive corner-index rectangle one `placed_cells` call touches, +/// widened to cover the smoothing knots those corners read. +#[derive(Clone, Copy)] +struct CornerRect { + i0: i64, + j0: i64, + i1: i64, + j1: i64, +} + +impl CornerRect { + /// Widen a sampled corner range to every index [`smoothing_knots`] can + /// return for it. + /// + /// The low side rounds down to a chunk boundary, because `base` is the + /// largest multiple of `CHUNK_CELLS` at or below the index. The high side + /// gains `CHUNK_CELLS - 1`, because `hi` is at most `base + 7`. + /// + /// **The high pad is load-bearing at `t = 0`, which is the case that looks + /// safe to drop.** A corner sitting exactly on a chunk boundary is its own + /// `lo` knot with `t = 0`, so its `hi` knot is multiplied by zero and + /// contributes nothing to the result - but it is still READ, exactly as the + /// TypeScript reads it. Skipping the read instead of padding for it would + /// change the arithmetic from `a + 0.0 * b` to `a`, which agree only while + /// `b` is finite. `cliff_elevation` is finite today; that is a property of + /// the field, not of this cache, and it is not this module's to assume. + fn covering(i0: i64, i1: i64, j0: i64, j1: i64) -> Self { + let n = CHUNK_CELLS as i64; + Self { + i0: i0.div_euclid(n) * n, + j0: j0.div_euclid(n) * n, + i1: i1 + n - 1, + j1: j1 + n - 1, + } + } +} + +/// The two per-corner caches, as dense grids over [`CornerRect`]. +/// +/// The TypeScript keys these by a `"i,j"` string in a `Map`; the query's corner +/// range is a known dense rectangle, so an index into a `Vec` is both cheaper +/// and simpler. Nothing about the OUTPUT depends on the choice - both fields are +/// pure functions of position - but the smoothing knots are read repeatedly and +/// `cliffiness` dominates the pass, so the cache is not optional. +struct CornerCache<'a, F: CliffFields> { + fields: &'a F, + smoothing: f64, + rect: CornerRect, + w: usize, + raw: Vec, + raw_seen: Vec, + sample: Vec, + sample_seen: Vec, +} + +impl<'a, F: CliffFields> CornerCache<'a, F> { + fn new(fields: &'a F, smoothing: f64, rect: CornerRect) -> Self { + let w = (rect.i1 - rect.i0 + 1) as usize; + let hh = (rect.j1 - rect.j0 + 1) as usize; + let cells = w * hh; + Self { + fields, + smoothing, + rect, + w, + raw: vec![0.0; cells], + raw_seen: vec![false; cells], + sample: vec![ + CornerSample { + elev: 0.0, + cliff: 0.0 + }; + cells + ], + sample_seen: vec![false; cells], + } + } + + fn index(&self, i: i64, j: i64) -> usize { + debug_assert!(i >= self.rect.i0 && i <= self.rect.i1); + debug_assert!(j >= self.rect.j0 && j <= self.rect.j1); + (j - self.rect.j0) as usize * self.w + (i - self.rect.i0) as usize + } + + /// The unsmoothed `cliff_elevation` at a corner. Sampled at the BARE lattice + /// `(i*4, j*4)` - the prototype's `grid_offset` is a CENTRE offset and + /// `crossingsForChunk` never reads it. + fn raw_elevation(&mut self, i: i64, j: i64) -> f64 { + let k = self.index(i, j); + if !self.raw_seen[k] { + #[allow(clippy::cast_precision_loss)] + let value = self + .fields + .cliff_elevation(i as f64 * CLIFF_GRID_SIZE, j as f64 * CLIFF_GRID_SIZE); + self.raw[k] = value; + self.raw_seen[k] = true; + } + self.raw[k] + } + + /// `cliff_smoothing` applied to the cliff ELEVATION register only - + /// cliffiness is read unsmoothed, because `crossingsForChunk` smooths the + /// register at `[settings+0x1e0]` and then reads `[+0x1e4]` raw. + /// + /// At `s = 1` the `E(i,j)` term vanishes exactly, so the raw sample is + /// skipped and only the knot corners are ever evaluated. That makes + /// smoothing slightly cheaper than no smoothing rather than dearer. + fn elevation_at(&mut self, i: i64, j: i64) -> f64 { + if self.smoothing == 0.0 { + return self.raw_elevation(i, j); + } + let (ilo, ihi, tx) = smoothing_knots(i); + let (jlo, jhi, ty) = smoothing_knots(j); + let bilinear = (1.0 - tx) * (1.0 - ty) * self.raw_elevation(ilo, jlo) + + tx * (1.0 - ty) * self.raw_elevation(ihi, jlo) + + (1.0 - tx) * ty * self.raw_elevation(ilo, jhi) + + tx * ty * self.raw_elevation(ihi, jhi); + if self.smoothing == 1.0 { + return bilinear; + } + (1.0 - self.smoothing) * self.raw_elevation(i, j) + self.smoothing * bilinear + } + + fn get(&mut self, i: i64, j: i64) -> CornerSample { + let k = self.index(i, j); + if !self.sample_seen[k] { + let elev = self.elevation_at(i, j); + #[allow(clippy::cast_precision_loss)] + let cliff = self + .fields + .cliffiness(i as f64 * CLIFF_GRID_SIZE, j as f64 * CLIFF_GRID_SIZE); + self.sample[k] = CornerSample { elev, cliff }; + self.sample_seen[k] = true; + } + self.sample[k] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The sweep clears the FIRST CLEARABLE edge in `L, T, R, B`, and the choice + /// is observable: an interior cell with all four edges crossing is illegal, + /// and which two survive depends entirely on the order. + /// + /// Set up so nothing else in the chunk moves - every other cell's code is + /// already legal, checked by the assertion on the untouched edges - so this + /// isolates the choice rather than the sweep as a whole. + /// + /// **This is the test that sees [`poison::sweep_order`]**, and nothing else + /// does: the end-to-end cliff fixture is red under poison from the crossing + /// hook alone. + #[test] + fn the_sweep_clears_the_first_clearable_edge_in_l_t_r_b_order() { + let n = CHUNK_CELLS; + let build = || { + let mut v = vec![0i8; (n + 1) * n]; + let mut h = vec![0i8; n * (n + 1)]; + // The four edges of interior cell (1, 1), all crossing upward. + v[(n + 1) + 1] = 1; // L + v[(n + 1) + 2] = 1; // R + h[n + 1] = 1; // T + h[2 * n + 1] = 1; // B + (v, h) + }; + let code_at = |v: &[i8], h: &[i8], cx: usize, cy: usize| { + cell_code( + v[cy * (n + 1) + cx], + v[cy * (n + 1) + cx + 1], + h[cy * n + cx], + h[(cy + 1) * n + cx], + ) + }; + + let (mut v, mut h) = build(); + assert_eq!(code_at(&v, &h, 1, 1), 85, "all four edges crossing"); + assert!(!is_cliff_placed(85), "85 is not a placing code"); + + fix_impossible_cells_sweep(&mut v, &mut h, n, n, SWEEP_EDGE_ORDER_LTRB); + // L then T cleared, leaving R and B: code 17, `east-to-south`. + assert_eq!( + code_at(&v, &h, 1, 1), + 17, + "L, T, R, B clears west then north" + ); + + // The same cell under a rotated order keeps its west edge and loses the + // other three - a different, also legal, code. Without this arm the + // assertion above would pass for any order that happens to terminate. + let (mut v2, mut h2) = build(); + fix_impossible_cells_sweep(&mut v2, &mut h2, n, n, [1, 2, 3, 0]); + assert_eq!( + code_at(&v2, &h2, 1, 1), + 64, + "T, R, B, L clears north, east, south" + ); + } + + /// An edge on the chunk's outer boundary is NOT clearable, which is what + /// keeps the pass chunk-local and lets it run with no chunk-ordering + /// dependence. A cell in the corner is therefore denied its first choice. + #[test] + fn the_sweep_will_not_clear_an_edge_on_the_chunks_boundary() { + let n = CHUNK_CELLS; + let mut v = vec![0i8; (n + 1) * n]; + let mut h = vec![0i8; n * (n + 1)]; + // Cell (0, 1): its LEFT edge is on the chunk boundary, so `L` is denied + // and the sweep must fall through to `T`. + v[n + 1] = 1; // L, on the boundary + v[(n + 1) + 1] = 1; // R + h[n] = 1; // T + h[2 * n] = 1; // B + fix_impossible_cells_sweep(&mut v, &mut h, n, n, SWEEP_EDGE_ORDER_LTRB); + assert_eq!(v[n + 1], 1, "the boundary edge survives"); + assert_eq!(h[n], 0, "north was cleared instead"); + } + + /// `crosses_cliff` needs both corners non-negative and their max at or above + /// `elevation_0`, and the cliffiness gate compares the AVERAGE to 0.5 rather + /// than to zero. Each clause is planted so it can fail on its own. + #[test] + fn a_crossing_needs_a_band_a_sign_and_the_cliffiness_gate() { + let (e0, interval) = (70.0, 120.0); + // A rising edge across the band at 70. + assert_eq!(crosses_cliff(69.0, 71.0, 1.0, e0, interval), 1); + assert_eq!(crosses_cliff(71.0, 69.0, 1.0, e0, interval), -1); + // The gate is on the AVERAGE, and 0.5 exactly does not open it. + assert_eq!(crosses_cliff(69.0, 71.0, 0.5, e0, interval), 0); + assert_eq!(crosses_cliff(69.0, 71.0, 0.500_001, e0, interval), 1); + // A negative corner is never a crossing, whatever the other one is. + assert_eq!(crosses_cliff(-1.0, 200.0, 1.5, e0, interval), 0); + // Below the first band there is nothing to cross. + assert_eq!(crosses_cliff(10.0, 60.0, 1.5, e0, interval), 0); + // Two corners inside the same band do not cross it. + assert_eq!(crosses_cliff(80.0, 100.0, 1.5, e0, interval), 0); + } + + /// The knots land at in-chunk indices 0, 4 and 7 - the second span is THREE + /// corners wide, not four, because `hi` clamps to `CHUNK_CELLS - 1` rather + /// than to the block edge at 8. That asymmetry is what the prototype docs + /// mean by smoothing making placement "inaccurate". + #[test] + fn the_smoothing_knots_are_zero_four_and_seven_with_an_uneven_second_span() { + let spans: Vec<(i64, i64, f64)> = (0..9).map(smoothing_knots).collect(); + assert_eq!(spans[0], (0, 4, 0.0)); + assert_eq!(spans[1], (0, 4, 0.25)); + assert_eq!(spans[4], (4, 7, 0.0)); + // Three wide, so the step is a third rather than a quarter. + assert_eq!(spans[5], (4, 7, 1.0 / 3.0)); + assert_eq!(spans[7], (4, 7, 1.0)); + // Index 8 is the next chunk's index 0 - a knot on itself, `t = 0`, which + // is what makes the smoothed field agree across the chunk seam. + assert_eq!(spans[8], (8, 12, 0.0)); + // ...and it holds on the negative side, where a truncating remainder + // would fold the knots onto the wrong chunk. + assert_eq!(smoothing_knots(-1), (-4, -1, 1.0)); + assert_eq!(smoothing_knots(-8), (-8, -4, 0.0)); + } +} diff --git a/crates/fmw-noise/src/cliffs/vulcanus_fields.rs b/crates/fmw-noise/src/cliffs/vulcanus_fields.rs new file mode 100644 index 00000000..fa790fb2 --- /dev/null +++ b/crates/fmw-noise/src/cliffs/vulcanus_fields.rs @@ -0,0 +1,257 @@ +//! The two cliff fields for Vulcanus, ported from +//! `src/noise/cliffs/vulcanusCliffFields.ts`. +//! +//! Vulcanus does not reuse Nauvis's cliff expressions. `planet-map-gen.lua:13` +//! overrides both properties: +//! +//! ```lua +//! cliffiness = "cliffiness_basic", +//! cliff_elevation = "cliff_elevation_from_elevation", -- = "elevation" +//! cliff_settings = { name = "cliff-vulcanus", +//! cliff_elevation_interval = 120, +//! cliff_elevation_0 = 70 } +//! ``` +//! +//! so this port is much smaller than the Nauvis one will be: `cliff_elevation` +//! is the planet's own elevation, and `cliffiness_basic` is a single clamp over +//! a 2-octave `quick_multioctave_noise`. None of the Nauvis hills / ringbreak / +//! billows machinery is involved. +//! +//! **There are no Vulcanus cliff sliders.** +//! `space-age/prototypes/autoplace-controls.lua` defines `gleba_cliff` and +//! `fulgora_cliff` but no Vulcanus equivalent, and the planet's +//! `autoplace_controls` list has no cliff entry - so frequency and continuity +//! are fixed at 1 and `cliff_richness` is fixed at 1. The interval and +//! elevation-0 below are planet constants for the same reason: they come from +//! the planet definition, not from the user's preset, which describes a Nauvis +//! surface. + +use crate::cliffs::placement::{CliffFields, TileCollision}; +use crate::eval::math::{log2, max2, min2}; +use crate::expressions::vulcanus_stack::VulcanusStack; +use crate::poison; +use crate::quick_multioctave_noise::{ + octave_terms, sum_octaves, QuickMultioctaveParams, QuickOctaves, +}; +use crate::tiles::vulcanus_catalog::VulcanusTile; + +/// `cliff_elevation_0` from `planet_map_gen.vulcanus()`'s `cliff_settings`. +pub const VULCANUS_CLIFF_ELEVATION_0: f64 = 70.0; + +/// `cliff_elevation_interval` from the same `cliff_settings`. +pub const VULCANUS_CLIFF_ELEVATION_INTERVAL: f64 = 120.0; + +/// `cliff_smoothing` on Vulcanus - **1, and it is load-bearing.** +/// +/// Vulcanus's `cliff_settings` sets only `name`, `cliff_elevation_interval` and +/// `cliff_elevation_0`, so smoothing takes the `CliffPlacementSettings` +/// prototype default of `1` (full smoothing), not 0. Vulcanus is the odd planet +/// out: Nauvis, Fulgora and Gleba all set `cliff_smoothing = 0` explicitly, +/// Fulgora with the comment "This is critical for correct cliff placement." +/// +/// The prototype docs say smoothing "makes cliffs straighter on rough elevation +/// but makes placement inaccurate", and that is exactly what it did: left at +/// Nauvis's 0, Vulcanus reproduced 57-69% of real cliffs while placing 1.1-1.6x +/// too many (#18). +pub const VULCANUS_CLIFF_SMOOTHING: f64 = 1.0; + +/// `cliff_richness` on Vulcanus: `getModifiedRichness(richness, size)` with no +/// cliff autoplace control to move either lever, so it is pinned at 1 and the +/// `0.5 * log2(cliff_richness)` term of `cliffiness_basic` vanishes. +/// +/// Kept as a named constant rather than folded away so the expression still +/// reads like the Lua it ports - and so the one place a `log2` enters this +/// layer stays visible. See [`CliffinessBasic::new`] for why that matters. +pub const VULCANUS_CLIFF_RICHNESS: f64 = 1.0; + +/// `seed1` of `cliffiness_basic`'s `quick_multioctave_noise` call. +pub const CLIFFINESS_BASIC_SEED1: u32 = 123; + +/// `cliffiness_basic` (`core/prototypes/noise-programs.lua:310`): +/// +/// ```text +/// clamp(0.5 * log2(cliff_richness) + +/// quick_multioctave_noise{x = x, y = y, seed0 = map_seed, seed1 = 123, +/// input_scale = 1/32, output_scale = 1, octaves = 2, +/// octave_output_scale_multiplier = 1, +/// octave_input_scale_multiplier = 1/3}, +/// 0, 1) + 0.5 +/// ``` +/// +/// Range `[0.5, 1.5]`. That matters for the placement gate: `crosses_cliff` +/// compares the AVERAGE of two corners' cliffiness against `0.5`, so on Vulcanus +/// an edge is cliffy whenever the clamp is above zero at either corner - a +/// continuous field, unlike Nauvis's `cliffiness_nauvis`, which is a hard 0-or-10 +/// gate. Same comparison, different shape of input. +pub struct CliffinessBasic { + richness_term: f64, + octaves: QuickOctaves, +} + +impl CliffinessBasic { + /// Prepare the field for one seed. + /// + /// **The octave terms are hoisted, and that is not an optimisation to take + /// or leave.** `octave_terms` runs a PRNG over three 256-byte permutation + /// tables per octave; rebuilding them per call is what cost + /// `multioctave_noise` 20x before it was measured. Cliffiness is evaluated + /// at every corner of every chunk the query touches and dominates the + /// placement pass, so it is the worst place in this layer to rebuild. + /// + /// **The `log2` is evaluated here, once, and only because Vulcanus pins its + /// argument at 1.** A transcendental inside the module is the #270 hazard: + /// the libm `wasm32-unknown-unknown` links is not V8's, and only a tier-2 + /// sweep can see the difference. `log2(1)` is exactly 0 on any conforming + /// libm, so there is nothing to disagree about - and + /// [`tests::the_richness_term_is_exactly_zero_at_vulcanuss_pinned_richness`] + /// pins that rather than leaving it as an argument. A planet that ever moves + /// this lever must send the term across the ABI the way the bearings' trig + /// is sent, not compute it here. + #[must_use] + pub fn new(seed0: u32, cliff_richness: f64) -> Self { + Self { + richness_term: 0.5 * log2(cliff_richness), + octaves: octave_terms(&QuickMultioctaveParams { + seed0, + seed1: CLIFFINESS_BASIC_SEED1, + octaves: 2, + input_scale: 1.0 / 32.0, + output_scale: 1.0, + octave_output_scale_multiplier: 1.0, + octave_input_scale_multiplier: 1.0 / 3.0, + offset_x: 0.0, + }), + } + } + + /// Vulcanus's own richness, which is the only one that exists today. + #[must_use] + pub fn for_vulcanus(seed0: u32) -> Self { + Self::new(seed0, VULCANUS_CLIFF_RICHNESS) + } + + /// Evaluate the field. + /// + /// `min2`/`max2` rather than `f64::min`/`f64::max`, in the TypeScript's own + /// argument order: the two disagree on NaN and on signed zero, and only an + /// order-sensitive raw-bits fold can see it. + #[must_use] + pub fn eval(&self, x: f64, y: f64) -> f64 { + let n = f64::from(sum_octaves(x, y, &self.octaves)); + poison::f64_result(min2(1.0, max2(0.0, self.richness_term + n)) + 0.5) + } +} + +/// Both fields the placement pass needs, for one Vulcanus stack. +/// +/// `cliff_elevation` is `vulcanus_elevation` itself (`max(-500, vulcanus_elev)`), +/// which is what `cliff_elevation_from_elevation` resolves to once the planet +/// has routed the `elevation` property at `vulcanus_elevation`. +pub struct VulcanusCliffFields<'a, 'b> { + stack: &'a VulcanusStack<'b>, + cliffiness: CliffinessBasic, +} + +impl<'a, 'b> VulcanusCliffFields<'a, 'b> { + #[must_use] + pub fn new(stack: &'a VulcanusStack<'b>, seed0: u32) -> Self { + Self { + stack, + cliffiness: CliffinessBasic::for_vulcanus(seed0), + } + } +} + +impl CliffFields for VulcanusCliffFields<'_, '_> { + /// **`cliff_elevation`, not `elevation`** - the cliff generator and the tile + /// generator read genuinely different fields. + /// + /// `multisample`'s offsets are in the consuming noise program's GRID UNITS, + /// and the cliff generator walks the 4-tile corner lattice while every + /// per-tile consumer walks 1, so `vulcanus_basalt_lakes_multisample`'s 2x2 + /// min-filter spans 4 tiles here and 1 there. Using the per-tile field made + /// the cliff elevation too rough and was issue #18's root cause (#83). + fn cliff_elevation(&self, x: f64, y: f64) -> f64 { + self.stack.cliff_elevation(x, y) + } + + fn cliffiness(&self, x: f64, y: f64) -> f64 { + self.cliffiness.eval(x, y) + } +} + +/// The Vulcanus tiles whose `CollisionMask` shares a layer with the cliff's, so +/// a cliff whose collision box touches one is never placed. +/// +/// `tile_collision_masks.lava()` sets `water_tile = true` and the cliff mask +/// holds `water_tile`; no other Vulcanus tile does. Notably +/// `volcanic-jagged-ground` - the tile the ore patches paint, which the Lua +/// itself labels "CLIFF TILE" - is `tile_collision_masks.ground()`, which the +/// cliff mask does not touch, so ore does NOT exclude cliffs through this rule. +/// That distinction is why the earlier ore-separation work correctly found no +/// exclusion here while the removal rule in +/// [`super::vulcanus_ore_rejection`] exists. +/// +/// **Measured rather than deduced.** Switching lava and lava-hot out of the +/// tile autoplace category and regenerating is what established the set, not a +/// reading of `tile_collision_masks`. +/// +/// It resolves the tile through the ported argmax rather than reading back a +/// rendered pixel, and that is load-bearing for tiled rendering: the collision +/// box reaches tiles outside the render window, so reading pixels would make +/// the answer depend on the window. +pub struct VulcanusLavaTiles<'a, 'b> { + stack: &'a VulcanusStack<'b>, +} + +impl<'a, 'b> VulcanusLavaTiles<'a, 'b> { + #[must_use] + pub fn new(stack: &'a VulcanusStack<'b>) -> Self { + Self { stack } + } +} + +impl TileCollision for VulcanusLavaTiles<'_, '_> { + fn collides(&self, x: i64, y: i64) -> bool { + #[allow(clippy::cast_precision_loss)] + let tile = self.stack.tile(x as f64, y as f64); + matches!(tile, VulcanusTile::Lava | VulcanusTile::LavaHot) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The one `log2` in this layer is inert at Vulcanus's pinned richness, so + /// no libm disagreement can reach the field. Asserted rather than argued. + #[test] + fn the_richness_term_is_exactly_zero_at_vulcanuss_pinned_richness() { + let f = CliffinessBasic::new(123_456, VULCANUS_CLIFF_RICHNESS); + assert_eq!(f.richness_term.to_bits(), 0.0f64.to_bits()); + } + + /// The clamp puts the field on `[0.5, 1.5]`, which is what makes the + /// placement gate's `> 0.5` comparison mean "the clamp cleared zero". + #[test] + fn the_field_stays_inside_the_half_to_one_and_a_half_band() { + let f = CliffinessBasic::for_vulcanus(123_456); + let mut saw_below_one = false; + let mut saw_above_one = false; + for i in 0..400 { + let x = f64::from(i) * 7.0 - 1400.0; + let v = f.eval(x, x * 0.5 - 300.0); + assert!( + (0.5..=1.5).contains(&v), + "cliffiness {v} at x={x} left the band" + ); + if v < 1.0 { + saw_below_one = true; + } else { + saw_above_one = true; + } + } + // Without both halves the bound above would pass on a constant field. + assert!(saw_below_one && saw_above_one, "the field did not vary"); + } +} diff --git a/crates/fmw-noise/src/cliffs/vulcanus_ore_rejection.rs b/crates/fmw-noise/src/cliffs/vulcanus_ore_rejection.rs new file mode 100644 index 00000000..3830b5c4 --- /dev/null +++ b/crates/fmw-noise/src/cliffs/vulcanus_ore_rejection.rs @@ -0,0 +1,278 @@ +//! The ORE -> CLIFF rejection: a resource entity's collision rectangle +//! overlapping a cliff cell's suppresses that cliff. +//! +//! Ported from `src/noise/cliffs/vulcanusOreRejection.ts`, whose module comment +//! carries the full evidence trail. What a reader of this port needs: +//! +//! ## The mechanism is named, the geometry is not +//! +//! **The mechanism is `ResourceEntityPrototype::cliff_removal_probability`**, +//! settled 2026-08-14 by a PROTOTYPE lever rather than a surface one. It +//! defaults to `1.0` and no shipped prototype overrides it, so it is invisible +//! from the data alone. Zeroing that one field - leaving all 945 resource +//! entities exactly where they are - is indistinguishable from switching the +//! resources off entirely, and the difference is exactly the ten cells the +//! effect is measured on. At 1.0 the removal is unconditional, so the box +//! overlap below is correct as written. +//! +//! **What is still NOT established is the geometry the engine removes with.** +//! The base `collision_box` is an empirical fit, and naming the field licenses +//! no tuning of it. Two things here are deliberately not the shape you might +//! expect: +//! +//! 1. **The cliff rectangle is the prototype's BASE `collision_box`, not the +//! per-orientation rotbb box** the lava rejection uses. Those are materially +//! different shapes - the base box is `+/-0.988 x +/-0.488`, orientation 4's +//! rotbb is `[-3.5,-3,4.5,3]`. The base box is the one the rule was measured +//! with. [`CliffRejectionBox`] keeps both so the choice stays a recorded +//! measurement rather than an assumption, which is the lesson #88/#90 paid +//! for: the best-scoring collision model was the wrong one, because it also +//! absorbed an unrelated defect. +//! 2. **It does not explain all 31 suppressed cells, and it is not tuned until +//! it does.** Box overlap accounts for 21 with zero false alarms in the 885 +//! cliffs the game kept; the crossing STAGE explains 2 more with no tuning at +//! all, because zeroing a rejected cell's edges leaves two neighbours with +//! codes that no longer place. Scored against the lever, the rule is +//! precision 1.000, recall 0.710 - exactly right where it fires, simply too +//! narrow. Widening the box until all 31 fall out is exactly how #88 shipped +//! a wrong model that scored perfectly. +//! +//! The rival candidate stays refuted: cliffs are both computed and placed +//! BEFORE any resource entity exists, and the masks are disjoint anyway, so no +//! collision test can see an entity that is not there yet. Where the rule DOES +//! act is at the destroy stage, which is what a field named +//! `cliff_removal_probability` predicts. + +use crate::cliffs::catalog::{ + cliff_orientation_for_code, CliffCollisionBox, CLIFF_ORIENTATION_COLLISION_BOX, +}; +use crate::cliffs::placement::CellRejection; +use crate::eval::ctx::VulcanusResourceControls; +use crate::expressions::vulcanus_stack::VulcanusStack; +use crate::poison; +use crate::resources::vulcanus_catalog::VulcanusOreFootprint; + +/// `cliff-vulcanus`'s prototype `collision_box`, read off a running game +/// (`LuaEntityPrototype.collision_box`). +/// +/// Quantised to `1/256` because `MapPosition` is 8-bit fixed point: +/// `0.98828125 = 253/256`, `0.48828125 = 125/256`. +pub const VULCANUS_CLIFF_BASE_COLLISION_BOX: CliffCollisionBox = + [-0.988_281_25, -0.488_281_25, 0.988_281_25, 0.488_281_25]; + +/// The three solid ores' collision half-extent, `0.09765625 = 25/256`, +/// identical across `tungsten-ore`, `calcite` and `coal`. +pub const VULCANUS_ORE_COLLISION_HALF: f64 = 0.097_656_25; + +/// `sulfuric-acid-geyser`'s collision half-extent, `1.3984375 = 358/256` - the +/// 2.8 x 2.8 box from `space-age/prototypes/entity/resources.lua:182`. +/// +/// More than fourteen times the ores' in each axis, which is what makes the +/// geometry measurable at all: a point-at-tile-centre test explains the calcite +/// cells and cannot explain the geyser ones. +pub const VULCANUS_GEYSER_COLLISION_HALF: f64 = 1.398_437_5; + +/// Which cliff rectangle the rejection tests with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CliffRejectionBox { + /// The prototype's base `collision_box` - the shape the rule was measured + /// with, and the shipping default. + #[default] + Base, + /// The per-orientation rotbb box the LAVA rejection uses. Kept so the choice + /// stays a measurement; see the module docs. + Orientation, +} + +/// A geyser placement predicate, for the arm that includes the geyser. +/// +/// Injected rather than built here because the geyser ROLLS: reproducing it +/// needs the placement machinery, which is the resource overlay's, not this +/// module's. +pub trait GeyserPlacement { + fn geyser_at(&self, x: i64, y: i64) -> bool; +} + +/// The ore -> cliff rejection for one Vulcanus stack. +pub struct VulcanusOreRejection<'a, 'b> { + stack: &'a VulcanusStack<'b>, + footprint: VulcanusOreFootprint, + box_kind: CliffRejectionBox, + /// Include the sulfuric-acid geyser as a suppressing entity. **Off unless a + /// predicate is supplied**, and that default is a measurement rather than + /// caution for its own sake. + /// + /// The three solid ores THRESHOLD off region fields the oracle validates to + /// ~1e-3, and the region saturates, so their footprint boundary is sharp and + /// essentially deterministic. The geyser ROLLS: its placements are + /// salt-dependent, and re-running one region over eight salts gives 46-63 + /// entities against the game's 56. A geyser our model puts in the wrong + /// place, with a box 14x the ores', removes a cliff the game KEPT - a false + /// rejection, which costs recall. This rule is otherwise pure precision, so + /// recall loss is the one outcome worth gating against. + geyser: Option<&'a dyn GeyserPlacement>, +} + +impl<'a, 'b> VulcanusOreRejection<'a, 'b> { + #[must_use] + pub fn new(stack: &'a VulcanusStack<'b>, controls: &VulcanusResourceControls) -> Self { + Self { + stack, + footprint: VulcanusOreFootprint::new(controls), + box_kind: CliffRejectionBox::Base, + geyser: None, + } + } + + #[must_use] + pub fn with_box(mut self, kind: CliffRejectionBox) -> Self { + self.box_kind = kind; + self + } + + #[must_use] + pub fn with_geyser(mut self, g: &'a dyn GeyserPlacement) -> Self { + self.geyser = Some(g); + self + } + + /// The cliff rectangle for a cell, relative to its centre. The + /// [`CliffRejectionBox::Orientation`] variant falls back to the base box for + /// a code that places nothing, which cannot reach the predicate anyway. + fn cliff_box(&self, code: u8) -> CliffCollisionBox { + match self.box_kind { + CliffRejectionBox::Base => VULCANUS_CLIFF_BASE_COLLISION_BOX, + CliffRejectionBox::Orientation => cliff_orientation_for_code(code) + .map_or(VULCANUS_CLIFF_BASE_COLLISION_BOX, |id| { + CLIFF_ORIENTATION_COLLISION_BOX[id as usize] + }), + } + } +} + +/// The inclusive tile window whose CENTRES can overlap a rectangle. +/// +/// An entity sits at a tile centre `(tx + 0.5, ty + 0.5)`, so the tiles that can +/// possibly overlap follow in closed form from the two rectangles - no entity +/// enumeration and no spatial index is needed. Cell centres sit at integer `x` +/// and half-integer `y`, so for the base box against an ore this window is +/// exactly TWO tiles; the geyser's larger box widens it to 4x3. Both are well +/// under the lava rejection's ~30 lookups per cell. +/// +/// The overlap is strict (`<`), which is the same comparison the measurement +/// used; solving that for `tx` gives the bounds below. +fn tile_window(lo: f64, hi: f64, half: f64) -> (i64, i64) { + let min = (lo - half - 0.5).floor() as i64 + 1; + let max = (hi + half - 0.5).ceil() as i64 - 1; + (min, max) +} + +impl CellRejection for VulcanusOreRejection<'_, '_> { + fn rejects(&self, code: u8, x: f64, y: f64) -> bool { + let [l, t, r, b] = self.cliff_box(code); + + if !self.footprint.is_empty() { + let (tx0, tx1) = tile_window(x + l, x + r, VULCANUS_ORE_COLLISION_HALF); + let (ty0, ty1) = tile_window(y + t, y + b, VULCANUS_ORE_COLLISION_HALF); + for tx in tx0..=tx1 { + for ty in ty0..=ty1 { + if self.footprint.occupies(self.stack, tx, ty) { + return poison::bool_result(true); + } + } + } + } + + if let Some(g) = self.geyser { + let (tx0, tx1) = tile_window(x + l, x + r, VULCANUS_GEYSER_COLLISION_HALF); + let (ty0, ty1) = tile_window(y + t, y + b, VULCANUS_GEYSER_COLLISION_HALF); + for tx in tx0..=tx1 { + for ty in ty0..=ty1 { + if g.geyser_at(tx, ty) { + return poison::bool_result(true); + } + } + } + } + + poison::bool_result(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The window is DERIVED, and the derivation is what this guards. + /// + /// The base box against an ore reaches exactly TWO TILES in total, and the + /// two axes are not symmetric: a cell centre sits at integer `x` and + /// half-integer `y`, so x spans two tiles and y exactly one. That asymmetry + /// is the whole reason the window is derived rather than written down - a + /// hardcoded square would be wrong on one axis whichever square was picked. + #[test] + fn the_base_box_against_an_ore_reaches_exactly_two_tiles() { + let [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + let (x0, x1) = tile_window(2.0 + l, 2.0 + r, VULCANUS_ORE_COLLISION_HALF); + let (y0, y1) = tile_window(2.5 + t, 2.5 + b, VULCANUS_ORE_COLLISION_HALF); + assert_eq!((x0, x1), (1, 2), "x window at a cell centre of 2"); + assert_eq!((y0, y1), (2, 2), "y window at a cell centre of 2.5"); + assert_eq!( + (x1 - x0 + 1) * (y1 - y0 + 1), + 2, + "tiles the ore arm tests per cell" + ); + } + + /// Widening the window by a tile on every side must find no additional + /// tile whose centre can overlap - which is what says the closed form is + /// tight rather than merely sufficient. The TypeScript's own spec asserts + /// the same thing by re-running the whole rejection with a padded window. + #[test] + fn a_tile_outside_the_window_cannot_overlap_the_cliff_box() { + let [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + let half = VULCANUS_ORE_COLLISION_HALF; + let (x0, x1) = tile_window(2.0 + l, 2.0 + r, half); + let (y0, y1) = tile_window(2.5 + t, 2.5 + b, half); + // The strict overlap the measurement used, stated independently of the + // closed form so the two can disagree. + let overlaps = |tx: i64, ty: i64| { + #[allow(clippy::cast_precision_loss)] + let (ex, ey) = (tx as f64 + 0.5, ty as f64 + 0.5); + ex - half < 2.0 + r && ex + half > 2.0 + l && ey - half < 2.5 + b && ey + half > 2.5 + t + }; + for tx in (x0 - 1)..=(x1 + 1) { + for ty in (y0 - 1)..=(y1 + 1) { + let inside = (x0..=x1).contains(&tx) && (y0..=y1).contains(&ty); + assert_eq!(overlaps(tx, ty), inside, "tile ({tx}, {ty})"); + } + } + } + + /// The geyser's box is fourteen times the ores', and that is what makes the + /// two rules geometrically distinguishable at all. + #[test] + fn the_geyser_box_widens_the_window_to_four_by_three() { + let [l, t, r, b] = VULCANUS_CLIFF_BASE_COLLISION_BOX; + let (x0, x1) = tile_window(2.0 + l, 2.0 + r, VULCANUS_GEYSER_COLLISION_HALF); + let (y0, y1) = tile_window(2.5 + t, 2.5 + b, VULCANUS_GEYSER_COLLISION_HALF); + assert_eq!(x1 - x0 + 1, 4); + assert_eq!(y1 - y0 + 1, 3); + } + + /// Both half-extents and every base-box edge are exact `1/256` multiples, + /// because `MapPosition` is 8-bit fixed point. A transcription slip that + /// dropped a digit would land off the grid. + #[test] + fn every_collision_constant_lands_on_the_eight_bit_fixed_point_grid() { + let mut all: Vec = VULCANUS_CLIFF_BASE_COLLISION_BOX.to_vec(); + all.push(VULCANUS_ORE_COLLISION_HALF); + all.push(VULCANUS_GEYSER_COLLISION_HALF); + for v in all { + let scaled = v * 256.0; + assert_eq!(scaled, scaled.trunc(), "{v} is not a 1/256 multiple"); + } + assert_eq!(VULCANUS_ORE_COLLISION_HALF * 256.0, 25.0); + assert_eq!(VULCANUS_GEYSER_COLLISION_HALF * 256.0, 358.0); + } +} diff --git a/crates/fmw-noise/src/expressions/vulcanus_resources.rs b/crates/fmw-noise/src/expressions/vulcanus_resources.rs index 0dc86a5d..13b10fef 100644 --- a/crates/fmw-noise/src/expressions/vulcanus_resources.rs +++ b/crates/fmw-noise/src/expressions/vulcanus_resources.rs @@ -141,6 +141,15 @@ impl SpotSpec { } } +/// The three solid ores' region fields - the projection of [`ResourceFields`] +/// that the ore -> cliff rejection reads. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OreRegions { + pub tungsten: f64, + pub coal: f64, + pub calcite: f64, +} + /// Every named expression this layer's oracle fixture grades, at one position. #[derive(Debug, Clone, Copy, PartialEq, Default)] pub struct ResourceFields { @@ -568,6 +577,47 @@ impl<'a> VulcanusResources<'a> { max2(starting, min2(1.0 - starting_circle, placed)) } + /// The three SOLID ores' region fields, and nothing else. + /// + /// The ore -> cliff rejection asks only "does a solid-ore entity stand on + /// this tile", which needs `tungsten_region`, `coal_region` and + /// `calcite_region`. Going through [`VulcanusResources::eval`] for that + /// would also evaluate the two sulfur cones, the sulfur spot selection and + /// the patch noise, none of which any consumer of this projection reads - + /// and the rejection runs on every placed cell of every chunk a render + /// touches, so it is the one call site where that matters. + /// + /// **A projection, not a second model.** Each line here is the same + /// expression `eval` uses, and + /// [`tests::the_ore_region_projection_agrees_with_the_full_eval_bit_for_bit`] + /// asserts the two agree on raw bits rather than approximately. Two + /// implementations that could drift apart would be worse than the work + /// saved, which is the standing objection to a fast path. + #[must_use] + pub fn ore_regions(&self, x: f64, y: f64) -> OreRegions { + let wobble = WobbleSums::at(self.helpers, x, y); + let starting_circle = self.spawn.eval(x, y, wobble).starting_circle; + let (wx, wy) = self.resource_wobble(x, y); + let cone = |spot: &StartingSpot| starting_spot_at_angle(spot, x, y, 0.5 * wx, 0.5 * wy); + OreRegions { + tungsten: Self::region( + cone(&self.spot_tungsten), + starting_circle, + self.place_metal_spots(x, y), + ), + coal: Self::region( + cone(&self.spot_coal), + starting_circle, + self.place_capped_spots(self.coal_spots, x, y), + ), + calcite: Self::region( + cone(&self.spot_calcite), + starting_circle, + self.place_capped_spots(self.calcite_spots, x, y), + ), + } + } + /// Evaluate every graded field of this layer at one position. #[must_use] pub fn eval(&self, x: f64, y: f64) -> ResourceFields { @@ -648,6 +698,60 @@ impl<'a> VulcanusResources<'a> { mod tests { use super::*; + /// The [`VulcanusResources::ore_regions`] fast path against the full + /// [`VulcanusResources::eval`] it projects, on RAW BITS rather than a + /// tolerance. + /// + /// Two implementations of the same three expressions is exactly the shape + /// that drifts silently - the ore -> cliff rejection would then reject + /// against a footprint the resource overlay does not draw, and neither + /// render would show it. This is what makes the projection a projection. + #[test] + fn the_ore_region_projection_agrees_with_the_full_eval_bit_for_bit() { + let ctx = EvalCtx::new(123_456); + let base = crate::expressions::vulcanus_stack::VulcanusBase::with_host_trig(&ctx); + let biomes = base.biomes_with_host_trig(); + let stack = + crate::expressions::vulcanus_stack::VulcanusStack::with_host_trig(&base, &biomes); + + // Spread across the three regions the cliff fixture covers, plus the + // starting area, where the four cones dominate rather than the spots. + let mut nonzero = 0usize; + for (x0, y0) in [(0.0, 0.0), (1500.0, 1500.0), (-1200.0, 800.0)] { + for i in 0..12 { + for j in 0..12 { + let (x, y) = (x0 + f64::from(i) * 19.0, y0 + f64::from(j) * 23.0); + let full = stack.resources(x, y); + let proj = stack.ore_regions(x, y); + assert_eq!( + proj.tungsten.to_bits(), + full.tungsten_region.to_bits(), + "tungsten at ({x}, {y})" + ); + assert_eq!( + proj.coal.to_bits(), + full.coal_region.to_bits(), + "coal at ({x}, {y})" + ); + assert_eq!( + proj.calcite.to_bits(), + full.calcite_region.to_bits(), + "calcite at ({x}, {y})" + ); + if proj.tungsten != 0.0 || proj.coal != 0.0 || proj.calcite != 0.0 { + nonzero += 1; + } + } + } + } + // Without this the comparison could pass on three fields that are zero + // everywhere sampled. + assert!( + nonzero > 100, + "only {nonzero} of 432 positions had any ore signal" + ); + } + fn layer_at(seed0: u32) -> (EvalCtx, VulcanusHelpers) { let ctx = EvalCtx::new(seed0); let helpers = VulcanusHelpers::new(&ctx); diff --git a/crates/fmw-noise/src/expressions/vulcanus_stack.rs b/crates/fmw-noise/src/expressions/vulcanus_stack.rs index c5d27950..47a8575a 100644 --- a/crates/fmw-noise/src/expressions/vulcanus_stack.rs +++ b/crates/fmw-noise/src/expressions/vulcanus_stack.rs @@ -45,7 +45,7 @@ use crate::expressions::vulcanus_climate::VulcanusClimate; use crate::expressions::vulcanus_cracks::VulcanusCracks; use crate::expressions::vulcanus_elevation::VulcanusElevation; use crate::expressions::vulcanus_helpers::VulcanusHelpers; -use crate::expressions::vulcanus_resources::{ResourceFields, VulcanusResources}; +use crate::expressions::vulcanus_resources::{OreRegions, ResourceFields, VulcanusResources}; use crate::expressions::vulcanus_spawn::VulcanusSpawn; use crate::multioctave_noise::Prepared; use crate::tiles::vulcanus_catalog::{ @@ -209,6 +209,36 @@ impl<'a> VulcanusStack<'a> { self.resources.eval(x, y) } + /// The three solid ores' region fields, which is all the ore -> cliff + /// rejection reads. A projection of [`VulcanusStack::resources`], not a + /// second model of it - see [`VulcanusResources::ore_regions`]. + #[must_use] + pub fn ore_regions(&self, x: f64, y: f64) -> OreRegions { + self.resources.ore_regions(x, y) + } + + /// `vulcanus_elevation` in the TILE channel - the 1-tile grid every + /// per-tile consumer walks, and what `calculate_tile_properties` reports. + /// + /// Distinct from [`VulcanusStack::cliff_elevation`] by exactly the amount + /// `multisample` shifts between grids (#83), which is not a rounding + /// difference: the two disagree by tens of tiles over most of a region. + #[must_use] + pub fn elevation(&self, x: f64, y: f64) -> f64 { + self.elevation.eval(x, y).elevation + } + + /// `cliff_elevation`, the elevation field the CLIFF generator samples. + /// + /// Distinct from the `elevation` the tile generator reads, because + /// `multisample`'s offsets are in the consuming program's grid units and + /// the cliff generator walks a 4-tile lattice (#83). Both hang off this one + /// stack and share every sub-expression below the multisample. + #[must_use] + pub fn cliff_elevation(&self, x: f64, y: f64) -> f64 { + self.elevation.cliff_elevation(x, y) + } + /// Every field the 19 tile expressions read, at one position. #[must_use] pub fn tile_fields(&self, x: f64, y: f64) -> VulcanusTileFields { diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index e890a7be..7e87a390 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -16,7 +16,9 @@ use crate::basis_gradient_table::{GRADIENT_X, GRADIENT_Y}; use crate::basis_noise::{basis_noise, tables_from_seed, BasisNoiseTables}; +use crate::eval::math::max2; use crate::test_json::{load, Json}; +use std::collections::BTreeMap; /// Load a fixture and pin the game version its ground truth was captured from. /// @@ -3358,3 +3360,376 @@ fn puts_every_vulcanus_tile_where_the_game_puts_it_at_a_real_saves_surface_seed( "the wrong-seed arm must be near chance, or it is not a control" ); } + +// --------------------------------------------------------------------------- +// Phase 5, second half (#225): the cliff stack. +// --------------------------------------------------------------------------- + +use crate::cliffs::catalog::{cliff_orientation_for_code, CLIFF_ORIENTATION_NAMES}; +use crate::cliffs::placement::{CliffBands, CliffFields, CliffPlacement, PlacedCliffCell}; +use crate::cliffs::vulcanus_fields::{ + VulcanusCliffFields, VulcanusLavaTiles, VULCANUS_CLIFF_ELEVATION_0, + VULCANUS_CLIFF_ELEVATION_INTERVAL, VULCANUS_CLIFF_SMOOTHING, +}; +use crate::cliffs::vulcanus_ore_rejection::VulcanusOreRejection; + +/// Which rejections a cliff scoring arm runs. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CliffArm { + /// `tileCollides` only, which is what `test/vulcanusCliffEntities.spec.ts` + /// scores. Kept so this port's numbers can be read against the figures that + /// spec's own header table publishes. + LavaOnly, + /// What `renderVulcanusCliffs.ts` ships: the lava rejection, the ore -> cliff + /// rejection, and both acting on the CROSSING rather than as a post-filter. + Shipping, +} + +/// Score one region of a cliff-entity fixture against the port. +/// +/// Returns `(game, ours, matched)` over `cliff-vulcanus` entities only. +/// `crater-cliff` is excluded rather than absorbed into the rates: it is placed +/// by the ENTITY generator, jitter draws and all, so its positions are +/// fractional and comparing them against a 4-tile lattice would be a category +/// error. +fn score_vulcanus_cliffs(region: &Json, cliffs: &[Json], seed0: u32, arm: CliffArm) -> CliffScore { + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let base = VulcanusBase::with_host_trig(&ctx); + let biomes = base.biomes_with_host_trig(); + let stack = VulcanusStack::with_host_trig(&base, &biomes); + + let fields = VulcanusCliffFields::new(&stack, seed0); + let lava = VulcanusLavaTiles::new(&stack); + let ore = VulcanusOreRejection::new(&stack, &ctx.vulcanus_resource_controls); + let bands = CliffBands { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + reject_at_crossing_stage: arm == CliffArm::Shipping, + ..CliffBands::default() + }; + let placement = CliffPlacement::new(&fields, bands).with_tile_collision(&lava); + let placement = match arm { + CliffArm::LavaOnly => placement, + CliffArm::Shipping => placement.with_cell_rejection(&ore), + }; + + let placed = placement.placed_cells( + region.get("x0").as_f64(), + region.get("y0").as_f64(), + region.get("x1").as_f64(), + region.get("y1").as_f64(), + ); + let ours: BTreeMap<(u64, u64), u8> = placed.iter().map(|c| (cell_key(c), c.code)).collect(); + let game: BTreeMap<(u64, u64), &str> = cliffs + .iter() + .filter(|c| c.get("name").as_str() == "cliff-vulcanus") + .map(|c| { + ( + (c.get("x").as_f64().to_bits(), c.get("y").as_f64().to_bits()), + c.get("orientation").as_str(), + ) + }) + .collect(); + + let mut matched = 0usize; + let mut orientation_agrees = 0usize; + for (k, want) in &game { + let Some(&code) = ours.get(k) else { continue }; + matched += 1; + let id = cliff_orientation_for_code(code).expect("a placed cell has an orientation"); + if CLIFF_ORIENTATION_NAMES[id as usize] == *want { + orientation_agrees += 1; + } + } + CliffScore { + game: game.len(), + ours: ours.len(), + matched, + orientation_agrees, + } +} + +/// What one region's arm scored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CliffScore { + /// `cliff-vulcanus` entities the game placed. + game: usize, + /// Cells the port places. + ours: usize, + /// Cells both agree on the POSITION of. + matched: usize, + /// Of those, how many the port also gives the game's own + /// `LuaEntity.cliff_orientation`. + orientation_agrees: usize, +} + +/// A position key that cannot round two distinct cells together: the raw bits +/// of the two coordinates, which are exact on the 4-tile lattice. +fn cell_key(c: &PlacedCliffCell) -> (u64, u64) { + (c.x.to_bits(), c.y.to_bits()) +} + +/// The Vulcanus cliff placement against `find_entities_filtered{type="cliff"}` +/// on a real Vulcanus surface - the end-to-end oracle for the whole stack. +/// +/// **Four columns per region, all frozen, which is stronger than what the +/// TypeScript spec asserts.** `test/vulcanusCliffEntities.spec.ts` bounds recall +/// and precision with guards "pinned just outside the measured values", wide +/// enough to swallow a change worth several cells - the #162 pathology this port +/// exists to stop inheriting. Freezing `ours` apart from `matched` is what makes +/// over-placement visible: a model that placed a cliff on every lattice cell +/// would score 100% recall. And `orientation` is four bits per cell against the +/// game's own `LuaEntity.cliff_orientation`, where position is one - a cell can +/// land in the right place off the wrong crossings, and 33 of them do. +/// +/// | arm | game | ours | matched | orientation | recall | precision | +/// | --- | ---: | ---: | ---: | ---: | ---: | ---: | +/// | lava only | 1569 | 1570 | 1525 | 1492 | 0.9720 | 0.9713 | +/// | shipping | 1569 | **1547** | 1525 | **1504** | 0.9720 | **0.9858** | +/// +/// **Both arms are graded because the difference between them IS the ore rule.** +/// It removes 23 cells and **not one of them is a cliff the game kept** - +/// `matched` is identical between the arms, so the whole 23 comes out of the +/// surplus - while turning 12 wrong orientations right. Wrong orientations go +/// **33 -> 21**, which is exactly what `renderVulcanusCliffs.ts` records having +/// measured for `rejectAtCrossingStage`, reached here through a separate +/// implementation and a different code path. +/// +/// All 23 are in region 1 `[1500,1500]`; regions 0 and 2 are untouched, which is +/// why the per-region rows are worth freezing and not just the totals. +/// +/// **Every one of these numbers was measured on the TypeScript side too, with +/// the same two arms against the same fixture, and all 24 agree exactly.** So +/// they describe the distance BOTH ports sit from the game, not a gap between +/// them - and because `orientation` agrees as well, the two ports produce the +/// same cell CODES and not merely the same positions. The lava-only rows also +/// reproduce the figures `vulcanusCliffEntities.spec.ts` publishes in its own +/// header table (283/283 at 0.9929, 885/900 at 0.9695/0.9533, 401/387 at +/// 0.9626/0.9974), which is a third, independently written statement of them. +/// +/// If one of these moves: read it, do not adjust it. Up is worth taking; down is +/// a regression. +#[test] +fn places_every_vulcanus_cliff_where_the_game_places_it() { + let fixture = load_captured_at( + "test/fixtures/oracle-vulcanus-cliff-entities.seed123456.json", + "2.1.12", + ); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let seed0 = fixture.get("seed").as_f64() as u32; + let cases = fixture.get("cases").as_array(); + assert_eq!(cases.len(), 3, "the fixture's three regions"); + + let score = |arm| { + let mut rows = Vec::new(); + let mut totals = CliffScore { + game: 0, + ours: 0, + matched: 0, + orientation_agrees: 0, + }; + for case in cases { + let got = score_vulcanus_cliffs( + case.get("region"), + case.get("cliffs").as_array(), + seed0, + arm, + ); + totals.game += got.game; + totals.ours += got.ours; + totals.matched += got.matched; + totals.orientation_agrees += got.orientation_agrees; + rows.push(got); + } + (rows, totals) + }; + let row = |game, ours, matched, orientation_agrees| CliffScore { + game, + ours, + matched, + orientation_agrees, + }; + + let (lava_rows, lava) = score(CliffArm::LavaOnly); + assert_eq!( + lava_rows, + vec![ + row(283, 283, 281, 276), + row(885, 900, 858, 833), + row(401, 387, 386, 383) + ], + "lava-only, per region" + ); + assert_eq!(lava, row(1569, 1570, 1525, 1492), "lava-only totals"); + + let (ship_rows, ship) = score(CliffArm::Shipping); + assert_eq!( + ship_rows, + vec![ + row(283, 283, 281, 277), + row(885, 877, 858, 842), + row(401, 387, 386, 385) + ], + "shipping, per region" + ); + assert_eq!(ship, row(1569, 1547, 1525, 1504), "shipping totals"); + + // The ore rule's own claim, stated as assertions rather than left to be read + // off the two rows. + assert_eq!( + lava.matched, ship.matched, + "the ore rejection cost a true positive" + ); + assert_eq!(lava.ours - ship.ours, 23, "cells the ore rejection removed"); + assert_eq!( + ( + lava.matched - lava.orientation_agrees, + ship.matched - ship.orientation_agrees + ), + (33, 21), + "wrong orientations, which renderVulcanusCliffs.ts records as 33 -> 21" + ); +} + +/// The Vulcanus cliff fields against the game's own samples at the game's own +/// lattice - 12,675 corners across three regions. +/// +/// This is the layer under [`places_every_vulcanus_cliff_where_the_game_places_it`], +/// and it needs its own grading for the reason the whole port is graded field by +/// field: an end-to-end count can be right for compensating reasons, and a +/// discrete output absorbs a sub-ULP error in its inputs essentially always. +/// +/// ## The fixture's `elevation` column is the TILE channel, and that is the +/// whole of issue #83 +/// +/// The capture samples through `LuaSurface.calculate_tile_properties`, whose +/// noise program has a **1-tile grid**. The cliff generator walks the **4-tile** +/// corner lattice, and `multisample`'s offsets are in GRID UNITS, so +/// `vulcanus_basalt_lakes_multisample` returns different values in the two +/// channels. Grading `cliff_elevation` against this column is therefore a +/// category error - it scores 419 of 12,675 with a worst residual of **60.6 +/// tiles**, and the TypeScript scores exactly the same 419 and the same 6.0623e1, +/// because both ports read the right field and the fixture holds the other one. +/// +/// So this test grades the TILE-channel field against the column that holds it, +/// and asserts the two channels DISAGREE - turning #83 from a comment into a +/// live assertion. A port that collapsed the two grids would go red here rather +/// than quietly losing seven points of cliff recall, which is how the bug hid +/// the first time: the fixture and the port shared the mistake, so every check +/// agreed. +/// +/// The gap is **sparse and large**, not a uniform offset: the grids disagree at +/// 2,519 of the 12,675 corners and agree at the other 10,156, because the 2x2 +/// min-filter only bites where a neighbour is lower. Where it bites it is worth +/// up to 60.6 tiles. That shape is why the wrong channel cost seven points of +/// recall rather than being obvious. +/// +/// **The tile-channel elevation itself is 786 of 12,675**, worst residual +/// 4.393e-2 - about 1.3e-4 relative on a field spanning roughly -58 to +1024, +/// the same order every layer above it carries. That is the standing Vulcanus +/// elevation gap (#293 took it from 115 to 169 of 434 on its own fixture), not +/// anything the cliff stack introduced, and the TypeScript scores the identical +/// 786 and the identical 4.3931e-2. +/// +/// **`cliffiness` is exact at every corner - 12,675 of 12,675.** It has no +/// `multisample` in it, so it is channel-independent and this fixture grades it +/// directly. Read the count with its clamp: `cliffiness_basic` ends in +/// `min(1, max(0, ...)) + 0.5` and saturates at 8,431 of the 12,675 corners, +/// where a position is exact for free. The other 4,244 are not, which is what +/// makes the full house worth something. +#[test] +fn reproduces_the_vulcanus_cliff_fields_at_every_captured_corner() { + let fixture = load_captured_at( + "test/fixtures/oracle-vulcanus-cliff-corner-fields.seed123456.json", + "2.1.12", + ); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let seed0 = fixture.get("seed").as_f64() as u32; + let grid = fixture.get("grid").as_f64(); + assert_eq!(grid, 4.0, "the cliff lattice is 4 tiles"); + assert_eq!( + fixture.get("cornerOffsetY").as_f64(), + 0.0, + "the game samples the BARE lattice - a 0.5 here is the superseded capture" + ); + + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let base = VulcanusBase::with_host_trig(&ctx); + let biomes = base.biomes_with_host_trig(); + let stack = VulcanusStack::with_host_trig(&base, &biomes); + let fields = VulcanusCliffFields::new(&stack, seed0); + + let corners = fixture.get("corners").as_array(); + let want_elev = fixture.get("elevation").as_f64_array(); + let want_cliff = fixture.get("cliffiness").as_f64_array(); + assert_eq!(corners.len(), 12_675, "captured corners"); + assert_eq!(want_elev.len(), corners.len()); + assert_eq!(want_cliff.len(), corners.len()); + + let mut tile_exact = 0usize; + let mut cliff_exact = 0usize; + let mut saturated = 0usize; + let mut channels_differ = 0usize; + let mut worst_tile: f64 = 0.0; + let mut worst_channel_gap: f64 = 0.0; + for (k, corner) in corners.iter().enumerate() { + let key = corner.as_str(); + let (i, j) = key.split_once(',').expect("corner keys are \"i,j\""); + let x = i.parse::().expect("corner i") * grid; + let y = j.parse::().expect("corner j") * grid; + + let tile_channel = stack.elevation(x, y); + if tile_channel as f32 == want_elev[k] as f32 { + tile_exact += 1; + } + worst_tile = max2(worst_tile, (tile_channel - want_elev[k]).abs()); + + let cliff_channel = fields.cliff_elevation(x, y); + let gap = (cliff_channel - tile_channel).abs(); + if gap > 0.0 { + channels_differ += 1; + } + worst_channel_gap = max2(worst_channel_gap, gap); + + let got_cliff = fields.cliffiness(x, y); + if got_cliff as f32 == want_cliff[k] as f32 { + cliff_exact += 1; + } + if want_cliff[k] == 0.5 || want_cliff[k] == 1.5 { + saturated += 1; + } + } + + assert_eq!( + cliff_exact, 12_675, + "cliffiness exact f32 matches out of 12,675" + ); + assert_eq!( + saturated, 8_431, + "corners where the cliffiness clamp saturates" + ); + assert_eq!( + tile_exact, 786, + "tile-channel elevation exact f32 matches out of 12,675" + ); + assert!( + worst_tile < 4.4e-2, + "tile-channel elevation worst residual {worst_tile:e}" + ); + + // #83 as an assertion. The two channels are the same expression read + // through different grids, and they must not agree. + assert_eq!( + channels_differ, 2_519, + "corners where the two grids disagree" + ); + + // #83 as an assertion. The two channels are the same expression read + // through different grids, and they must not agree. + + assert!( + worst_channel_gap > 50.0, + "the channel gap collapsed to {worst_channel_gap} - has multisample lost its grid?" + ); +} diff --git a/crates/fmw-noise/src/lib.rs b/crates/fmw-noise/src/lib.rs index c6a9336d..a4685074 100644 --- a/crates/fmw-noise/src/lib.rs +++ b/crates/fmw-noise/src/lib.rs @@ -15,6 +15,7 @@ pub mod basis_gradient_table; pub mod basis_noise; pub mod checksum; +pub mod cliffs; pub mod distance_from_nearest_point; pub mod eval; pub mod expressions; @@ -23,6 +24,7 @@ pub mod multioctave_noise; pub mod poison; pub mod quick_multioctave_noise; pub mod random_penalty; +pub mod resources; pub mod spot_candidates; pub mod spot_selection; pub mod starting_lakes; diff --git a/crates/fmw-noise/src/poison.rs b/crates/fmw-noise/src/poison.rs index f7fe434f..e726995f 100644 --- a/crates/fmw-noise/src/poison.rs +++ b/crates/fmw-noise/src/poison.rs @@ -171,3 +171,51 @@ pub fn index_result(index: usize, len: usize) -> usize { let _ = len; index } + +/// Rotate a tri-state CROSSING to the next value. +/// +/// `CliffGenerator::crossesCliff` answers "no crossing", "crossing up" or +/// "crossing down", and four of those answers assemble into the cell code the +/// orientation table keys on. That is a classification, so the numeric hooks +/// cannot reach it for the reason [`bool_result`] and [`index_result`] record: +/// a one-ULP nudge to an elevation changes which side of a band boundary it +/// falls on essentially never. +/// +/// It rotates rather than negating because negating `0` is `0` - the answer +/// most edges give - so a sign flip would leave most of the lattice untouched +/// and the end-to-end cliff test could stay green. Rotating moves every edge. +#[inline] +#[must_use] +pub fn crossing_result(value: i8) -> i8 { + #[cfg(feature = "poison")] + return match value { + 0 => 1, + 1 => -1, + _ => 0, + }; + #[cfg(not(feature = "poison"))] + value +} + +/// Rotate the edge order the cliff repair sweep tries. +/// +/// `fixImpossibleCellsSweep` has no numeric output to bend and no single choice +/// to flip: it is an algorithm over discrete inputs whose only observable is +/// which edge it cleared. The engine's order is `L, T, R, B`, and clearing a +/// different one leaves a different - usually still legal - cell code, which is +/// precisely the wrong answer a mis-ported sweep gives. +/// +/// **It needs its own hook even though the end-to-end cliff test already goes +/// red**, for the reason [`index_result`] records: under poison +/// [`crossing_result`] moves every edge in the lattice, so +/// `places_every_vulcanus_cliff_where_the_game_places_it` would be red whether +/// or not the sweep had a control at all. A gate satisfiable by an unrelated +/// part of the system is not a gate for the new part. +#[inline] +#[must_use] +pub fn sweep_order(order: [usize; 4]) -> [usize; 4] { + #[cfg(feature = "poison")] + return [order[1], order[2], order[3], order[0]]; + #[cfg(not(feature = "poison"))] + order +} diff --git a/crates/fmw-noise/src/resources/mod.rs b/crates/fmw-noise/src/resources/mod.rs new file mode 100644 index 00000000..8fdc9368 --- /dev/null +++ b/crates/fmw-noise/src/resources/mod.rs @@ -0,0 +1,13 @@ +//! The resource OVERLAY layer: how the game's resource probabilities turn into +//! placed entities. +//! +//! Distinct from `expressions::vulcanus_resources`, which is the noise chain. +//! This is the thin layer above it - thresholds, footprints and the per-entry +//! catalog - ported from `src/noise/resources/`. +//! +//! Only the ore FOOTPRINT is here so far. It arrived with the cliff stack, +//! because the ore -> cliff rejection asks exactly this question, and the rest +//! of the catalog (map colours, the geyser's rolled probability) lands with the +//! resource overlay itself. + +pub mod vulcanus_catalog; diff --git a/crates/fmw-noise/src/resources/vulcanus_catalog.rs b/crates/fmw-noise/src/resources/vulcanus_catalog.rs new file mode 100644 index 00000000..13892c06 --- /dev/null +++ b/crates/fmw-noise/src/resources/vulcanus_catalog.rs @@ -0,0 +1,74 @@ +//! The Vulcanus resource catalog, ported from +//! `src/noise/resources/vulcanusResourceCatalog.ts`. +//! +//! **Partial by design.** The cliff stack needs the solid-ore footprint and +//! nothing else; the map colours, the entry ordering and the geyser's rolled +//! probability serve the resource overlay and land with it. What is here is the +//! whole of what the cliff rejection reads. + +use crate::eval::ctx::VulcanusResourceControls; +use crate::expressions::vulcanus_stack::VulcanusStack; + +/// The threshold a solid ore's probability must clear for the game to have +/// placed an entity on that tile: `probability >= 0.5`. +/// +/// **This lives in the catalog rather than in a renderer because it has two +/// consumers.** The resource overlay paints with it, and the ore -> cliff +/// rejection asks the same question to decide whether an ore suppresses a +/// cliff. Two copies of the number could drift apart, and the cliff overlay +/// would then reject against a footprint the ore overlay does not draw - a +/// disagreement invisible in both renders. +pub const RESOURCE_PROBABILITY_THRESHOLD: f64 = 0.5; + +/// Does the game hold a solid-ore entity on the tile whose centre is +/// `(x + 0.5, y + 0.5)`? +/// +/// The three solid ores THRESHOLD, so their footprint is exactly +/// `1000 * region >= RESOURCE_PROBABILITY_THRESHOLD` over the entries whose +/// `size` lever is positive. A disabled ore occupies nothing, which is not a +/// special case bolted on: it is the same `size = 0` lever the game itself was +/// driven with to establish that ore suppresses cliffs (#99). +/// +/// The geyser is deliberately absent - it ROLLS rather than thresholds, so it +/// has no footprint expressible this way. Callers that want it pass their own +/// predicate. +/// +/// **The field is sampled at the tile's integer coordinate, not at its centre.** +/// That is what the TypeScript does and what the measurement was made with; the +/// doc comment above describes which tile the answer is about, not where the +/// sample is taken. +pub struct VulcanusOreFootprint { + tungsten: bool, + coal: bool, + calcite: bool, +} + +impl VulcanusOreFootprint { + #[must_use] + pub fn new(controls: &VulcanusResourceControls) -> Self { + Self { + tungsten: controls.tungsten_ore.size > 0.0, + coal: controls.vulcanus_coal.size > 0.0, + calcite: controls.calcite.size > 0.0, + } + } + + /// True when no ore is enabled, so the whole rejection can be skipped. + #[must_use] + pub fn is_empty(&self) -> bool { + !self.tungsten && !self.coal && !self.calcite + } + + /// Whether a solid ore stands on tile `(tx, ty)`. + #[must_use] + pub fn occupies(&self, stack: &VulcanusStack<'_>, tx: i64, ty: i64) -> bool { + if self.is_empty() { + return false; + } + #[allow(clippy::cast_precision_loss)] + let r = stack.ore_regions(tx as f64, ty as f64); + (self.tungsten && 1000.0 * r.tungsten >= RESOURCE_PROBABILITY_THRESHOLD) + || (self.calcite && 1000.0 * r.calcite >= RESOURCE_PROBABILITY_THRESHOLD) + || (self.coal && 1000.0 * r.coal >= RESOURCE_PROBABILITY_THRESHOLD) + } +} diff --git a/scripts/verify-rust.sh b/scripts/verify-rust.sh index b95a44b5..66ae2f04 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -117,6 +117,26 @@ POISONED_TESTS=( tiles::vulcanus_catalog::tests::an_exact_tie_resolves_to_the_earlier_tile_in_order tiles::fulgora_catalog::tests::an_exact_tie_resolves_to_the_earlier_tile_in_land_order + + # Phase 5's second half (#225), the cliff stack. Three hooks, because three + # ops here can be wrong independently and one red test would otherwise stand + # in for all of them: + # + # - `poison::f64_result` on `cliffiness_basic`, the only numeric field; + # - `poison::crossing_result` on `crosses_cliff`, whose output is a + # TRI-STATE classification a numeric hook cannot reach; + # - `poison::sweep_order` on `fixImpossibleCells`, which has no value to + # bend at all - only a choice of which edge to clear; + # - `poison::bool_result` on `isCliffConnected` and the ore rejection. + # + # The two `cliffs::placement` tests are here rather than only the fixtures: + # under poison the crossing hook moves every edge in the lattice, so the + # end-to-end test is red whether or not the sweep has a control. + fixtures::places_every_vulcanus_cliff_where_the_game_places_it + fixtures::reproduces_the_vulcanus_cliff_fields_at_every_captured_corner + cliffs::placement::tests::a_crossing_needs_a_band_a_sign_and_the_cliffiness_gate + cliffs::placement::tests::the_sweep_clears_the_first_clearable_edge_in_l_t_r_b_order + cliffs::connections::tests::connection_is_a_parity_test_and_not_a_do_they_touch_test ) for t in "${POISONED_TESTS[@]}"; do if ! grep -q "^test ${t} \.\.\. FAILED" <<<"$POISON_OUT"; then diff --git a/src/noise/wasm/engine.wasm b/src/noise/wasm/engine.wasm index 90ab7fe9bab79e56d43b14a14a04ba34ea9f577d..da66b079094cc30194e0eea2104a21cbb5363f2f 100755 GIT binary patch delta 28 kcmbQVn`6>$j)oS-Elh1(j9aF+b1{W8-rIhki)phE0G;UyasU7T delta 28 kcmbQVn`6>$j)oS-Elh1(jH{=&b1{W8Ufq75i)phE0G%ufU;qFB From 44a4ecd276fa9ce4034f80df5a6d27e24d9f46eb Mon Sep 17 00:00:00 2001 From: Eric J Date: Mon, 24 Aug 2026 12:20:20 -0700 Subject: [PATCH 2/3] Render Vulcanus cliffs through the engine, on a per-planet ABI field (#225) The `cliffs` view now dispatches to WebAssembly. `rocks`, `resources` and `all` still take the TypeScript path, and the parity spec asserts that rather than assuming it. **ABI: the Vulcanus block grows 248 -> 280 bytes with NO version bump.** That is the per-planet split working rather than a shortcut - the prefix declares its own block length, `BadParamsLength` refuses a writer that disagrees, and Fulgora's request did not move a byte. A version bump is for a change to the COMMON prefix, which every planet reads. The new field is the cliff `cell_query_box`, four f64, and it is **sent rather than derived**. The halo is asymmetric, its two directions CROSS - a mark reaching far backwards has to be caught from ahead of the tile - and it needs the FULL image's geometry, which the prefix does not carry and only the tiled renderer knows. Keeping the arithmetic on the TypeScript side keeps it in the one place `test/tiledEquality.spec.ts` already guards. `cliffs` is sent as ONE request rather than two because the overlay has nothing to draw on its own and the two passes share the whole field DAG below the tile argmax; splitting it would build that chain twice. Tier 3, in `test/wasmVulcanusRenderParity.spec.ts`: - byte-identical against the TypeScript across the same four windows terrain uses, which vary every geometry field independently; - cliff pixels painted per window, frozen per window rather than bounded, and counted only where the overlay actually CHANGED the terrain render - a cliff pixel that was already that colour proves nothing; - tiled equals whole THROUGH THE ENGINE, with a no-halo arm that must differ. That arm runs at 8 tiles/px on purpose: at 1 tile/px the 4px block sits on a 4px lattice and a 32px seam is a multiple of 4, so blocks never straddle and the test would pass with the halo doing nothing. `test/fixtures/verify-wasm-request.py` - the third implementation, not the writer under test - grew five planted breaks for the new field, all confirmed caught by its per-edge value check. Its two structural checks (four distinct edges, not inverted) constrain the FIXTURE rather than catching a break, and the file says so rather than claiming credit for the five. **The benchmark both layers' own docs asked for.** `vulcanus_resources` and `vulcanus_biomes` each said "nothing on the render path reaches this layer yet, so it is correct-first by choice" and named the measurement to take when that stopped being true. The ore rejection now reaches both. Measured at 256x256, 1 tile/px, min of 5 after a warm pass, three runs agreeing: | arm | terrain | cliffs | overlay | | --- | ---: | ---: | ---: | | TypeScript | 33.10 us/px | 42.41 us/px | 1.28x | | WASM | 8.64 us/px | 9.52 us/px | 1.10x | The un-memoized chain costs proportionally LESS here than the memoized one does there, because the cliff pass walks a 4-tile lattice rather than every pixel - a few thousand evaluations against the terrain sweep's 65,536. Read the RATIOS and not the microseconds: those absolutes are from inside vitest, where the TypeScript arm pays #267's transform and the WASM arm does not, and `vulcanus-cliffs-NOTES.md` measures the same TypeScript terrain view at 12.68 us/px outside it. A ratio between the arms would be measuring the harness. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj --- CLAUDE.md | 110 +++++++++++- crates/fmw-noise/src/cliffs/catalog.rs | 37 ++++ crates/fmw-noise/src/cliffs/placement.rs | 19 +++ .../src/expressions/vulcanus_biomes.rs | 16 +- .../src/expressions/vulcanus_resources.rs | 39 ++++- crates/fmw-wasm/src/abi.rs | 67 +++++++- crates/fmw-wasm/src/render.rs | 91 +++++++++- src/noise/preview/elevationRenderRequest.ts | 32 ++-- src/noise/wasm/engine.wasm | Bin 138642 -> 156805 bytes src/noise/wasm/request.ts | 38 ++++- test/fixtures/PROVENANCE.json | 2 +- test/fixtures/verify-wasm-request.py | 53 ++++-- test/fixtures/wasm-request.v2.json | 60 ++++++- test/wasmFulgoraRenderParity.spec.ts | 4 +- test/wasmVulcanusRenderParity.spec.ts | 159 ++++++++++++++++-- 15 files changed, 656 insertions(+), 71 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8470ad9d..4baf3b94 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1409,16 +1409,89 @@ engine.** Landed: `vulcanus_helpers`, `vulcanus_cracks`, `vulcanus_climate`, `tiles/vulcanus_catalog`, `vulcanus_stack`, and the `terrain` render path behind ABI v2. `vulcanus_shared` needed no port - it is `starting_spot_at_angle`, done in #279 - and `vulcanus_seed` landed in phase 2. -Still out: the cliff, resource and rock OVERLAY stacks, so every composite view -keeps the TypeScript path and a test asserts that rather than assuming it. + +**Phase 5's second half adds the CLIFF stack, and `cliffs` renders through the +engine too.** `cliffs/{catalog,placement,connections,vulcanus_fields, +vulcanus_ore_rejection}` plus the ore footprint slice of +`resources/vulcanus_catalog`. Still out: the resource and rock OVERLAY stacks, +so `rocks`, `resources` and `all` keep the TypeScript path and a test asserts +that rather than assuming it. + +**Three of the nine TypeScript files in that directory pair were NOT ported, and +each for its own reason.** Read this before "finishing" them: + +- `cliffFields.ts` and `rocks/rockField.ts` are NAUVIS. They need + `nauvis_shared`, `elevation_nauvis`, `aux` and `moisture` - 464 more lines + that are the core of #226 - and neither reaches a Vulcanus view. They belong + to phase 6. +- `cliffConnections.ts` WAS ported, and it is the odd one: it has **zero `src/` + consumers**. `grep -rln` finds it imported by 23 investigation specs and by + nothing the renderer runs. It models `Cliff::updateConnections` / + `onDestroy`, which is #84's subject, and it was ported so that investigation + can be run against the engine rather than only against the TypeScript. **Tier 3 for Vulcanus** (`test/wasmVulcanusRenderParity.spec.ts`) is -byte-identical against the TypeScript across four windows, and **12,423 of -929,686** compared pixels against the game's own 1024x1024 PNG - 98.664%, which -is the TypeScript's own number to four decimal places, reached through a -separate path. It is asserted as an EXACT count where -`previewAgreement.spec.ts` uses a 2% bound, because byte-identity means it can -be. +byte-identical against the TypeScript across four windows for BOTH the `terrain` +and the `cliffs` view, and **12,423 of 929,686** compared pixels against the +game's own 1024x1024 PNG - 98.664%, which is the TypeScript's own number to four +decimal places, reached through a separate path. It is asserted as an EXACT +count where `previewAgreement.spec.ts` uses a 2% bound, because byte-identity +means it can be. + +**The cliff stack's tier 1 is the game's own cliff entities, four columns, both +rejection arms** - and every one of the 24 numbers was measured on the +TypeScript side too and agrees exactly, so they describe the distance BOTH ports +sit from the game: + +| arm | game | ours | matched | orientation | +| --------- | ---: | -------: | ------: | ----------: | +| lava only | 1569 | 1570 | 1525 | 1492 | +| shipping | 1569 | **1547** | 1525 | **1504** | + +`orientation` is four bits per cell against `LuaEntity.cliff_orientation` where +position is one, and it is what says the two ports produce the same cell CODES +rather than merely the same positions. The ore rejection removes 23 cells, none +of them a cliff the game kept, and takes wrong orientations **33 -> 21** - which +is exactly the figure `renderVulcanusCliffs.ts` records having measured, reached +through a separate implementation. + +**`cliffiness_basic` is exact at all 12,675 captured corners**, with the clamp +saturating at 8,431 of them - read the count with its clamp, the way +`vulcanus_biomes`' three clamped biomes are read. + +**The corner fixture's `elevation` column is the TILE channel, and grading +`cliff_elevation` against it is a category error worth 60.6 tiles.** That is +issue #83 - `multisample`'s offsets are in the consuming program's grid units, +so the 4-tile cliff lattice and the 1-tile tile lattice read different values. +Both ports score the same 419 of 12,675 against it, because both read the right +field and the fixture holds the other one. The test now grades the TILE-channel +field (786 of 12,675, worst 4.393e-2, identical on both sides) and asserts the +two grids DISAGREE at 2,519 corners - turning #83 from a comment into a live +assertion. The gap is **sparse and large** rather than a uniform offset, which +is why the wrong channel cost seven points of recall instead of being obvious. + +**The cliff pass needed THREE poison hooks, not one.** `crosses_cliff` returns a +tri-state classification a numeric hook cannot reach (`poison::crossing_result`, +which ROTATES rather than negating - negating `0` is `0`, the answer most edges +give, so a sign flip would leave most of the lattice untouched). And +`fixImpossibleCells` has no value to bend at all, only a choice of which edge to +clear, so it gets `poison::sweep_order`. Both have their own test in +`POISONED_TESTS`, because under poison the crossing hook moves every edge in the +lattice and the end-to-end test would be red whether or not the sweep had a +control. + +**ABI v2's Vulcanus block grew 248 -> 280 bytes with NO version bump**, and that +is the per-planet split working rather than a shortcut: the prefix declares its +own block length, `BadParamsLength` refuses a writer that disagrees, and +Fulgora's request did not move a byte. A version bump is for a change to the +COMMON prefix, which every planet reads. The new field is the cliff +`cell_query_box`, four `f64`, and it is **sent rather than derived** - the halo +is asymmetric, its two directions CROSS, and it needs the FULL image's geometry, +which the prefix does not carry and only the tiled renderer knows. +`test/fixtures/verify-wasm-request.py` grew five more planted breaks for it, all +caught by its per-edge value check; its two structural checks (four distinct +edges, not inverted) constrain the FIXTURE rather than catching a break, and the +comment says so rather than claiming credit for the five. **`vulcanus_stack` is TWO structs, and that is ownership rather than taste.** `VulcanusBiomes`, `VulcanusElevation` and `VulcanusResources` all borrow the @@ -1931,6 +2004,27 @@ time, under the greedy-accept rule. source, profile and pinned toolchain give the same bytes and the same sha256 on macOS/aarch64 and on an ubuntu x86_64 runner. That is why the gate can use `cmp` instead of rebuild-and-retest. +- **A `engine.wasm` diff can be pure LINE NUMBERS, and a DOC COMMENT is enough + to cause one.** Seen twice while landing #225's cliff half: a 9-line struct + added to `vulcanus_resources.rs` moved 2 bytes (two `core::panic::Location` + line numbers for that file's `RefCell` borrow sites, 427 -> 436 and 469 -> + 478), and a **19-line `///` block on its own** in `cliffs/placement.rs` moved + 9 bytes - six Locations in that file, every one shifted by exactly 19. No code + byte moved either time and every section kept its exact size. So a + comment-only edit in a reachable file makes `verify-rust.sh` report "stale", + and that is the gate working rather than a false positive. + + The fingerprint: tiny `cmp -l` count, every changed offset inside the `data` + section, all section sizes identical, and a `u32` delta equal to the lines you + inserted. **The trap is alignment** - the record is `{file_ptr, file_len, +line, col}` and it is NOT 4-byte aligned in the data image, so reading a `u32` + at `offset - (offset % 4)` gave "delta 4864" and looked like a moved string + table; realigned, the same field is 716 -> 735 and 4864 is just `19 << 8`. + Locate the record from its file pointer and length, not from alignment. The + build itself is deterministic - a no-change rebuild reproduces the bytes + exactly, checked while chasing this - so a diff after an edit is always the + edit. + - **The `poison` feature is the gate's anti-vacuity control, and it needs ONE HOOK PER OP.** It perturbs an op's returned value; `verify:rust` builds with it and asserts a **named list** of tier-1 tests goes red. The list is why: while diff --git a/crates/fmw-noise/src/cliffs/catalog.rs b/crates/fmw-noise/src/cliffs/catalog.rs index 1ec40cef..7518e745 100644 --- a/crates/fmw-noise/src/cliffs/catalog.rs +++ b/crates/fmw-noise/src/cliffs/catalog.rs @@ -65,6 +65,43 @@ pub const CLIFF_CELL_CENTER_X: f64 = 2.0; /// `y mod 4 == 2.5`, which the oracle spec checks on the fixture itself. pub const CLIFF_CELL_CENTER_Y: f64 = 2.5; +/// In-game `map_color` for cliff tiles. +/// +/// `cliff-vulcanus` declares the same `{144, 119, 87}` Nauvis's `cliff` does, so +/// no second colour is needed. +pub const CLIFF_MAP_COLOR: [u8; 3] = [144, 119, 87]; + +/// Side, in pixels, of the block painted per placed cliff cell. +/// +/// **4 is the size at which cells abut exactly** at the app's 1024px / +/// 1-tile-per-pixel preview: centres are 4px apart, so 4px blocks tile with +/// neither gap nor overlap. It replaced a 5x5 centred block, which overlapped +/// its neighbour by a pixel and read a pixel too thick - and the overlap was +/// doing no work, because it is the TILING, not the excess, that joins the +/// stipple into a line. +/// +/// **Do not drop this to 3.** Measured: at 3px the blocks fall a pixel short of +/// their neighbour and the ridgelines break into visible dashes. 4 is the floor, +/// not a preference. +/// +/// Deliberately in PIXEL space rather than world space. A world-space footprint +/// would be more faithful at 1 tile/px and would vanish when zoomed out, where a +/// cell is a fraction of a pixel; the whole point of the block is legibility at +/// preview scale. +pub const CLIFF_MARK_SIZE_PX: i64 = 4; + +/// How far the block extends BELOW/LEFT of the cell centre pixel. +/// +/// The block spans `px - CLIFF_MARK_BACK_PX ..= px + CLIFF_MARK_SIZE_PX - +/// CLIFF_MARK_BACK_PX - 1`, which aligns it with the cell's own footprint rather +/// than hanging it off one corner: a cell centred at world `cx*4 + 2` spans +/// `[cx*4, cx*4+4)`, i.e. 2 tiles back and 1 forward from its centre pixel. +/// +/// Also the halo a tiled renderer must widen its cell enumeration by, since it +/// is the larger of the two directions - and the two directions CROSS, which is +/// why the caller sends the query box rather than the engine deriving it. +pub const CLIFF_MARK_BACK_PX: i64 = 2; + /// Cells (and corners) per chunk axis: a 32-tile chunk over the 4-tile grid. pub const CHUNK_CELLS: usize = 8; diff --git a/crates/fmw-noise/src/cliffs/placement.rs b/crates/fmw-noise/src/cliffs/placement.rs index 3d5255bf..51be12f7 100644 --- a/crates/fmw-noise/src/cliffs/placement.rs +++ b/crates/fmw-noise/src/cliffs/placement.rs @@ -668,6 +668,25 @@ impl CornerRect { /// and simpler. Nothing about the OUTPUT depends on the choice - both fields are /// pure functions of position - but the smoothing knots are read repeatedly and /// `cliffiness` dominates the pass, so the cache is not optional. +/// +/// **It allocates for the whole rectangle up front, and there is no cap on +/// that.** Worth stating rather than leaving to be discovered, because this +/// module sits behind a boundary whose contract is that errors return a status +/// and never trap: a caller-supplied query box large enough to exhaust linear +/// memory would abort the instance instead. +/// +/// No cap is imposed anyway, and that is a deliberate choice rather than an +/// oversight. The chunk-structured pass VISITS essentially every corner of its +/// rectangle, so the TypeScript's `Map` ends up holding the same count - as +/// string keys and boxed numbers, so strictly more memory for the same query. +/// A cap here would reject renders the TypeScript performs happily, which is a +/// behaviour difference, and behaviour parity is the whole point of the port. +/// +/// The exposure is small in practice and that is measured rather than hoped: +/// the app tiles cliff renders into 64-pixel workers, so a request's rectangle +/// is a few thousand corners whatever the zoom. A cap belongs here only if a +/// whole-image cliff render at a high tiles-per-pixel ever becomes a real call +/// site, and then it belongs on BOTH sides. struct CornerCache<'a, F: CliffFields> { fields: &'a F, smoothing: f64, diff --git a/crates/fmw-noise/src/expressions/vulcanus_biomes.rs b/crates/fmw-noise/src/expressions/vulcanus_biomes.rs index f9f8270c..6694dd45 100644 --- a/crates/fmw-noise/src/expressions/vulcanus_biomes.rs +++ b/crates/fmw-noise/src/expressions/vulcanus_biomes.rs @@ -37,9 +37,19 @@ //! `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 +//! recomputes them. +//! +//! **This layer joined a render path with the cliff view, and the measurement +//! its own caveat asked for has been taken.** The ore -> cliff rejection reads +//! `vulcanus_resources`, whose `select_spots` pulls this chain. Measured at +//! 256x256, 1 tile/px, the cliff overlay costs **1.10x** the terrain sweep in +//! the WASM arm against **1.28x** in the TypeScript arm - so the un-memoized +//! chain is proportionally cheaper here than the memoized one is there, because +//! the cliff pass walks a 4-tile lattice rather than every pixel. The full table +//! and the reason only WITHIN-arm ratios are readable are in +//! `vulcanus_resources`' own cost section. +//! +//! It is still correct-first by choice for any PER-PIXEL consumer, and //! `multioctave_noise`'s own docs record what happened last time a per-call //! rebuild went unmeasured: 20x. diff --git a/crates/fmw-noise/src/expressions/vulcanus_resources.rs b/crates/fmw-noise/src/expressions/vulcanus_resources.rs index 13b10fef..79925f03 100644 --- a/crates/fmw-noise/src/expressions/vulcanus_resources.rs +++ b/crates/fmw-noise/src/expressions/vulcanus_resources.rs @@ -41,10 +41,41 @@ //! //! `select_spots` evaluates density and favorability at accepted candidates, //! and both pull a whole biome-full chain at that candidate. The TypeScript -//! memoizes those; this recomputes them. Nothing on the render path reaches -//! this layer yet, so it is correct-first by choice - the same call this layer's -//! neighbour made. `multioctave_noise`'s docs record what happened the last time -//! a per-call rebuild went unmeasured, which was 20x. +//! memoizes those; this recomputes them. +//! +//! **This layer IS on a render path now, and the recomputation was measured +//! rather than left as a caveat.** The ore -> cliff rejection reaches it through +//! [`VulcanusResources::ore_regions`], so the `cliffs` view evaluates this +//! chain a couple of tiles per placed cell. This comment used to say "nothing +//! on the render path reaches this layer yet, so it is correct-first by choice", +//! and its own next sentence said what to do when that stopped being true. +//! +//! Measured at 256x256, 1 tile/px, seed 123456, min of 5 after a warm pass, +//! three separate runs agreeing to the second decimal - as the cost of the +//! cliff OVERLAY relative to the terrain sweep in the SAME arm: +//! +//! | arm | terrain | cliffs | overlay | +//! | --- | ---: | ---: | ---: | +//! | TypeScript | 33.10 us/px | 42.41 us/px | 1.28x | +//! | WASM | 8.64 us/px | 9.52 us/px | **1.10x** | +//! +//! So the un-memoized chain costs proportionally LESS here than the memoized +//! one does in the TypeScript. The reason is that the cliff pass is not +//! per-pixel: it walks a 4-tile lattice and touches two tiles per placed cell, +//! so this layer is evaluated a few thousand times against the terrain sweep's +//! 65,536. A memo would be optimising something that is already 10% of the +//! render. +//! +//! **Read the RATIOS, not the microseconds.** Those absolutes are from a run +//! inside vitest, where the TypeScript arm pays #267's per-module transform and +//! the WASM arm does not - `docs/noise/vulcanus-cliffs-NOTES.md` measures the +//! same TypeScript terrain view at 12.68 us/px outside it. A ratio between the +//! two arms would be measuring the harness; a ratio WITHIN one arm cancels it, +//! which is the only reading this table supports. +//! +//! `multioctave_noise`'s docs record what happened the last time a per-call +//! rebuild went unmeasured, which was 20x - that is why this was measured, and +//! the answer this time is that it does not matter. use std::cell::RefCell; use std::collections::BTreeMap; diff --git a/crates/fmw-wasm/src/abi.rs b/crates/fmw-wasm/src/abi.rs index 22f28eae..4b2e407a 100644 --- a/crates/fmw-wasm/src/abi.rs +++ b/crates/fmw-wasm/src/abi.rs @@ -76,7 +76,7 @@ //! +32 f64 sin_vault sine of the vault bearing (the starting one + 180) //! +40 f64 cos_vault //! -//! vulcanus block (248 bytes, so a Vulcanus request is 304) +//! vulcanus block (280 bytes, so a Vulcanus request is 336) //! +0 f64 volcanism_frequency //! +8 f64 volcanism_size //! +16 f64 temperature_bias @@ -89,8 +89,19 @@ //! +72 f64 sulfur_frequency //! +80 f64 sulfur_size //! +88 f64 x 20 trig, as ten (sin, cos) pairs in TRIG ORDER below +//! +248 f64 x 4 cell_query_box: x0, y0, x1, y1 //! ``` //! +//! **The Vulcanus block grew from 248 to 280 when the cliff view landed, with +//! no version bump, and that is the split working rather than a shortcut.** The +//! prefix declares its own block length, [`Status::BadParamsLength`] refuses a +//! writer that disagrees with it, and Fulgora's request did not move a byte. +//! Both halves ship together in this repository and +//! `test/fixtures/wasm-request.v2.json` pins the encoding for each planet, so +//! there is no third party whose old requests could still be in flight. A +//! version bump is for a change to the COMMON prefix, which every planet +//! reads. +//! //! **The trig fields are the unusual part and they are deliberate.** Every angle //! is a per-render constant at every call site, and `starting_spot_at_angle` has //! no f32 narrowing to absorb a one-ULP `sin` difference - which #270 measured @@ -113,7 +124,7 @@ pub const COMMON_BYTES: usize = 56; pub const FULGORA_PARAMS_BYTES: usize = 48; /// Size of Vulcanus's parameter block. -pub const VULCANUS_PARAMS_BYTES: usize = 248; +pub const VULCANUS_PARAMS_BYTES: usize = 280; /// The largest request the module can accept, which is what `request_bytes()` /// reports so a caller can size one buffer for every planet. @@ -187,7 +198,15 @@ pub struct Request { } /// The planet-specific half. +/// +/// `large_enum_variant` is allowed rather than obeyed. Its advice is to box the +/// Vulcanus block, which would put an allocation and a pointer indirection on +/// the render path to save 232 bytes of stack in a function that runs ONCE per +/// request - and it would make [`Request`] no longer `Copy`, which the render +/// loop relies on. The asymmetry is the planets', not a modelling mistake: +/// Vulcanus genuinely needs ten bearings where Fulgora needs two. #[derive(Debug, Clone, Copy, PartialEq)] +#[allow(clippy::large_enum_variant)] pub enum Params { Fulgora(FulgoraParams), Vulcanus(VulcanusParams), @@ -220,6 +239,25 @@ pub struct VulcanusParams { pub sulfur_size: f64, /// Ten `(sin, cos)` pairs, indexed by [`VulcanusBearing`]. pub trig: [(f64, f64); VULCANUS_BEARINGS], + /// The world box to enumerate cliff cells over, as `[x0, y0, x1, y1]`. + /// + /// **Sent rather than derived, and that is not laziness.** A cliff cell + /// paints a 4px block spanning `px - 2 ..= px + 1`, so a cell centred just + /// outside a worker tile still owes that tile pixels - and the halo is + /// ASYMMETRIC and the two directions CROSS: a mark reaching far backwards + /// has to be caught from ahead of the tile. Deriving that here would need + /// the full image's geometry, which the common prefix does not carry and + /// which only the tiled renderer knows. + /// + /// It is also where the tiled-vs-whole cost lives. The cliff pass quantises + /// its enumeration to 32-tile chunks, so one surplus tile of halo can pull + /// in a whole extra chunk per axis - a symmetric 2/2 halo measured 24,336 + /// cliffiness evaluations against 17,424 for the exact 1/2 one, 1.40x for + /// zero pixels of difference. Keeping the exact box on the TypeScript side + /// keeps that in the one place `test/tiledEquality.spec.ts` already guards. + /// + /// For an untiled render this is the pixel box itself. + pub cell_query_box: [f64; 4], } impl VulcanusParams { @@ -296,6 +334,10 @@ pub fn decode(bytes: &[u8]) -> Result { let at = p + 88 + i * 16; *slot = (f64_at(bytes, at), f64_at(bytes, at + 8)); } + let mut cell_query_box = [0.0; 4]; + for (i, slot) in cell_query_box.iter_mut().enumerate() { + *slot = f64_at(bytes, p + 248 + i * 8); + } Params::Vulcanus(VulcanusParams { volcanism_frequency: f64_at(bytes, p), volcanism_size: f64_at(bytes, p + 8), @@ -309,6 +351,7 @@ pub fn decode(bytes: &[u8]) -> Result { sulfur_frequency: f64_at(bytes, p + 72), sulfur_size: f64_at(bytes, p + 80), trig, + cell_query_box, }) } }; @@ -381,11 +424,18 @@ mod tests { /// Not a coincidence worth relying on, but worth pinning: it means the /// v2 split cost Fulgora nothing, and if someone later "tidies" the common /// prefix this says so. + /// + /// **Vulcanus's block grew 304 -> 336 when the cliff view landed**, and no + /// version bump came with it. That is what a per-planet block is FOR: the + /// prefix declares its own length, `BadParamsLength` refuses a writer that + /// disagrees, and Fulgora's request did not move a byte. The two halves ship + /// together and `test/fixtures/wasm-request.v2.json` pins the encoding, so + /// there is no third party to keep compatible. #[test] fn the_split_left_a_fulgora_request_the_size_it_was_in_v1() { assert_eq!(COMMON_BYTES + FULGORA_PARAMS_BYTES, 104); - assert_eq!(COMMON_BYTES + VULCANUS_PARAMS_BYTES, 304); - assert_eq!(REQUEST_BYTES, 304); + assert_eq!(COMMON_BYTES + VULCANUS_PARAMS_BYTES, 336); + assert_eq!(REQUEST_BYTES, 336); } /// Each failure mode has its OWN code, so a caller can tell "you sent an @@ -468,7 +518,7 @@ mod tests { ); } - /// The same for Vulcanus's 31 fields, which is where a duplicated offset is + /// The same for Vulcanus's 35 fields, which is where a duplicated offset is /// most likely and least visible. /// /// A distinct value into every slot, all read back - so a pair of fields @@ -476,8 +526,8 @@ mod tests { #[test] fn no_two_vulcanus_fields_share_an_offset() { let mut b = good_vulcanus(); - // 31 distinct values: 11 scalars then 20 trig components. - for i in 0..31 { + // 35 distinct values: 11 scalars, 20 trig components, 4 box edges. + for i in 0..35 { let at = COMMON_BYTES + i * 8; let v = 100.0f64 + i as f64; b[at..at + 8].copy_from_slice(&v.to_le_bytes()); @@ -503,7 +553,8 @@ mod tests { got.push(sin); got.push(cos); } - let want: Vec = (0..31).map(|i| 100.0 + i as f64).collect(); + got.extend_from_slice(&v.cell_query_box); + let want: Vec = (0..35).map(|i| 100.0 + i as f64).collect(); assert_eq!(got, want); } diff --git a/crates/fmw-wasm/src/render.rs b/crates/fmw-wasm/src/render.rs index 06d0c27b..0524590b 100644 --- a/crates/fmw-wasm/src/render.rs +++ b/crates/fmw-wasm/src/render.rs @@ -5,6 +5,13 @@ //! `runRenderRequest`'s signature does not change. use crate::abi::{self, FulgoraParams, Params, Request, Status, VulcanusBearing, VulcanusParams}; +use fmw_noise::cliffs::catalog::{CLIFF_MAP_COLOR, CLIFF_MARK_BACK_PX, CLIFF_MARK_SIZE_PX}; +use fmw_noise::cliffs::placement::{CliffBands, CliffPlacement}; +use fmw_noise::cliffs::vulcanus_fields::{ + VulcanusCliffFields, VulcanusLavaTiles, VULCANUS_CLIFF_ELEVATION_0, + VULCANUS_CLIFF_ELEVATION_INTERVAL, VULCANUS_CLIFF_SMOOTHING, +}; +use fmw_noise::cliffs::vulcanus_ore_rejection::VulcanusOreRejection; use fmw_noise::eval::ctx::{EvalCtx, ResourceLevers, VulcanusResourceControls}; use fmw_noise::expressions::fulgora_scrap::ScrapControls; use fmw_noise::expressions::fulgora_shared::FulgoraCtx; @@ -33,6 +40,13 @@ pub const VIEW_TERRAIN: u32 = 1; /// model rolls at the right RATE is a separate question with its own gate. pub const VIEW_SCRAP_FOOTPRINT: u32 = 2; +/// Terrain with the cliff footprint painted over it. +/// +/// A composite rather than a bare field, and the only one so far: the cliff +/// overlay has nothing to draw on its own, and the two passes share the whole +/// field DAG below the tile argmax. +pub const VIEW_CLIFFS: u32 = 3; + /// The land colour, `FULGORA_LANDMASK_LAND_RGB` in /// `src/noise/preview/renderFulgoraTerrain.ts`. /// @@ -107,7 +121,7 @@ pub fn render(request: &[u8], out: &mut [u8]) -> Status { ( PLANET_FULGORA, VIEW_LANDMASK | VIEW_TERRAIN | VIEW_SCRAP_FOOTPRINT - ) | (PLANET_VULCANUS, VIEW_TERRAIN) + ) | (PLANET_VULCANUS, VIEW_TERRAIN | VIEW_CLIFFS) ); if !supported { return Status::UnsupportedPlanetOrView; @@ -257,6 +271,81 @@ fn render_vulcanus(req: &Request, p: &VulcanusParams, out: &mut [u8]) { offset += 4; } } + + if req.view == VIEW_CLIFFS { + paint_vulcanus_cliffs(req, p, &ctx, &stack, out); + } +} + +/// Composite the Vulcanus cliff footprint over terrain that is already painted. +/// +/// The whole field DAG is shared with the terrain pass above - the tile resolver +/// the lava rejection asks and the resource regions the ore rejection asks are +/// the SAME `VulcanusStack` the argmax just used. Building a private one here +/// would duplicate the entire chain, which is the mistake the TypeScript's +/// `sharedStack` plumbing exists to avoid. +/// +/// Two rejections run, and both act on the CROSSING rather than as a +/// post-filter: a rejected cell's four edge registers are zeroed, so a surviving +/// neighbour loses the shared one and changes orientation. The post-filter +/// reading predicts 1,662 cases of a survivor keeping such an edge and the game +/// shows 0. +/// +/// **Lava exclusion happens at PLACEMENT, not at paint time.** The Nauvis +/// renderer skips water-coloured pixels as it paints; here the cells never +/// exist, because `tryToAddCliff` runs a real collision test and drops the +/// entity. A paint-time skip would leave the cell in the placement and every +/// spec that scores against `find_entities_filtered` would still count it. +fn paint_vulcanus_cliffs( + req: &Request, + p: &VulcanusParams, + ctx: &EvalCtx, + stack: &VulcanusStack<'_>, + out: &mut [u8], +) { + let fields = VulcanusCliffFields::new(stack, req.seed0); + let lava = VulcanusLavaTiles::new(stack); + let ore = VulcanusOreRejection::new(stack, &ctx.vulcanus_resource_controls); + let placement = CliffPlacement::new( + &fields, + CliffBands { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + reject_at_crossing_stage: true, + ..CliffBands::default() + }, + ) + .with_tile_collision(&lava) + .with_cell_rejection(&ore); + + let [x0, y0, x1, y1] = p.cell_query_box; + let width = req.width as i64; + let height = req.height as i64; + let lo = CLIFF_MARK_BACK_PX; + let hi = CLIFF_MARK_SIZE_PX - CLIFF_MARK_BACK_PX - 1; + + for cell in placement.placed_cells(x0, y0, x1, y1) { + let cx = ((cell.x - req.origin_x) / req.tiles_per_pixel).floor() as i64; + let cy = ((cell.y - req.origin_y) / req.tiles_per_pixel).floor() as i64; + for dy in -lo..=hi { + let y = cy + dy; + if y < 0 || y >= height { + continue; + } + for dx in -lo..=hi { + let x = cx + dx; + if x < 0 || x >= width { + continue; + } + let o = ((y * width + x) * 4) as usize; + out[o] = CLIFF_MAP_COLOR[0]; + out[o + 1] = CLIFF_MAP_COLOR[1]; + out[o + 2] = CLIFF_MAP_COLOR[2]; + out[o + 3] = 255; + } + } + } } #[cfg(test)] diff --git a/src/noise/preview/elevationRenderRequest.ts b/src/noise/preview/elevationRenderRequest.ts index e7f6069c..23031668 100644 --- a/src/noise/preview/elevationRenderRequest.ts +++ b/src/noise/preview/elevationRenderRequest.ts @@ -297,28 +297,34 @@ export function placementMarkSweepBox(req: ElevationRenderRequest): WorldBox { } /** - * The Rust engine's Vulcanus path - terrain only. + * The Rust engine's Vulcanus path - `terrain`, and now `cliffs` too. * * Vulcanus has no ocean and no scrap, so the land mask and the scrap footprint * are meaningless there rather than merely unimplemented; the module rejects - * them by status. Its rock, cliff and resource OVERLAYS are still TypeScript - * (#225 is not finished), so only the bare `terrain` view routes here - the - * composite views below build the stack and paint the overlays as before. + * them by status. Its rock and resource OVERLAYS are still TypeScript (#225 is + * not finished), so `rocks`, `resources` and `all` still build the stack here + * and paint as before. + * + * **`cliffs` is a composite, not a field**, and the module renders both halves: + * the cliff overlay has nothing to draw on its own, and the two passes share the + * whole field DAG below the tile argmax. Sending it as one request is what lets + * the engine share that DAG - splitting it would build the chain twice. * * The copy note on the Fulgora function below applies here identically. */ function renderVulcanusThroughWasm( req: ElevationRenderRequest, engine: EngineExports, + view: "terrain" | "cliffs", ): ElevationRenderResult { const levers = (c: { frequency?: number; size?: number } | undefined) => ({ frequency: c?.frequency ?? 1, size: c?.size ?? 1, }); const controls = req.vulcanusResourceControls; - const view = renderThroughWasm(engine, { + const pixels = renderThroughWasm(engine, { planet: "vulcanus", - view: "terrain", + view, seed0: req.seed0, width: req.width, height: req.height, @@ -332,8 +338,11 @@ function renderVulcanusThroughWasm( vulcanusCoal: levers(controls?.vulcanusCoal), calcite: levers(controls?.calcite), sulfuricAcidGeyser: levers(controls?.sulfuricAcidGeyser), + // Inert for `terrain`; for `cliffs` it is the halo-widened box, computed by + // the same function the TypeScript path passes to `renderVulcanusCliffs`. + cellQueryBox: cliffCellQueryBox(req), }); - const owned = new Uint8ClampedArray(view); + const owned = new Uint8ClampedArray(pixels); return { id: req.id, buffer: owned.buffer, width: req.width, height: req.height }; } @@ -411,10 +420,11 @@ export function runRenderRequest( // Checked BEFORE the TypeScript stack is built, for the reason the // Fulgora branch gives: `makeVulcanusStack` derives seed tables for the // whole biome, crack, climate and elevation chain, and building them only - // to throw them away would be most of the saving. Bare `terrain` only - - // every other Vulcanus view still needs the stack for its overlays. - if (engine !== undefined && req.view === "terrain") { - return renderVulcanusThroughWasm(req, engine); + // to throw them away would be most of the saving. `terrain` and `cliffs` + // only - `rocks`, `resources` and `all` still need the stack here, + // because their overlays have no Rust port yet (#225). + if (engine !== undefined && (req.view === "terrain" || req.view === "cliffs")) { + return renderVulcanusThroughWasm(req, engine, req.view); } // ONE stack for the whole composite. Two things make this pay, and both // are needed: the overlays reuse the field objects terrain built, and diff --git a/src/noise/wasm/engine.wasm b/src/noise/wasm/engine.wasm index da66b079094cc30194e0eea2104a21cbb5363f2f..c0000fc6a2c6198a53cba0a5086744d0a2ba2255 100755 GIT binary patch delta 53070 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 BAK(B0 delta 35750 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 280 bytes with no version bump) added FIVE more, all confirmed caught by its per-edge value check: a transposed x0/x1, four identical edges, a block shifted by one f64, a mismatched y0, and a declared length still saying 248. Its two structural checks - four distinct edges, not inverted on either axis - constrain the FIXTURE rather than catching a break, and the file says so rather than claiming credit for the five." } } diff --git a/test/fixtures/verify-wasm-request.py b/test/fixtures/verify-wasm-request.py index 11c46623..0267d5c1 100644 --- a/test/fixtures/verify-wasm-request.py +++ b/test/fixtures/verify-wasm-request.py @@ -8,8 +8,9 @@ What it can and cannot check: -- It CAN check every offset, the endianness, the field order, and the eleven - Vulcanus scalars against the request that produced them. +- It CAN check every offset, the endianness, the field order, the eleven + Vulcanus scalars and the four cell-query-box edges against the request that + produced them. - It CANNOT reproduce the trig VALUES, because those are V8's `Math.sin` after an `f32` narrowing and Python's libm is a different implementation - which is the whole point of #270 and the reason the trig crosses the boundary as values @@ -29,9 +30,9 @@ ABI_VERSION = 2 COMMON_BYTES = 56 FULGORA_PARAMS_BYTES = 48 -VULCANUS_PARAMS_BYTES = 248 +VULCANUS_PARAMS_BYTES = 280 PLANET = {"fulgora": 0, "vulcanus": 1} -VIEW = {"landmask": 0, "terrain": 1, "scrapFootprint": 2} +VIEW = {"landmask": 0, "terrain": 1, "scrapFootprint": 2, "cliffs": 3} BEARING_NAMES = [ "spawnAshlands", @@ -99,7 +100,7 @@ def decode_fulgora(b, req): def decode_vulcanus(b, req): if len(b) != COMMON_BYTES + VULCANUS_PARAMS_BYTES: - raise AssertionError(f"vulcanus request is {len(b)} bytes, expected 304") + raise AssertionError(f"vulcanus request is {len(b)} bytes, expected 336") decode_common(b, req, VULCANUS_PARAMS_BYTES) p = COMMON_BYTES check("volcanismFrequency", f64(b, p), req["volcanismFrequency"]) @@ -179,7 +180,27 @@ def circular_delta(x, y): raise AssertionError( f"{name}: sits {got:.4f} degrees from ashlands, expected {want:.4f}" ) - return trig + + # The cliff cell query box, appended when the `cliffs` view landed. + # + # The value check below is what catches a wrong offset, a transposition or a + # shifted block - all five planted breaks were caught by it. The two + # structural checks after it are constraints on the FIXTURE rather than + # break-catchers: they refuse a fixture whose edges repeat or whose box is + # inverted, because against such a box the value check would stop + # discriminating. Recorded that way round because "five breaks caught" is + # true of the value check and would be a false claim about the other two. + box = req.get("cellQueryBox") + if box is None: + raise AssertionError("the vulcanus arm must carry an explicit cellQueryBox") + edges = [f64(b, p + 248 + i * 8) for i in range(4)] + for i, key in enumerate(["x0", "y0", "x1", "y1"]): + check(f"cellQueryBox.{key}", edges[i], box[key]) + if len(set(edges)) != 4: + raise AssertionError(f"cellQueryBox edges are not all distinct: {edges}") + if not (edges[0] < edges[2] and edges[1] < edges[3]): + raise AssertionError(f"cellQueryBox is inverted on an axis: {edges}") + return trig, {"x0": edges[0], "y0": edges[1], "x1": edges[2], "y1": edges[3]} def main(): @@ -188,9 +209,12 @@ def main(): fb = bytes(d["fulgora"]["bytes"]) vb = bytes(d["vulcanus"]["bytes"]) ftrig = decode_fulgora(fb, d["fulgora"]["request"]) - vtrig = decode_vulcanus(vb, d["vulcanus"]["request"]) + vtrig, vbox = decode_vulcanus(vb, d["vulcanus"]["request"]) print("fulgora: all fields agree, both bearings unit-norm") - print("vulcanus: all fields agree, ten bearings unit-norm, 1 legitimate duplicate") + print( + "vulcanus: all fields agree, ten bearings unit-norm, 1 legitimate duplicate, " + "cell query box distinct and non-inverted" + ) fixture = { "_comment": ( @@ -201,7 +225,9 @@ def main(): "these bytes. The layout tables are in crates/fmw-wasm/src/abi.rs. v2 replaced v1's " "single fixed 104-byte struct with a common 56-byte prefix plus a per-planet block " "whose length the prefix declares; a Fulgora request is still exactly 104 bytes, and a " - "Vulcanus one is 304. These bytes were checked by an INDEPENDENT Python decoder - a " + "Vulcanus one is 336 - it grew from 304 when the cliffs view added a cell query " + "box, with no version bump, because the prefix declares its own block length and " + "Fulgora's request did not move a byte. These bytes were checked by an INDEPENDENT Python decoder - a " "third implementation written from the layout table, not the TypeScript writer under " "test and not the Rust reader - which agreed on every offset and every scalar field. " "It deliberately does NOT check the trig VALUES: those are V8's Math.sin after an f32 " @@ -215,7 +241,12 @@ def main(): "a swap is the failure that renders a plausible planet with its biomes rotated. Seven " "planted breaks are now caught - a shifted block, two different bearing swaps, a " "sin/cos transposition, an offset sign flip, a wrong declared length and a big-endian " - "field. Regenerating " + "field. The cell query box added five more, all caught by its per-edge value check: a " + "transposed x0/x1, four identical edges, a block shifted by one f64, a mismatched y0, " + "and a declared length still saying 248. Two further checks on that box - four " + "distinct edges, and not inverted on either axis - constrain the FIXTURE rather than " + "catching a break, because against a degenerate box the value check would stop " + "discriminating. Regenerating " "these bytes from the encoder would make the fixture agree with itself and prove " "nothing; if the layout changes, bump ABI_VERSION on both sides and re-verify the " "same way." @@ -234,7 +265,7 @@ def main(): "paramsBytes": VULCANUS_PARAMS_BYTES, "totalBytes": len(vb), "request": d["vulcanus"]["request"], - "decoded": {"trig": vtrig}, + "decoded": {"trig": vtrig, "cellQueryBox": vbox}, "bytes": d["vulcanus"]["bytes"], }, } diff --git a/test/fixtures/wasm-request.v2.json b/test/fixtures/wasm-request.v2.json index 4c0cbc32..94ae51fa 100644 --- a/test/fixtures/wasm-request.v2.json +++ b/test/fixtures/wasm-request.v2.json @@ -1,5 +1,5 @@ { - "_comment": "The WASM render boundary's request encoding at ABI v2, pinned (#225). NOT Factorio ground truth - this is our own ABI, so it has no game version, which is why PROVENANCE.json declares it under notFixtures. It IS read by a spec: test/wasmFulgoraRenderParity.spec.ts asserts src/noise/wasm/request.ts writes exactly these bytes. The layout tables are in crates/fmw-wasm/src/abi.rs. v2 replaced v1's single fixed 104-byte struct with a common 56-byte prefix plus a per-planet block whose length the prefix declares; a Fulgora request is still exactly 104 bytes, and a Vulcanus one is 304. These bytes were checked by an INDEPENDENT Python decoder - a third implementation written from the layout table, not the TypeScript writer under test and not the Rust reader - which agreed on every offset and every scalar field. It deliberately does NOT check the trig VALUES: those are V8's Math.sin after an f32 narrowing, and Python's libm is a different implementation, which is the whole point of #270. It checks the trig block three other ways instead: sin^2+cos^2 = 1 for each pair; exactly one legitimate duplicate pair (the volcano-spot disc sits at the mountains bearing); and - the one that matters - each bearing's angle recovered with atan2 and checked against the OFFSET the Lua gives it from the ashlands bearing, which pins which slot is which. That third check was added because the first two were measured MISSING a planted swap of two bearings: both pairs are still unit-norm, and a swap is the failure that renders a plausible planet with its biomes rotated. Seven planted breaks are now caught - a shifted block, two different bearing swaps, a sin/cos transposition, an offset sign flip, a wrong declared length and a big-endian field. Regenerating these bytes from the encoder would make the fixture agree with itself and prove nothing; if the layout changes, bump ABI_VERSION on both sides and re-verify the same way.", + "_comment": "The WASM render boundary's request encoding at ABI v2, pinned (#225). NOT Factorio ground truth - this is our own ABI, so it has no game version, which is why PROVENANCE.json declares it under notFixtures. It IS read by a spec: test/wasmFulgoraRenderParity.spec.ts asserts src/noise/wasm/request.ts writes exactly these bytes. The layout tables are in crates/fmw-wasm/src/abi.rs. v2 replaced v1's single fixed 104-byte struct with a common 56-byte prefix plus a per-planet block whose length the prefix declares; a Fulgora request is still exactly 104 bytes, and a Vulcanus one is 336 - it grew from 304 when the cliffs view added a cell query box, with no version bump, because the prefix declares its own block length and Fulgora's request did not move a byte. These bytes were checked by an INDEPENDENT Python decoder - a third implementation written from the layout table, not the TypeScript writer under test and not the Rust reader - which agreed on every offset and every scalar field. It deliberately does NOT check the trig VALUES: those are V8's Math.sin after an f32 narrowing, and Python's libm is a different implementation, which is the whole point of #270. It checks the trig block three other ways instead: sin^2+cos^2 = 1 for each pair; exactly one legitimate duplicate pair (the volcano-spot disc sits at the mountains bearing); and - the one that matters - each bearing's angle recovered with atan2 and checked against the OFFSET the Lua gives it from the ashlands bearing, which pins which slot is which. That third check was added because the first two were measured MISSING a planted swap of two bearings: both pairs are still unit-norm, and a swap is the failure that renders a plausible planet with its biomes rotated. Seven planted breaks are now caught - a shifted block, two different bearing swaps, a sin/cos transposition, an offset sign flip, a wrong declared length and a big-endian field. The cell query box added five more, all caught by its per-edge value check: a transposed x0/x1, four identical edges, a block shifted by one f64, a mismatched y0, and a declared length still saying 248. Two further checks on that box - four distinct edges, and not inverted on either axis - constrain the FIXTURE rather than catching a break, because against a degenerate box the value check would stop discriminating. Regenerating these bytes from the encoder would make the fixture agree with itself and prove nothing; if the layout changes, bump ABI_VERSION on both sides and re-verify the same way.", "magic": 1381453126, "abiVersion": 2, "commonBytes": 56, @@ -133,12 +133,12 @@ ] }, "vulcanus": { - "paramsBytes": 248, - "totalBytes": 304, + "paramsBytes": 280, + "totalBytes": 336, "request": { "planet": "vulcanus", "seed0": 1249936247, - "view": "terrain", + "view": "cliffs", "width": 8, "height": 4, "originX": -512.5, @@ -162,6 +162,12 @@ "sulfuricAcidGeyser": { "frequency": 1, "size": 1 + }, + "cellQueryBox": { + "x0": -514.5, + "y0": 255.25, + "x1": -494.5, + "y1": 266.75 } }, "decoded": { @@ -206,6 +212,12 @@ "sin": 0.8855194449424744, "cos": -0.46460235118865967 } + }, + "cellQueryBox": { + "x0": -514.5, + "y0": 255.25, + "x1": -494.5, + "y1": 266.75 } }, "bytes": [ @@ -221,7 +233,7 @@ 0, 0, 0, - 1, + 3, 0, 0, 0, @@ -237,8 +249,8 @@ 0, 0, 0, - 248, - 0, + 24, + 1, 0, 0, 0, @@ -512,7 +524,39 @@ 11, 188, 221, - 191 + 191, + 0, + 0, + 0, + 0, + 0, + 20, + 128, + 192, + 0, + 0, + 0, + 0, + 0, + 232, + 111, + 64, + 0, + 0, + 0, + 0, + 0, + 232, + 126, + 192, + 0, + 0, + 0, + 0, + 0, + 172, + 112, + 64 ] } } diff --git a/test/wasmFulgoraRenderParity.spec.ts b/test/wasmFulgoraRenderParity.spec.ts index 52834c7f..0d9e42e5 100644 --- a/test/wasmFulgoraRenderParity.spec.ts +++ b/test/wasmFulgoraRenderParity.spec.ts @@ -229,7 +229,7 @@ describe("the request encoding is pinned on both sides", () => { }); /** - * A Fulgora request is 104 bytes and a Vulcanus one is 304, so the encoder + * A Fulgora request is 104 bytes and a Vulcanus one is 336, so the encoder * returns a LENGTH rather than the buffer's capacity. * * Asserted because v1 had one size and the two were interchangeable there; @@ -278,7 +278,7 @@ describe("the request encoding is pinned on both sides", () => { calcite: { frequency: 1, size: 1 }, sulfuricAcidGeyser: { frequency: 1, size: 1 }, }), - ).toThrow(/vulcanus request needs 304 bytes/); + ).toThrow(/vulcanus request needs 336 bytes/); }); it("reports a bad request by status rather than trapping", async () => { diff --git a/test/wasmVulcanusRenderParity.spec.ts b/test/wasmVulcanusRenderParity.spec.ts index 71dbb1eb..eebe7146 100644 --- a/test/wasmVulcanusRenderParity.spec.ts +++ b/test/wasmVulcanusRenderParity.spec.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vite-plus/test"; import { withDiffArtifacts } from "./diffArtifacts"; import { decodePng } from "./oracle/decodePng"; +import { planTiles, stitchTiles, type ImageBox } from "../src/noise/preview/tiling"; import { compileEngine, instantiateEngine } from "../src/noise/wasm/engine"; import { CLIFF_MAP_COLOR } from "../src/noise/cliffs/cliffCatalog"; import { ROCK_MAP_COLOR } from "../src/noise/rocks/rockCatalog"; @@ -61,6 +62,16 @@ const MASKED_PX = 118890; */ const DIFFERING_PX = 12423; +/** + * Cliff pixels the overlay paints over terrain, per window, in `WINDOWS` order. + * + * Frozen rather than bounded, for the reason every count in this port is: a + * bound wide enough to be safe is wide enough to swallow a change worth several + * cells. Both renderers produce these, because the comparison above is + * byte-identity. + */ +const CLIFF_PIXELS_PER_WINDOW = [640, 572, 1379, 48]; + let compiled: WebAssembly.Module | undefined; async function engine() { compiled ??= await compileEngine( @@ -171,24 +182,150 @@ describe("the WASM engine renders Vulcanus terrain exactly as the TypeScript doe /** * The engine is not consulted for a view it cannot serve. * - * Vulcanus's rock, cliff and resource overlays are still TypeScript, so a - * composite view must take the TypeScript path and come back with the overlays - * painted. If the dispatch ever routed `all` to the module, this would come - * back as bare terrain - which looks like a rendering regression rather than a - * routing one. + * Vulcanus's rock and resource overlays are still TypeScript, so `rocks`, + * `resources` and `all` must take the TypeScript path and come back with the + * overlays painted. If the dispatch ever routed one of them to the module, + * this would come back missing them - which looks like a rendering regression + * rather than a routing one. + * + * **`cliffs` is deliberately absent from this list**, because it moved. The + * block below is what grades it, and this test would be the thing that went + * red if it had moved without being graded. */ - it("leaves the composite views on the TypeScript path", async () => { + it("leaves the un-ported composite views on the TypeScript path", async () => { const e = await engine(); const w = WINDOWS[0] as Window; - const composite = { ...request(w), view: "all" as const }; - const withEngine = new Uint8ClampedArray(runRenderRequest(composite, e).buffer); - const withoutEngine = new Uint8ClampedArray(runRenderRequest(composite).buffer); - expect(Array.from(withEngine)).toEqual(Array.from(withoutEngine)); + for (const view of ["all", "rocks", "resources"] as const) { + const composite = { ...request(w), view }; + const withEngine = new Uint8ClampedArray(runRenderRequest(composite, e).buffer); + const withoutEngine = new Uint8ClampedArray(runRenderRequest(composite).buffer); + expect(Array.from(withEngine), `${view}: engine vs none`).toEqual(Array.from(withoutEngine)); + } // And `all` really does differ from bare terrain here, so the assertion // above is not comparing two copies of the same picture. + const all = new Uint8ClampedArray(runRenderRequest({ ...request(w), view: "all" }, e).buffer); const terrain = new Uint8ClampedArray(runRenderRequest(request(w), e).buffer); - expect(Array.from(withEngine)).not.toEqual(Array.from(terrain)); + expect(Array.from(all)).not.toEqual(Array.from(terrain)); + }, 300000); +}); + +/** + * Tier 3 for the CLIFF view, which the module renders as a composite: terrain, + * then the cliff footprint over it. + * + * Sent as one request rather than two on purpose - the cliff overlay has nothing + * to draw on its own, and the two passes share the whole field DAG below the + * tile argmax, which splitting would build twice. + */ +describe("the WASM engine renders Vulcanus cliffs exactly as the TypeScript does", () => { + const cliffRequest = (w: Window): ElevationRenderRequest => ({ + ...request(w), + view: "cliffs", + }); + + it("is byte-identical across four windows", async () => { + const e = await engine(); + for (const w of WINDOWS) { + const req = cliffRequest(w); + const wasm = new Uint8ClampedArray(runRenderRequest(req, e).buffer); + const ts = new Uint8ClampedArray(runRenderRequest(req).buffer); + expect(wasm.length, `${w.label}: length`).toBe(w.width * w.height * 4); + expect(Array.from(wasm), `${w.label}: pixels`).toEqual(Array.from(ts)); + } + }, 300000); + + /** + * Anti-vacuity, and the assertion that actually needs making: byte-identity + * between two renders that painted NO cliffs would be satisfied by an engine + * whose cliff pass never ran. + * + * Counted per window rather than over the union, because one cliff-rich window + * would otherwise cover for three empty ones - and the count is frozen, so a + * pass that starts placing more or fewer is a finding rather than noise. + */ + it("paints cliff pixels in every window, and the same ones the TypeScript does", async () => { + const e = await engine(); + const isCliff = (px: Uint8ClampedArray, i: number): boolean => + px[i] === CLIFF_MAP_COLOR[0] && + px[i + 1] === CLIFF_MAP_COLOR[1] && + px[i + 2] === CLIFF_MAP_COLOR[2]; + + const counts: number[] = []; + for (const w of WINDOWS) { + const wasm = new Uint8ClampedArray(runRenderRequest(cliffRequest(w), e).buffer); + const terrain = new Uint8ClampedArray(runRenderRequest(request(w), e).buffer); + let painted = 0; + let overTerrain = 0; + for (let i = 0; i < wasm.length; i += 4) { + if (!isCliff(wasm, i)) continue; + painted++; + // A cliff pixel that was ALREADY that colour in the terrain render + // proves nothing, so count the ones the overlay actually changed. + if (!isCliff(terrain, i)) overTerrain++; + } + expect(painted, `${w.label}: cliff pixels`).toBeGreaterThan(0); + expect(overTerrain, `${w.label}: pixels the overlay changed`).toBeGreaterThan(0); + counts.push(overTerrain); + } + expect(counts).toEqual(CLIFF_PIXELS_PER_WINDOW); + }, 300000); + + /** + * Rendering the window as tiles must reproduce the single whole-image render + * byte for byte, through the ENGINE. + * + * This is the guarantee the cell query box exists to provide, and it is the + * reason that box is sent across the ABI rather than derived inside the + * module: a cliff cell centred just outside a tile still owes it pixels, + * because the 4px block spans `px - 2 ..= px + 1`. + * + * The second arm is what stops this being vacuous. Dropping `fullImage` + * removes the halo - `cliffCellQueryBox` then returns the bare pixel box - and + * the tiles must come back DIFFERENT. Without that arm the test would pass on + * a window where no cliff happens to straddle a seam, which is most of them: + * at 1 tile per pixel the 4px block sits on a 4px lattice and a 32px seam is a + * multiple of 4, so blocks never straddle. `tilesPerPixel: 8` is chosen for + * exactly that reason, not for coverage. + */ + it("tiles to the same bytes as one whole render, and the halo is what makes it so", async () => { + const e = await engine(); + const w = WINDOWS[2] as Window; // tall, coarse - 8 tiles per pixel + const whole = new Uint8ClampedArray(runRenderRequest(cliffRequest(w), e).buffer); + + const full: ImageBox = { + originX: w.originX, + originY: w.originY, + width: w.width, + height: w.height, + tilesPerPixel: w.tilesPerPixel, + }; + const renderTiled = (halo: boolean): Uint8ClampedArray => { + const tiles = planTiles(full, 8).map((t) => { + const out = runRenderRequest( + { + ...cliffRequest(w), + originX: t.originX, + originY: t.originY, + width: t.width, + height: t.height, + ...(halo ? { fullImage: full } : {}), + }, + e, + ); + return { + dx: t.dx, + dy: t.dy, + width: t.width, + height: t.height, + data: new Uint8ClampedArray(out.buffer), + }; + }); + return stitchTiles(full, tiles); + }; + + expect(Array.from(renderTiled(true))).toEqual(Array.from(whole)); + expect(Array.from(renderTiled(false))).not.toEqual(Array.from(whole)); }, 300000); }); From cc3ec3bb39095d5be600151ad87321307264a84a Mon Sep 17 00:00:00 2001 From: Eric J Date: Mon, 24 Aug 2026 12:27:51 -0700 Subject: [PATCH 3/3] Grade the ported connection model against the game (#225) `cliffs::connections` landed with unit tests and no measurement against anything. It is on no render path - it is the model #84's investigation is scored with - so nothing was checking that 445 lines of `Cliff::updateConnections` and `onDestroy` reproduce the behaviour they transcribe. This runs the same three arms `test/cliffConnections.spec.ts` runs, over the same fixture, scored on ORIENTATION against the game's own cliffs: | model | matched | wrong | surplus | missing | | --- | ---: | ---: | ---: | ---: | | `reject_at_crossing_stage` (ships) | 1504 | 21 | 22 | 6 | | `applyCliffs`, lava + ore | 1508 | 18 | 22 | 5 | | `applyCliffs`, no cascade | 1500 | 25 | 22 | 6 | All twelve numbers were written from the TypeScript spec's own header BEFORE running, and all twelve matched on the first run - so `destroy_end`, `is_cliff_connected`, the `onDestroy` cascade and the chunk-border gate are graded rather than asserted. The no-cascade row is what makes the middle row mean something: without it "the apply stage is better" would not distinguish the cascade from the re-staging. The relations are asserted as well as the counts, so the claim survives a re-measure that moves every row. **It is the most expensive test in the crate and `verify:rust` is no longer the cheapest job in the workflow.** Measured: 33s normally, 93s under poison, taking the script from a few seconds to 1m50s. Poison is the expensive half because `crossing_result` turns every lattice edge into a crossing, so far more cells place and the cascade recurses over a dense set. Recorded at the POISONED_TESTS entry and in CLAUDE.md, whose "19s, the cheapest job" line expired here. Still far under the 300s+ test shards, so it does not move the gate wall. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj --- CLAUDE.md | 17 ++- crates/fmw-noise/src/fixtures.rs | 214 ++++++++++++++++++++++++++++++- scripts/verify-rust.sh | 16 +++ 3 files changed, 243 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4baf3b94..86142cbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -555,8 +555,21 @@ Add future phases the same way. Two more things about that job, both measured on its first run (#230): -- **It is 19s**, of which `scripts/verify-rust.sh` is 2s, the pinned-toolchain - sync is 10s and cargo-deny is 1s. It is the cheapest job in the workflow. +- **It WAS 19s and is not any more.** On its first run (#230) it was 19s, of + which `scripts/verify-rust.sh` was 2s, the pinned-toolchain sync 10s and + cargo-deny 1s, and it was the cheapest job in the workflow. #225's cliff half + ended that: `the_apply_stage_beats_the_crossing_stage_on_three_counts_and_ +loses_on_none` is 33s in the normal arm and **93s under poison**, taking the + script alone to **1m50s** locally. Poison is the expensive half because + `crossing_result` turns every lattice edge into a crossing, so far more cells + place and the `onDestroy` cascade recurses over a dense set. + + It is kept because it is the ONLY grading of `cliffs::connections`, a + 445-line module on no render path - without it that port would have unit tests + and no measurement against anything. It is still far under the test shards, so + it does not move the gate wall; it is simply no longer free. Anyone adding a + second fixture test of that shape should re-measure this job first. + - **It runs `bash scripts/verify-rust.sh` directly**, the one deviation from "the YAML names only package.json scripts". That does not reopen the drift the rule guards against, because `verify:rust` _is_ that one line, so the diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index 7e87a390..da907476 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -3365,8 +3365,14 @@ fn puts_every_vulcanus_tile_where_the_game_puts_it_at_a_real_saves_surface_seed( // Phase 5, second half (#225): the cliff stack. // --------------------------------------------------------------------------- -use crate::cliffs::catalog::{cliff_orientation_for_code, CLIFF_ORIENTATION_NAMES}; -use crate::cliffs::placement::{CliffBands, CliffFields, CliffPlacement, PlacedCliffCell}; +use crate::cliffs::catalog::{ + cliff_code_for_orientation, cliff_collision_tile_box, cliff_orientation_for_code, + CLIFF_ORIENTATION_NAMES, +}; +use crate::cliffs::connections::{apply_cliff_connections, ApplyCollision, CliffConnectionOptions}; +use crate::cliffs::placement::{ + CellRejection, CliffBands, CliffFields, CliffPlacement, PlacedCliffCell, TileCollision, +}; use crate::cliffs::vulcanus_fields::{ VulcanusCliffFields, VulcanusLavaTiles, VULCANUS_CLIFF_ELEVATION_0, VULCANUS_CLIFF_ELEVATION_INTERVAL, VULCANUS_CLIFF_SMOOTHING, @@ -3733,3 +3739,207 @@ fn reproduces_the_vulcanus_cliff_fields_at_every_captured_corner() { "the channel gap collapsed to {worst_channel_gap} - has multisample lost its grid?" ); } + +/// `Surface::wouldCollide` for a Vulcanus cliff at the APPLY stage: the +/// orientation's box against the lava tiles, plus the ore removal. +/// +/// The same geometry `tile_collides` drives through the placement pass - only +/// the STAGE it runs at is different, which is the whole subject of +/// [`the_apply_stage_beats_the_crossing_stage_on_three_counts_and_loses_on_none`]. +struct LavaAndOre<'a, 'b> { + lava: VulcanusLavaTiles<'a, 'b>, + ore: VulcanusOreRejection<'a, 'b>, +} + +impl ApplyCollision for LavaAndOre<'_, '_> { + fn collides(&self, orientation: u8, x: f64, y: f64) -> bool { + let Some(code) = cliff_code_for_orientation(orientation) else { + return false; + }; + if let Some(b) = cliff_collision_tile_box(code, x, y) { + for tx in b.left..=b.right { + for ty in b.top..=b.bottom { + if self.lava.collides(tx, ty) { + return true; + } + } + } + } + self.ore.rejects(code, x, y) + } +} + +/// How a set of oriented cells scores against the game's own. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct OrientationScore { + /// Right place, right orientation. + matched: usize, + /// Right place, wrong orientation. + wrong: usize, + /// A cell the game does not have. + surplus: usize, + /// A cell the game has and this does not. + missing: usize, +} + +/// `applyCliffs` against `rejectAtCrossingStage` - the two stages a rejection +/// could act at, scored on ORIENTATION against the game's own cliffs. +/// +/// `rejectAtCrossingStage` zeroes a rejected cell's four edges. The real stage +/// destroys the entity and lets `Cliff::onDestroy` take the facing end of each +/// CONNECTED neighbour - one or two sides, not four, and by rewriting the +/// orientation rather than by clearing a crossing. +/// +/// **This is what grades [`crate::cliffs::connections`] at all.** That module +/// is on no render path - it is the model #84's investigation is scored with - +/// so without this it would be a port with unit tests and no measurement +/// against anything. Here it runs the same three arms the TypeScript's +/// `cliffConnections.spec.ts` runs, over the same fixture, and must reach the +/// same numbers: +/// +/// | model | matched | wrong | surplus | missing | +/// | --- | ---: | ---: | ---: | ---: | +/// | `reject_at_crossing_stage` (ships) | 1504 | 21 | 22 | 6 | +/// | `applyCliffs`, lava + ore | **1508** | **18** | 22 | **5** | +/// | `applyCliffs`, no cascade | 1500 | 25 | 22 | 6 | +/// +/// The apply stage is better on three counts and worse on none, and the +/// no-cascade row is what says the CASCADE rather than the re-staging is doing +/// it - without that arm "the apply stage is better" would not distinguish the +/// two explanations. +/// +/// **It is deliberately not what the renderer runs.** On POSITION alone the two +/// models are a wash - 1526 against 1525 of 1531 - and the renderer paints +/// positions and ignores orientation. Adopting it there means running the pass +/// over a padded query and filtering afterwards, which changes the geometry the +/// tiled-equals-whole tests pin. That is worth doing on its own evidence, not +/// smuggled in for one cell. +/// +/// The 64-tile pad is a HALO, not a margin: a cell on the query's outer chunk +/// ring reads its neighbour across the boundary, and the `onDestroy` cascade can +/// reach further still. +#[test] +fn the_apply_stage_beats_the_crossing_stage_on_three_counts_and_loses_on_none() { + let fixture = load_captured_at( + "test/fixtures/oracle-vulcanus-cliff-entities.seed123456.json", + "2.1.12", + ); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let seed0 = fixture.get("seed").as_f64() as u32; + + let ctx = crate::eval::ctx::EvalCtx::new(seed0); + let base = VulcanusBase::with_host_trig(&ctx); + let biomes = base.biomes_with_host_trig(); + let stack = VulcanusStack::with_host_trig(&base, &biomes); + let fields = VulcanusCliffFields::new(&stack, seed0); + let lava = VulcanusLavaTiles::new(&stack); + let ore = VulcanusOreRejection::new(&stack, &ctx.vulcanus_resource_controls); + let apply = LavaAndOre { + lava: VulcanusLavaTiles::new(&stack), + ore: VulcanusOreRejection::new(&stack, &ctx.vulcanus_resource_controls), + }; + let bands = CliffBands { + elevation0: VULCANUS_CLIFF_ELEVATION_0, + interval: VULCANUS_CLIFF_ELEVATION_INTERVAL, + smoothing: VULCANUS_CLIFF_SMOOTHING, + ..CliffBands::default() + }; + + let mut totals = [OrientationScore::default(); 3]; + for case in fixture.get("cases").as_array() { + let r = case.get("region"); + let (x0, y0) = (r.get("x0").as_f64(), r.get("y0").as_f64()); + let (x1, y1) = (r.get("x1").as_f64(), r.get("y1").as_f64()); + + // The game's own cliffs in this region, by position, carrying the + // orientation it gave each one. + let mut game: BTreeMap<(u64, u64), u8> = BTreeMap::new(); + for e in case.get("cliffs").as_array() { + if e.get("name").as_str() != "cliff-vulcanus" { + continue; + } + let (x, y) = (e.get("x").as_f64(), e.get("y").as_f64()); + if x < x0 || x >= x1 || y < y0 || y >= y1 { + continue; + } + let want = e.get("orientation").as_str(); + if let Some(id) = CLIFF_ORIENTATION_NAMES.iter().position(|n| *n == want) { + game.insert((x.to_bits(), y.to_bits()), id as u8); + } + } + + // Arm 0: the shipping model, rejecting at the crossing stage. + let shipped: BTreeMap<(u64, u64), u8> = CliffPlacement::new( + &fields, + CliffBands { + reject_at_crossing_stage: true, + ..bands + }, + ) + .with_tile_collision(&lava) + .with_cell_rejection(&ore) + .placed_cells(x0, y0, x1, y1) + .iter() + .filter_map(|c| cliff_orientation_for_code(c.code).map(|id| (cell_key(c), id))) + .collect(); + + // Arms 1 and 2: the crossing field and the repair alone, over a 64-tile + // halo, with the rejection moved to the apply stage. + let raw = CliffPlacement::new(&fields, bands).placed_cells( + x0 - 64.0, + y0 - 64.0, + x1 + 64.0, + y1 + 64.0, + ); + let staged = |no_cascade: bool| -> BTreeMap<(u64, u64), u8> { + apply_cliff_connections( + &raw, + &CliffConnectionOptions { + collides: Some(&apply), + no_cascade, + ..Default::default() + }, + ) + .iter() + .filter(|c| c.x >= x0 && c.x < x1 && c.y >= y0 && c.y < y1) + .map(|c| ((c.x.to_bits(), c.y.to_bits()), c.orientation)) + .collect() + }; + + for (i, port) in [shipped, staged(false), staged(true)].iter().enumerate() { + for (k, id) in port { + match game.get(k) { + None => totals[i].surplus += 1, + Some(want) if want == id => totals[i].matched += 1, + Some(_) => totals[i].wrong += 1, + } + } + totals[i].missing += game.keys().filter(|k| !port.contains_key(*k)).count(); + } + } + + let row = |matched, wrong, surplus, missing| OrientationScore { + matched, + wrong, + surplus, + missing, + }; + assert_eq!( + totals[0], + row(1504, 21, 22, 6), + "rejectAtCrossingStage (ships)" + ); + assert_eq!(totals[1], row(1508, 18, 22, 5), "applyCliffs, lava + ore"); + assert_eq!(totals[2], row(1500, 25, 22, 6), "applyCliffs, no cascade"); + + // Stated as relations too, so the claim survives a re-measure that moves + // every row: better on three counts, worse on none. + assert!(totals[1].matched > totals[0].matched); + assert!(totals[1].wrong < totals[0].wrong); + assert!(totals[1].missing < totals[0].missing); + assert_eq!(totals[1].surplus, totals[0].surplus); + // And on POSITION alone it is one cell, which is why the renderer is left + // alone. The whole gain is in orientation. + assert_eq!(totals[1].matched + totals[1].wrong, 1526); + assert_eq!(totals[0].matched + totals[0].wrong, 1525); +} diff --git a/scripts/verify-rust.sh b/scripts/verify-rust.sh index 66ae2f04..13695bd4 100755 --- a/scripts/verify-rust.sh +++ b/scripts/verify-rust.sh @@ -137,6 +137,22 @@ POISONED_TESTS=( cliffs::placement::tests::a_crossing_needs_a_band_a_sign_and_the_cliffiness_gate cliffs::placement::tests::the_sweep_clears_the_first_clearable_edge_in_l_t_r_b_order cliffs::connections::tests::connection_is_a_parity_test_and_not_a_do_they_touch_test + + # The only grading of `cliffs::connections` against anything - that module is + # on no render path, so without this it would be a 445-line port with unit + # tests and no measurement. + # + # It is also the most expensive test in the crate by a wide margin, and that + # is worth knowing before anyone adds a second like it. Measured: 33s in the + # normal arm and 93s under poison, which took this whole script from a few + # seconds to **1m50s** wall. Poisoning is the expensive half because + # `crossing_result` turns every lattice edge into a crossing, so far more + # cells place and the onDestroy cascade recurses over a dense set. + # + # `verify:rust` is therefore NO LONGER the cheapest job in the workflow. It is + # still far under the test shards (300s+), so it does not move the gate wall - + # but the line in CLAUDE.md calling it 19s and the cheapest job expired here. + fixtures::the_apply_stage_beats_the_crossing_stage_on_three_counts_and_loses_on_none ) for t in "${POISONED_TESTS[@]}"; do if ! grep -q "^test ${t} \.\.\. FAILED" <<<"$POISON_OUT"; then