diff --git a/CLAUDE.md b/CLAUDE.md index 239b6a0..7b97623 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1199,6 +1199,8 @@ when this file does not. Get it with `shasum -a 256 src/noise/wasm/engine.wasm`. | 4 (#224) | the rest of Fulgora: masks, roads, ruins, scrap, the tile catalog and `fulgora_stack` | done | | 5 (#225) | Vulcanus end to end - terrain, cliffs, rocks, resources. **Every Vulcanus view the panel offers renders through the engine** (not `elevation` - see below). | done | | 6 (#226) | Nauvis - every expression, the TERRAIN render, all FIVE overlays and the `all` composite. The `elevation` view is ported too, as of #227 | done | +| 7 (#227) | delete the ported TypeScript under `src/noise/` | done | +| 8 (#363) | Fulgora's `resources` and `all` composites, so **every planet's DEFAULT view renders through the engine** | done | Phase 6 has ported every Nauvis _expression_: `nauvis_shared`, `elevation_lakes` (which also yields `elevation_island` - the same tree at @@ -1789,13 +1791,45 @@ below. What stays here is the part that is a RULE rather than a record. **The request layout is at v2 and is per-planet.** A 56-byte common prefix declares `params_bytes`, then a per-planet block follows. Fulgora's request is -104 bytes; Vulcanus's is 368. +120 bytes; Vulcanus's is 368; Nauvis's is 568 and is the largest, so +`REQUEST_BYTES` is Nauvis's. **A planet block can grow with NO version bump, and that is the split working.** -The Vulcanus block has grown three times (248 -> 280 -> 312 -> 368) and -Fulgora's has not moved a byte. `BadParamsLength` refuses a writer whose -declared length disagrees. **A version bump is for a change to the COMMON -prefix**, which every planet reads. +The Vulcanus block has grown three times (248 -> 280 -> 312 -> 368), and +Fulgora's grew for the first time in #363 (48 -> 64, so the request went 104 -> 120) when the scrap overlay needed `control:scrap:frequency` and `:size` to +cross. `BadParamsLength` refuses a writer whose declared length disagrees. **A +version bump is for a change to the COMMON prefix**, which every planet reads. + +**The Fulgora block is append-only, and the two scrap sliders sit AFTER the +trig rather than beside the two island sliders.** Grouping them with the other +controls would have moved the trig block, which every existing reader already +knows the offsets of. `test/fixtures/verify-wasm-request.py` - the third +implementation, neither the writer nor the Rust reader - checks both new +offsets, and a planted swap of the two is one of the breaks confirmed caught. + +**The module does NOT default the scrap sliders**, and `FulgoraParams`'s +`Default` gives 0 rather than the neutral 1 on purpose, so a writer that forgot +them renders visibly wrong rather than plausibly right. The single place an +absent slider becomes 1 is `writeFulgoraParams` in `src/noise/wasm/request.ts`. +Do not add a second. + +**`VIEW_SCRAP_FOOTPRINT` is not the scrap overlay, and #363's issue body was +written believing it was.** The footprint view paints every tile where the +probability is positive; the `all` composite paints the subset a placement ROLL +accepts. Measured over a 128x128 window at seed 123456: 708 footprint tiles +against 177 placed, so substituting one for the other moves 531 pixels. The +footprint is deliberately not a roll, because diffing rolled pixels against the +game's drawn pixels measures the salt rather than the model - +`crates/fmw-wasm/src/render.rs` says so at the constant. + +**`control:scrap:frequency` above neutral does not move the picture**, measured +2026-08-31 on a 64x64 window at (-500, 3000), seed 123456: `(4, 1)` is +byte-identical to `(1, 1)` at 149 scrap pixels, while `(0.25, 1)` gives 104. +`size` moves both ways - 194 at 3, 118 at 0.25. This is not a curiosity: a +parity test that moves `frequency` UPWARD grades nothing, and one written that +way was measured passing against a module that ignored the field outright. +`test/wasmFulgoraRenderParity.spec.ts` moves one slider at a time and pins the +dead zone. **Nauvis's block landed at 64 bytes with no bump and has since grown five times** - 96 for the tree overlay's four levers, 144 for the rock overlay's two diff --git a/crates/fmw-wasm/src/abi.rs b/crates/fmw-wasm/src/abi.rs index 3008572..1019cf2 100644 --- a/crates/fmw-wasm/src/abi.rs +++ b/crates/fmw-wasm/src/abi.rs @@ -70,13 +70,15 @@ //! 48 f64 tiles_per_pixel //! 56 = COMMON_BYTES //! -//! fulgora block (48 bytes, so a Fulgora request is 104 - unchanged from v1) +//! fulgora block (64 bytes, so a Fulgora request is 120) //! +0 f64 islands_frequency //! +8 f64 islands_size //! +16 f64 sin_start sine of the starting bearing //! +24 f64 cos_start //! +32 f64 sin_vault sine of the vault bearing (the starting one + 180) //! +40 f64 cos_vault +//! +48 f64 scrap_frequency read by the scrap overlay only +//! +56 f64 scrap_size read by the scrap overlay only //! //! vulcanus block (312 bytes, so a Vulcanus request is 368) //! +0 f64 volcanism_frequency @@ -123,11 +125,13 @@ //! crude-oil, uranium-ore //! ``` //! -//! **The Vulcanus block has grown twice with no version bump - 248 to 280 for -//! the cliff view, 280 to 312 for the rock and resource overlays - 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 has not moved a byte through either change. +//! **Three blocks have now grown with no version bump, which is the split +//! working rather than a shortcut.** Vulcanus went 248 to 280 for the cliff +//! view and 280 to 312 for the rock and resource overlays; Fulgora went 48 to +//! 64 for the scrap overlay (#363), which is the first time its block has +//! moved since v1. The prefix declares its own block length, +//! [`Status::BadParamsLength`] refuses a writer that disagrees with it, and no +//! other planet's request moved a byte through any of the three. //! 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 @@ -161,7 +165,7 @@ pub const ABI_VERSION: u32 = 2; pub const COMMON_BYTES: usize = 56; /// Size of Fulgora's parameter block. -pub const FULGORA_PARAMS_BYTES: usize = 48; +pub const FULGORA_PARAMS_BYTES: usize = 64; /// Size of Vulcanus's parameter block. pub const VULCANUS_PARAMS_BYTES: usize = 312; @@ -310,6 +314,17 @@ pub struct FulgoraParams { pub cos_start: f64, pub sin_vault: f64, pub cos_vault: f64, + /// `control:scrap:frequency`, wire value. Neutral is 1. + /// + /// **Sent rather than defaulted, and `Default` gives 0 rather than 1 here + /// deliberately.** A zero is not the neutral slider, so a caller that + /// forgets to write these fields gets a visibly wrong render rather than a + /// plausible one. The one constructor the app uses fills them from the + /// request, defaulting to 1 there where the neutral value belongs. + pub scrap_frequency: f64, + /// `control:scrap:size`, wire value. Neutral is 1. See + /// [`FulgoraParams::scrap_frequency`]. + pub scrap_size: f64, } /// Vulcanus's block. @@ -609,6 +624,8 @@ pub fn decode(bytes: &[u8]) -> Result { cos_start: f64_at(bytes, p + 24), sin_vault: f64_at(bytes, p + 32), cos_vault: f64_at(bytes, p + 40), + scrap_frequency: f64_at(bytes, p + 48), + scrap_size: f64_at(bytes, p + 56), }), PLANET_NAUVIS => Params::Nauvis(NauvisParams { water_level: f64_at(bytes, p), @@ -925,7 +942,7 @@ mod tests { /// 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 + FULGORA_PARAMS_BYTES, 120); assert_eq!(COMMON_BYTES + VULCANUS_PARAMS_BYTES, 368); // **`REQUEST_BYTES` is NAUVIS's now.** It equalled Vulcanus's request // for three planets and this assertion read `368` the whole time, diff --git a/crates/fmw-wasm/src/render.rs b/crates/fmw-wasm/src/render.rs index 8b346f4..4db6463 100644 --- a/crates/fmw-wasm/src/render.rs +++ b/crates/fmw-wasm/src/render.rs @@ -7,6 +7,7 @@ use crate::abi::{ self, FulgoraParams, NauvisParams, Params, Request, Status, VulcanusBearing, VulcanusParams, }; +use core::cell::RefCell; use fmw_noise::cliffs::catalog::{modified_elevation_interval, CliffControls, CliffSettings}; use fmw_noise::cliffs::catalog::{CLIFF_MAP_COLOR, CLIFF_MARK_BACK_PX, CLIFF_MARK_SIZE_PX}; use fmw_noise::cliffs::fields::{CliffFieldParams, NauvisCliffFields}; @@ -31,7 +32,7 @@ use fmw_noise::expressions::starting_spot_at_angle::AngleTrig; use fmw_noise::expressions::vulcanus_biomes::VulcanusBiomes; use fmw_noise::expressions::vulcanus_stack::{VulcanusBase, VulcanusStack}; use fmw_noise::placement::roll::PLACEMENT_MARK_RADIUS_PX; -use fmw_noise::resources::fulgora_catalog::SCRAP_MAP_COLOR; +use fmw_noise::resources::fulgora_catalog::{FulgoraScrapPlacement, SCRAP_MAP_COLOR}; use fmw_noise::resources::nauvis_catalog::NAUVIS_RESOURCE_CATALOG; use fmw_noise::resources::nauvis_oil::{crude_oil, NauvisOilPlacement}; use fmw_noise::resources::resolve_resource::{ @@ -239,7 +240,7 @@ pub fn render(request: &[u8], out: &mut [u8]) -> Status { (req.planet, req.view), ( PLANET_FULGORA, - VIEW_LANDMASK | VIEW_TERRAIN | VIEW_SCRAP_FOOTPRINT + VIEW_LANDMASK | VIEW_TERRAIN | VIEW_SCRAP_FOOTPRINT | VIEW_RESOURCES | VIEW_ALL ) | ( PLANET_VULCANUS, VIEW_TERRAIN | VIEW_CLIFFS | VIEW_ROCKS | VIEW_RESOURCES | VIEW_ALL @@ -295,20 +296,44 @@ fn render_fulgora(req: &Request, p: &FulgoraParams, out: &mut [u8]) { islands_frequency: p.islands_frequency, islands_size: p.islands_size, }; + let controls = ScrapControls { + frequency: p.scrap_frequency, + size: p.scrap_size, + }; // ONE stack for the whole window, so the four Voronoi point caches are warm // across it - the same reason the TypeScript renderer shares a stack. - let mut stack = FulgoraStack::new( + // + // Behind a `RefCell` because the composite views need it in two places at + // once: the terrain pass walks it directly, and the scrap placement reads + // it through `PlacementSource::probability`, which gets `&self`. The two + // never hold a borrow at the same time - the terrain pass drops its borrow + // before asking `placed()`. + let stack = RefCell::new(FulgoraStack::new( &ctx, - &ScrapControls::default(), + &controls, AngleTrig::new(p.sin_start, p.cos_start), AngleTrig::new(p.sin_vault, p.cos_vault), - ); + )); + + // Built for every view rather than only the composites. It is lazy - a + // `PlacementSet` resolves a chunk on first ask and caches it - so a terrain + // render that never calls `placed()` pays for an empty `BTreeMap`. + let placement = FulgoraScrapPlacement::new(&stack); + let placed = placement.placement_set(); + + // Fulgora has no cliffs and no rocks, so `all` and `resources` are the same + // picture: terrain with the scrap overlay on it. Both codes are served + // because the dispatcher and the panel each ask with a different one, and + // collapsing them here rather than at the call site keeps the two answers + // provably identical. + let overlay = matches!(req.view, VIEW_RESOURCES | VIEW_ALL); + let mut offset = 0usize; for py in 0..req.height { let wy = req.origin_y + f64::from(py) * req.tiles_per_pixel; for px in 0..req.width { let wx = req.origin_x + f64::from(px) * req.tiles_per_pixel; - let fields = stack.eval(wx, wy); + let fields = stack.borrow_mut().eval(wx, wy); let color = match req.view { VIEW_TERRAIN => tile_color(fields.tile()), VIEW_SCRAP_FOOTPRINT => { @@ -318,6 +343,16 @@ fn render_fulgora(req: &Request, p: &FulgoraParams, out: &mut [u8]) { [0, 0, 0] } } + _ if overlay => { + // Terrain first, then the overlay paints over it - the + // TypeScript's order, where `renderFulgoraResources` + // mutates the terrain image in place. + if placed.placed(wx, wy) { + SCRAP_MAP_COLOR + } else { + tile_color(fields.tile()) + } + } _ => match ocean_tile(&fields.elevation) { None => LAND, Some(Ocean::Shallow) => SHALLOW, diff --git a/src/noise/preview/elevationRenderRequest.ts b/src/noise/preview/elevationRenderRequest.ts index a2e4bd1..bb5a8ea 100644 --- a/src/noise/preview/elevationRenderRequest.ts +++ b/src/noise/preview/elevationRenderRequest.ts @@ -362,7 +362,7 @@ function renderVulcanusThroughWasm( function renderFulgoraThroughWasm( req: ElevationRenderRequest, engine: EngineExports, - which: "landmask" | "terrain", + which: "landmask" | "terrain" | "resources" | "all", ): ElevationRenderResult { const view = renderThroughWasm(engine, { view: which, @@ -374,6 +374,11 @@ function renderFulgoraThroughWasm( tilesPerPixel: req.tilesPerPixel, islandsFrequency: req.fulgoraIslandControls?.frequency ?? 1, islandsSize: req.fulgoraIslandControls?.size ?? 1, + // Passed through UNDEFAULTED on purpose. `writeFulgoraParams` is the one + // place that turns an absent slider into the neutral 1; adding a `?? 1` + // here would be a second copy of that constant, and the two could drift. + scrapFrequency: req.fulgoraScrapControls?.frequency, + scrapSize: req.fulgoraScrapControls?.size, }); const owned = new Uint8ClampedArray(view); return { id: req.id, buffer: owned.buffer, width: req.width, height: req.height }; @@ -640,11 +645,26 @@ export function runRenderRequest( throw new Error(unsupportedPair(planet, view)); } if (planet === "fulgora") { - // The one path the Rust engine serves so far (#223). Checked BEFORE the + // Every view an ordinary user can reach, as of #363. Checked BEFORE the // TypeScript stack is built, because `makeFulgoraStack` derives seed // tables for eight multioctave fields, and building them only to throw // them away would be most of the saving. - if (engine !== undefined && (view === "landmask" || view === "terrain")) { + // + // `"all"` is the one that matters: `ElevationPreviewPanel.vue`'s + // `effectiveView` returns it for every non-dev-mode Fulgora request, so + // until this line it was the DEFAULT view that fell to TypeScript while + // the two dev-mode views went to the engine. `"resources"` is the same + // picture - Fulgora has no cliffs and no rocks - and the module collapses + // the two codes onto one arm rather than the caller doing it. + // + // The TypeScript arm below is NOT dead. It still serves an engineless + // request, which is what `test/tiledEquality.spec.ts` and the no-engine + // half of the parity specs run, and it is the thing the engine is graded + // against. + if ( + engine !== undefined && + (view === "landmask" || view === "terrain" || view === "resources" || view === "all") + ) { return renderFulgoraThroughWasm(req, engine, view); } // Fulgora has a resources overlay now; it still has no cliffs and no diff --git a/src/noise/wasm/engine.wasm b/src/noise/wasm/engine.wasm index 26ab04e..709b088 100755 Binary files a/src/noise/wasm/engine.wasm and b/src/noise/wasm/engine.wasm differ diff --git a/src/noise/wasm/request.ts b/src/noise/wasm/request.ts index 56f9990..0736ad0 100644 --- a/src/noise/wasm/request.ts +++ b/src/noise/wasm/request.ts @@ -21,7 +21,8 @@ * v1 was one fixed 104-byte struct with Fulgora's two island sliders and four * trig values baked in. Vulcanus needs 31 more `f64`, and Nauvis will need its * own set again, so the reserved word became `params_bytes` and each planet - * declares its own block. A Fulgora request is still exactly 104 bytes. + * declares its own block. A Fulgora request was 104 bytes until #363 added the + * two scrap sliders, and is 120 now. */ import { f32 } from "../eval/f32"; @@ -37,7 +38,7 @@ export const ABI_VERSION = 2; export const COMMON_BYTES = 56; /** Must equal `fmw_wasm::abi::FULGORA_PARAMS_BYTES`. */ -export const FULGORA_PARAMS_BYTES = 48; +export const FULGORA_PARAMS_BYTES = 64; /** Must equal `fmw_wasm::abi::VULCANUS_PARAMS_BYTES`. */ export const VULCANUS_PARAMS_BYTES = 312; @@ -127,6 +128,21 @@ export interface FulgoraRenderRequest extends CommonRenderRequest { readonly planet?: "fulgora"; readonly islandsFrequency: number; readonly islandsSize: number; + /** + * `control:scrap:frequency` and `control:scrap:size`, wire values, neutral + * at 1. + * + * **Optional, for the reason `planet` is**, so the existing Fulgora call + * sites need no change. `writeFulgoraParams` substitutes the neutral 1. + * + * **The module does NOT default these**, which is why the substitution has to + * happen and why it happens exactly once. `FulgoraParams` reads both straight + * into `ScrapControls`, so an unwritten field would encode 0 - a real slider + * setting, and not the neutral one. One encoder-side default is the whole + * defence, so do not add a second at a call site. + */ + readonly scrapFrequency?: number; + readonly scrapSize?: number; } /** One resource autoplace control's two sliders. Richness is not read. */ @@ -390,7 +406,7 @@ export function vulcanusBearingTrig(seed0: number): { sin: number; cos: number } * Write a request into `target`, returning the bytes written. * * The return value is the LENGTH of this request, not the buffer's capacity - - * a Fulgora request is 104 bytes and a Vulcanus one is 368, and the module is + * a Fulgora request is 120 bytes and a Vulcanus one is 368, and the module is * handed the length so it can check the declared block against what arrived. */ export function encodeRenderRequest(target: Uint8Array, req: WasmRenderRequest): number { @@ -533,6 +549,10 @@ function writeFulgoraParams(view: DataView, req: FulgoraRenderRequest): void { view.setFloat64(p + 24, trig.cosStart, true); view.setFloat64(p + 32, trig.sinVault, true); view.setFloat64(p + 40, trig.cosVault, true); + // The ONE place an absent scrap slider becomes the neutral 1. See the field + // docs on `FulgoraRenderRequest`. + view.setFloat64(p + 48, req.scrapFrequency ?? 1, true); + view.setFloat64(p + 56, req.scrapSize ?? 1, true); } function writeVulcanusParams(view: DataView, req: VulcanusRenderRequest): void { diff --git a/test/fixtures/verify-wasm-request.py b/test/fixtures/verify-wasm-request.py index eef8885..33937dd 100644 --- a/test/fixtures/verify-wasm-request.py +++ b/test/fixtures/verify-wasm-request.py @@ -29,7 +29,7 @@ MAGIC = 0x52574D46 ABI_VERSION = 2 COMMON_BYTES = 56 -FULGORA_PARAMS_BYTES = 48 +FULGORA_PARAMS_BYTES = 64 VULCANUS_PARAMS_BYTES = 312 PLANET = {"fulgora": 0, "vulcanus": 1, "nauvis": 2} VIEW = { @@ -98,12 +98,25 @@ def decode_common(b, req, params_bytes): def decode_fulgora(b, req): - if len(b) != COMMON_BYTES + FULGORA_PARAMS_BYTES: - raise AssertionError(f"fulgora request is {len(b)} bytes, expected 104") + want = COMMON_BYTES + FULGORA_PARAMS_BYTES + if len(b) != want: + raise AssertionError(f"fulgora request is {len(b)} bytes, expected {want}") decode_common(b, req, FULGORA_PARAMS_BYTES) p = COMMON_BYTES check("islandsFrequency", f64(b, p), req["islandsFrequency"]) check("islandsSize", f64(b, p + 8), req["islandsSize"]) + # The two scrap sliders (#363), which grew this block from 48 to 64. They + # sit AFTER the trig rather than beside the island sliders, because the + # block is append-only - putting them with the other controls would move + # the trig and break every reader that already knows its offsets. + # + # `req` may omit them, because `FulgoraRenderRequest` makes them optional + # and `writeFulgoraParams` substitutes the neutral 1. This decoder + # substitutes the same 1, from the layout table rather than from the + # writer, so a writer that defaulted to 0 - the `Default` value on the Rust + # side, and the plausible mistake - fails here. + check("scrapFrequency", f64(b, p + 48), req.get("scrapFrequency", 1)) + check("scrapSize", f64(b, p + 56), req.get("scrapSize", 1)) trig = { "sinStart": f64(b, p + 16), "cosStart": f64(b, p + 24), @@ -496,11 +509,13 @@ def main(): "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 " + "whose length the prefix declares. A Fulgora request is 120 bytes - it was 104 until " + "#363 appended the two scrap sliders for the scrap overlay - and a " "Vulcanus one is 368 - it grew from 304 to 336 when the cliffs view added a cell query " "box and from 336 to 368 when the rock and resource overlays added a placement sweep " - "box, neither time with a version bump, because the prefix declares its own block " - "length and Fulgora's request has not moved a byte through either. These bytes were checked by an INDEPENDENT Python decoder - a " + "box. None of the three bumped the version, because the prefix declares its own block " + "length, so a planet's block can grow without moving any other planet's request. " + "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 " diff --git a/test/fixtures/wasm-request.v2.json b/test/fixtures/wasm-request.v2.json index 156be0f..aeb2db7 100644 --- a/test/fixtures/wasm-request.v2.json +++ b/test/fixtures/wasm-request.v2.json @@ -1,11 +1,11 @@ { - "_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 368 - it grew from 304 to 336 when the cliffs view added a cell query box and from 336 to 368 when the rock and resource overlays added a placement sweep box, neither time with a version bump, because the prefix declares its own block length and Fulgora's request has not moved a byte through either. 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. The placement sweep box added five more planted breaks, each run rather than listed: the cliff box written into both slots, the two boxes swapped, a block shifted by one f64, one edge wrong, and a declared length still saying 280. All five are caught by the per-edge value check. A SIXTH is not, and is why the halo is also checked for symmetry: a halo one tile wider on the low x side than the high one, with the request edited to agree, passes every value check - and the placement halo really is symmetric about the pixel box, where the cliff halo is not, which is the whole reason there are two boxes rather than one. The no-coinciding-edge check caught none of the six and is a fixture constraint like the distinctness ones. 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 120 bytes - it was 104 until #363 appended the two scrap sliders for the scrap overlay - and a Vulcanus one is 368 - it grew from 304 to 336 when the cliffs view added a cell query box and from 336 to 368 when the rock and resource overlays added a placement sweep box. None of the three bumped the version, because the prefix declares its own block length, so a planet's block can grow without moving any other planet's request. 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. The placement sweep box added five more planted breaks, each run rather than listed: the cliff box written into both slots, the two boxes swapped, a block shifted by one f64, one edge wrong, and a declared length still saying 280. All five are caught by the per-edge value check. A SIXTH is not, and is why the halo is also checked for symmetry: a halo one tile wider on the low x side than the high one, with the request edited to agree, passes every value check - and the placement halo really is symmetric about the pixel box, where the cliff halo is not, which is the whole reason there are two boxes rather than one. The no-coinciding-edge check caught none of the six and is a fixture constraint like the distinctness ones. 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, "fulgora": { - "paramsBytes": 48, - "totalBytes": 104, + "paramsBytes": 64, + "totalBytes": 120, "request": { "seed0": 2967702466, "view": "terrain", @@ -54,7 +54,7 @@ 0, 0, 0, - 48, + 64, 0, 0, 0, @@ -129,7 +129,23 @@ 107, 183, 237, - 191 + 191, + 0, + 0, + 0, + 0, + 0, + 0, + 240, + 63, + 0, + 0, + 0, + 0, + 0, + 0, + 240, + 63 ] }, "vulcanus": { diff --git a/test/wasmFulgoraRenderParity.spec.ts b/test/wasmFulgoraRenderParity.spec.ts index e791d5b..962ead7 100644 --- a/test/wasmFulgoraRenderParity.spec.ts +++ b/test/wasmFulgoraRenderParity.spec.ts @@ -21,6 +21,7 @@ import { renderFulgoraLandMask, renderFulgoraTerrain, } from "../src/noise/preview/renderFulgoraTerrain"; +import { renderFulgoraResources } from "../src/noise/preview/renderFulgoraResources"; import { surfaceSeedForPlanet } from "../src/model/planetSurfaceSeed"; /** @@ -109,7 +110,13 @@ const WINDOWS: readonly Window[] = [ }, ]; -function typescriptPixels(w: Window, view: "landmask" | "terrain"): Uint8ClampedArray { +type FulgoraView = "landmask" | "terrain" | "resources" | "all"; + +function typescriptPixels( + w: Window, + view: FulgoraView, + scrap?: { readonly frequency?: number; readonly size?: number }, +): Uint8ClampedArray { const opts = { seed0: SEED0, width: w.width, @@ -119,9 +126,61 @@ function typescriptPixels(w: Window, view: "landmask" | "terrain"): Uint8Clamped tilesPerPixel: w.tilesPerPixel, ctx: { islandsFrequency: w.islandsFrequency, islandsSize: w.islandsSize }, }; - return (view === "landmask" ? renderFulgoraLandMask(opts) : renderFulgoraTerrain(opts)).data; + if (view === "landmask") return renderFulgoraLandMask(opts).data; + const image = renderFulgoraTerrain(opts); + // The composite, in the dispatcher's own order: terrain, then the scrap + // overlay mutating it in place. `"resources"` and `"all"` take the same + // branch there because Fulgora has no cliffs and no rocks. + if (view === "resources" || view === "all") { + renderFulgoraResources(image, { + seed0: SEED0, + originX: w.originX, + originY: w.originY, + tilesPerPixel: w.tilesPerPixel, + ctx: { islandsFrequency: w.islandsFrequency, islandsSize: w.islandsSize }, + scrapControls: scrap, + }); + } + return image.data; } +/** + * Windows chosen for SCRAP, which the four above have almost none of. + * + * Measured 2026-08-31 at seed 123456, counting pixels where the `all` + * composite differs from plain terrain: the four parameter-varying windows + * carry 0, 1, 11 and 0. That is 12 pixels of overlay in 7,685, so a + * byte-identity test over them alone is very nearly a terrain test with extra + * steps. + * + * These two carry 149 and 134. They were found by sweeping origins on a + * +/-3000 grid. Near spawn there is no scrap at all, because the starting mask + * suppresses it, which is why the "origin" window reads 0 rather than being + * unlucky. + */ +const SCRAP_WINDOWS: readonly Window[] = [ + { + label: "scrap-dense, south-west", + width: 64, + height: 64, + originX: -500, + originY: 3000, + tilesPerPixel: 1, + islandsFrequency: 1, + islandsSize: 1, + }, + { + label: "scrap-dense, west", + width: 64, + height: 64, + originX: -2000, + originY: 1000, + tilesPerPixel: 1, + islandsFrequency: 1, + islandsSize: 1, + }, +]; + describe("the WASM engine renders Fulgora's land mask byte-identically", () => { it.each(["landmask", "terrain"] as const)( "matches the TypeScript %s renderer on every pixel of every window", @@ -234,7 +293,7 @@ describe("the request encoding is pinned on both sides", () => { }); /** - * A Fulgora request is 104 bytes and a Vulcanus one is 368, so the encoder + * A Fulgora request is 120 bytes and a Vulcanus one is 368, so the encoder * returns a LENGTH rather than the buffer's capacity. * * Asserted because v1 had one size and the two were interchangeable there; @@ -259,13 +318,16 @@ describe("the request encoding is pinned on both sides", () => { expect(fixture.nauvis.totalBytes).toBe(568); expect(REQUEST_BYTES).toBe(fixture.nauvis.totalBytes); expect(REQUEST_BYTES).toBeGreaterThan(fixture.vulcanus.totalBytes); - expect(fixture.fulgora.totalBytes).toBe(104); + // Fulgora's block moved for the first time since v1: 48 to 64 bytes, so + // 104 to 120, when #363 appended the two scrap sliders. It is still the + // smallest of the three by a wide margin. + expect(fixture.fulgora.totalBytes).toBe(120); expect(REQUEST_BYTES).toBeGreaterThan(fixture.fulgora.totalBytes); }); it("refuses a target buffer too small to hold the request", () => { expect(() => - encodeRenderRequest(new Uint8Array(103), { + encodeRenderRequest(new Uint8Array(119), { seed0: 1, width: 1, height: 1, @@ -275,12 +337,12 @@ describe("the request encoding is pinned on both sides", () => { islandsFrequency: 1, islandsSize: 1, }), - ).toThrow(/fulgora request needs 104 bytes/); + ).toThrow(/fulgora request needs 120 bytes/); // A Vulcanus request does not fit in a buffer that a Fulgora one does, and // the message says which planet - the whole point of the per-planet length. expect(() => - encodeRenderRequest(new Uint8Array(104), { + encodeRenderRequest(new Uint8Array(120), { planet: "vulcanus", seed0: 1, width: 1, @@ -569,3 +631,206 @@ describe("the WASM engine's scrap footprint contains the game's scrap", () => { expect(share).toBeLessThan(0.5); }, 300000); }); + +/** + * **The composite, which is what an ordinary user actually sees.** + * + * `ElevationPreviewPanel.vue`'s `effectiveView` returns `"all"` for every + * non-dev-mode Fulgora request, so until #363 the DEFAULT Fulgora render was + * the one view still served by TypeScript, while the two dev-mode views went + * to the engine. This block is what lets the gate widen. + * + * The engine's overlay is a placement ROLL - `FulgoraScrapPlacement` in + * `crates/fmw-noise/src/resources/fulgora_catalog.rs` - and NOT the scrap + * footprint. `VIEW_SCRAP_FOOTPRINT` paints every tile where the probability is + * positive, which is a much larger set: 708 tiles against 177 over a 128x128 + * window at seed 123456. Substituting one for the other passes no test here. + */ +describe("the WASM engine renders Fulgora's composite byte-identically", () => { + it.each(["resources", "all"] as const)( + "matches the TypeScript %s composite on every pixel of every window", + async (view) => { + const e = await engine(); + for (const w of [...WINDOWS, ...SCRAP_WINDOWS]) { + const wasm = renderThroughWasm(e, { view, seed0: SEED0, ...w }); + const ts = typescriptPixels(w, view); + expect(wasm.length, `${w.label}: length`).toBe(ts.length); + + let first = -1; + for (let i = 0; i < ts.length; i++) { + if (wasm[i] !== ts[i]) { + first = i; + break; + } + } + if (first >= 0) { + const px = Math.floor(first / 4) % w.width; + const py = Math.floor(first / 4 / w.width); + expect.fail( + `${w.label}: first difference at pixel (${String(px)}, ${String(py)}), ` + + `byte ${String(first % 4)}: wasm ${String(wasm[first])} vs ts ${String(ts[first])}`, + ); + } + } + }, + ); + + /** + * `"resources"` and `"all"` are the same picture on Fulgora, and the module + * collapses them onto one arm. Asserted rather than assumed, because the + * claim is what justifies the collapse - if Fulgora ever gains a second + * overlay this test is what notices that the two stopped being equal. + */ + it("serves resources and all as the same picture, which is why one arm does both", async () => { + const e = await engine(); + const w = WINDOWS[0] as Window; + const resources = renderThroughWasm(e, { view: "resources", seed0: SEED0, ...w }).slice(); + const all = renderThroughWasm(e, { view: "all", seed0: SEED0, ...w }); + expect(Array.from(all)).toEqual(Array.from(resources)); + }); + + /** + * The overlay draws SOMETHING, and does not draw everything. + * + * Without this the byte-identity test above passes vacuously whenever the + * window holds no scrap: two terrain renders agreeing is not a test of the + * overlay. This counts the pixels the composite changed against plain + * terrain, per window, and the count is reported so a future reader can see + * which windows carry the weight. + */ + it("the overlay actually paints, so the composite is not just terrain again", async () => { + const e = await engine(); + const counts: string[] = []; + const perWindow = new Map(); + let total = 0; + for (const w of [...WINDOWS, ...SCRAP_WINDOWS]) { + const terrain = renderThroughWasm(e, { view: "terrain", seed0: SEED0, ...w }).slice(); + const all = renderThroughWasm(e, { view: "all", seed0: SEED0, ...w }); + let changed = 0; + for (let i = 0; i < terrain.length; i += 4) { + if ( + all[i] !== terrain[i] || + all[i + 1] !== terrain[i + 1] || + all[i + 2] !== terrain[i + 2] + ) { + changed++; + } + } + const pixels = w.width * w.height; + perWindow.set(w.label, changed); + counts.push(`${w.label}: ${String(changed)}/${String(pixels)}`); + expect(changed, `${w.label}: the overlay painted the WHOLE window`).toBeLessThan(pixels); + total += changed; + } + // The four parameter-varying windows are NOT required to carry scrap: they + // are chosen to move the request's fields, and three of them hold + // essentially none. The two scrap windows are, and they are the reason the + // byte-identity test above grades the overlay rather than the terrain. + for (const sw of SCRAP_WINDOWS) { + const n = perWindow.get(sw.label) ?? 0; + expect(n, `${sw.label}: overlay pixels`).toBeGreaterThan(50); + } + expect(total, `no window painted any scrap - ${counts.join(", ")}`).toBeGreaterThan(0); + console.log(`MEASURED composite overlay pixels - ${counts.join(", ")}`); + }); +}); + +/** + * **The two new ABI fields, graded end to end and INDEPENDENTLY.** + * + * `scrapFrequency` and `scrapSize` grew the Fulgora block from 48 to 64 bytes + * (#363). Nothing else in this file would notice if the module ignored either: + * every other test sends the neutral 1, and a module reading neither field + * would still be byte-identical to a TypeScript renderer also running at + * neutral. + * + * **One field at a time, because moving both together hides one of them.** The + * first version of this test moved `frequency` to 4 and `size` to 3 at once, + * and hard-coding `scrap_frequency` to the neutral 1 in the module passed it. + * Two things were wrong with it. The picture still changed, because `size` was + * still moving; and the two sides still agreed, because of what the sweep below + * then turned up. + * + * **`scrapFrequency` above neutral does not move this window at all.** Measured + * 2026-08-31 on the scrap-dense south-west window, counting scrap pixels: at + * `(1, 1)` there are 149, and at `(4, 1)` there are 149 and every byte is + * identical. Below neutral it moves: `(0.25, 1)` gives 104. So 4 was in a dead + * zone. `size` moves in both directions - `(1, 3)` gives 194 and `(1, 0.25)` + * gives 118. + * + * That is consistent with the probability being capped by the Lua's own `min`, + * which `expressions::fulgora_scrap`'s module docs record, but it is stated + * here as the measurement rather than as the mechanism: what the test needs is + * a value that demonstrably moves the picture, and 4 is not one. + */ +describe("the scrap sliders cross the ABI and are read", () => { + const w = SCRAP_WINDOWS[0] as Window; + + /** + * Each case moves ONE slider off neutral to a value the sweep above showed + * moving the picture. `frequency` goes DOWN because up is a dead zone. + */ + const CASES = [ + { label: "frequency below neutral", scrap: { frequency: 0.25, size: 1 } }, + { label: "size above neutral", scrap: { frequency: 1, size: 3 } }, + { label: "size below neutral", scrap: { frequency: 1, size: 0.25 } }, + ] as const; + + it.each(CASES)("$label changes the engine's picture", async ({ scrap }) => { + const e = await engine(); + const neutral = renderThroughWasm(e, { view: "all", seed0: SEED0, ...w }).slice(); + const moved = renderThroughWasm(e, { + view: "all", + seed0: SEED0, + ...w, + scrapFrequency: scrap.frequency, + scrapSize: scrap.size, + }); + expect(Array.from(moved)).not.toEqual(Array.from(neutral)); + }); + + it.each(CASES)("$label agrees with the TypeScript, byte for byte", async ({ scrap }) => { + const e = await engine(); + const wasm = renderThroughWasm(e, { + view: "all", + seed0: SEED0, + ...w, + scrapFrequency: scrap.frequency, + scrapSize: scrap.size, + }); + const ts = typescriptPixels(w, "all", scrap); + expect(wasm.length).toBe(ts.length); + let first = -1; + for (let i = 0; i < ts.length; i++) { + if (wasm[i] !== ts[i]) { + first = i; + break; + } + } + if (first >= 0) { + const px = Math.floor(first / 4) % w.width; + const py = Math.floor(first / 4 / w.width); + expect.fail( + `first difference at pixel (${String(px)}, ${String(py)}), ` + + `byte ${String(first % 4)}: wasm ${String(wasm[first])} vs ts ${String(ts[first])}`, + ); + } + }); + + /** + * The dead zone itself, pinned so it is a known property rather than a + * surprise the next person rediscovers by writing the test I wrote first. + */ + it("raising frequency above neutral is a no-op on this window, which is why the cases lower it", async () => { + const e = await engine(); + const neutral = renderThroughWasm(e, { view: "all", seed0: SEED0, ...w }).slice(); + const raised = renderThroughWasm(e, { + view: "all", + seed0: SEED0, + ...w, + scrapFrequency: 4, + scrapSize: 1, + }); + expect(Array.from(raised)).toEqual(Array.from(neutral)); + }); +});