From 30cc22dbf7bad06767544ba9d97ec27c64459917 Mon Sep 17 00:00:00 2001 From: Eric J Date: Sun, 30 Aug 2026 22:14:35 -0700 Subject: [PATCH] Give the Vulcanus cliff-blocking tile set one definition per side (#364) The set of Vulcanus tiles that refuse a cliff - `lava` and `lava-hot` - was written out four times, and nothing asserted the four agreed. Two of them sat on adjacent lines in `fixtures.rs` and said the same thing in two vocabularies, one by enum and one by string literal, so they could drift apart without either file changing. | before | after | | --- | --- | | `cliffs/vulcanus_fields.rs` inlined `Lava \| LavaHot` | calls `tile.is_cliff_blocking()` | | `fixtures.rs` `is_lava`, the same match again | `VulcanusTile::is_cliff_blocking` | | `fixtures.rs` `want_lava`, by name | resolves through `VulcanusTile::from_name`, then asks the same predicate | | `cliffCatalog.ts` | unchanged, still the only TypeScript definition | `from_name` is the inverse of the existing `name()`, resolved through `TILE_ORDER` so the two cannot disagree. It refuses a name Vulcanus does not place, which turns a typo in a fixture into a failure rather than a silent `false`. ## The two sides are compared now, not just tidied `fmw-wasm` exports `vulcanus_cliff_blocking_names_fnv1a64()`, an FNV-1a 64 over the blocking tiles' names, sorted and joined by newlines. `test/wasmVulcanusParity.spec.ts` hashes `VULCANUS_CLIFF_BLOCKING_TILES` through the module's own `fnv1a64` and compares. Names rather than a count, because a count cannot tell `lava` from `volcanic-folds` and a swap is the drift worth catching. The module's own hash rather than a second FNV-1a in TypeScript, because a reimplementation is one more thing that can disagree and its disagreement would look identical to real drift. Sorted, because catalog order is ground truth for the argmax tie-break but a `Set` of two strings does not carry it, so hashing in catalog order would make this depend on a fact it is not trying to check. ## Planted, not predicted Adding `VolcanicFolds` to the Rust set and rebuilding the module turns exactly the new parity test red and leaves the other eleven in that file green. A second test is the standing control: it hashes the set with a tile added, with one removed and with one swapped, and asserts all three differ from the module's answer. Without it, a bug that made both sides hash nothing would leave the comparison green while comparing nothing. ## The module grew 1,289 bytes, measured rather than assumed 320,092 to 321,381. Stubbing the export to return 0 unconditionally and rebuilding gives 320,135, so the export symbol and its body cost 43 bytes and the name table, the sort and the hash cost 1,246. The cost is `name()`'s 19-arm match pulling every tile name string into the module. Nothing unaccounted for got linked in, and the 512 KB tripwire in `wasmEngine.spec.ts` did not fire. ## Two more sites, deliberately left alone Sweeping for `Lava | LavaHot` rather than trusting the issue's table found two the issue did not list: `rocks/vulcanus_placement.rs` and `resources/vulcanus_geyser.rs`. Both refuse the same two tiles, and both keep their own definition. The three sets are equal by coincidence, not by a shared rule. The geyser's own doc comment already says so - "the forbidden set coincides with the rock overlay's while being reached by a completely different route" - and the three routes are the cliff's collision mask holding `water_tile`, the rock prototypes' `vulcanus_tiles_cold` / `vulcanus_tiles_hot` autoplace lists, and `type = "resource"` defaulting to the mask `{resource = true}`. Change the tile data on any one of those axes and the three come apart, and a shared predicate would then be wrong in two places at once, silently. That warning now sits on `is_cliff_blocking` itself. Closes #364. Co-Authored-By: Claude Opus 5 --- .../fmw-noise/src/cliffs/vulcanus_fields.rs | 20 +-- crates/fmw-noise/src/fixtures.rs | 14 +- .../fmw-noise/src/tiles/vulcanus_catalog.rs | 157 ++++++++++++++++++ crates/fmw-wasm/src/lib.rs | 18 ++ src/noise/cliffs/cliffCatalog.ts | 10 +- src/noise/wasm/engine.wasm | Bin 320092 -> 321381 bytes test/wasmVulcanusParity.spec.ts | 60 +++++++ 7 files changed, 261 insertions(+), 18 deletions(-) diff --git a/crates/fmw-noise/src/cliffs/vulcanus_fields.rs b/crates/fmw-noise/src/cliffs/vulcanus_fields.rs index fa790fb2..b3567e6b 100644 --- a/crates/fmw-noise/src/cliffs/vulcanus_fields.rs +++ b/crates/fmw-noise/src/cliffs/vulcanus_fields.rs @@ -33,7 +33,6 @@ 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; @@ -183,18 +182,11 @@ impl CliffFields for VulcanusCliffFields<'_, '_> { /// 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`. +/// **Which tiles those are is not a rule of its own.** It is +/// [`VulcanusTile::is_cliff_blocking`](crate::tiles::vulcanus_catalog::VulcanusTile::is_cliff_blocking), +/// which is also where the measurement behind the set is written down. This +/// used to inline `Lava | LavaHot` and restate that measurement in its own +/// words, one of four copies (#364). /// /// 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 @@ -215,7 +207,7 @@ 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) + tile.is_cliff_blocking() } } diff --git a/crates/fmw-noise/src/fixtures.rs b/crates/fmw-noise/src/fixtures.rs index 44d6645d..f624b47b 100644 --- a/crates/fmw-noise/src/fixtures.rs +++ b/crates/fmw-noise/src/fixtures.rs @@ -3629,8 +3629,18 @@ fn classifies_every_vulcanus_lava_tile_correctly() { let biomes = base.biomes_with_host_trig(); let stack = VulcanusStack::with_host_trig(&base, &biomes); - let is_lava = |t: VulcanusTile| matches!(t, VulcanusTile::Lava | VulcanusTile::LavaHot); - let want_lava = |name: &str| name == "lava" || name == "lava-hot"; + // Both sides go through `is_cliff_blocking`, which is the whole point of + // #364: these two lines used to say the same thing in two vocabularies - one + // by enum, one by string literal - and could drift apart without either file + // changing. The name side resolves through `VulcanusTile::from_name` rather + // than matching strings, so a fixture name the catalog does not know is a + // loud failure rather than a silent `false`. + let is_lava = VulcanusTile::is_cliff_blocking; + let want_lava = |name: &str| { + VulcanusTile::from_name(name) + .unwrap_or_else(|| panic!("fixture names a tile the catalog does not place: {name}")) + .is_cliff_blocking() + }; let positions = fixture.get("positions").as_array(); let want = fixture.get("tileNames").as_array(); diff --git a/crates/fmw-noise/src/tiles/vulcanus_catalog.rs b/crates/fmw-noise/src/tiles/vulcanus_catalog.rs index 97fcce83..39fb8438 100644 --- a/crates/fmw-noise/src/tiles/vulcanus_catalog.rs +++ b/crates/fmw-noise/src/tiles/vulcanus_catalog.rs @@ -136,6 +136,111 @@ impl VulcanusTile { Self::VolcanicAshSoil => [48, 48, 43], } } + + /// The tile whose prototype name is `name`, if Vulcanus places one. + /// + /// The inverse of [`Self::name`], resolved through [`TILE_ORDER`] so the two + /// cannot disagree. It exists because the oracle fixtures record tile + /// NAMES, not enum variants, and a caller holding a name used to have no way + /// to reach the enum except by matching strings of its own (#364). + #[must_use] + pub fn from_name(name: &str) -> Option { + TILE_ORDER.into_iter().find(|tile| tile.name() == name) + } + + /// Whether a cliff whose collision box touches this tile is refused. + /// + /// `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 + /// [`crate::cliffs::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`. A future tile that blocks cliffs + /// would be found the same way. + /// + /// **This is the only definition on the Rust side, and that is the point of + /// it (#364).** There were four: here, `cliffs::vulcanus_fields`, and twice + /// in `fixtures.rs` - one of those two by name and its neighbour by enum, so + /// the string form and the enum form could drift apart without either file + /// changing. TypeScript keeps its own single definition, + /// `VULCANUS_CLIFF_BLOCKING_TILES` in `cliffCatalog.ts`, and + /// [`cliff_blocking_names_fnv1a64`] is what stops the two sides drifting. + /// **Two other gates pick the same two tiles and MUST NOT be folded into + /// this one.** `rocks::vulcanus_placement` and `resources::vulcanus_geyser` + /// each refuse lava as well, and all three sets are equal today - but they + /// are reached by three unrelated routes in the game's data, so the equality + /// is a coincidence rather than a shared rule: + /// + /// | gate | what actually decides it | + /// | --- | --- | + /// | cliffs, here | the cliff's collision mask holds `water_tile`, and `tile_collision_masks.lava()` sets it | + /// | rocks | the four rock prototypes' `vulcanus_tiles_cold` / `vulcanus_tiles_hot` autoplace lists, whose union is every tile but these | + /// | geysers | `type = "resource"`'s default mask is `{resource = true}`, which `tile_collision_masks.lava()` also lists | + /// + /// Change the tile data on any one of those three axes and the three sets + /// come apart. A single shared predicate would then be wrong in two places + /// at once, and silently, which is worse than the duplication #364 removed. + /// Each gate keeps its own definition and its own comment saying which + /// mechanism it reads. + #[must_use] + pub fn is_cliff_blocking(self) -> bool { + matches!(self, Self::Lava | Self::LavaHot) + } +} + +/// FNV-1a 64 over every cliff-blocking tile's name, sorted, joined by `\n`. +/// +/// The cross-language half of #364. TypeScript holds the same set as +/// `VULCANUS_CLIFF_BLOCKING_TILES`, and `fmw-wasm` exports this so a spec can +/// hash the TypeScript set through the module's own `fnv1a64` and compare the +/// two in process. A change to one side that misses the other then fails a +/// test instead of sitting there. +/// +/// **Sorted, so the two sides do not also have to agree on an order.** Catalog +/// order is ground truth for the argmax's tie-break, but it is not something +/// the TypeScript set carries - it is a `Set` of two strings - so hashing in +/// catalog order would make this assertion depend on a fact it is not trying +/// to check. +/// +/// Insertion sort over a fixed buffer rather than `Vec::sort`: the WASM build +/// has no allocator, and at 19 tiles the cost is irrelevant. +#[must_use] +pub fn cliff_blocking_names_fnv1a64() -> u64 { + let mut names = [""; TILE_ORDER.len()]; + let mut count = 0; + for tile in TILE_ORDER { + if tile.is_cliff_blocking() { + names[count] = tile.name(); + count += 1; + } + } + let names = &mut names[..count]; + for i in 1..names.len() { + let mut j = i; + while j > 0 && names[j - 1] > names[j] { + names.swap(j - 1, j); + j -= 1; + } + } + + // 19 names of at most 26 bytes, plus separators, fits with room to spare. + let mut buf = [0u8; 1024]; + let mut len = 0; + for (i, name) in names.iter().enumerate() { + if i > 0 { + buf[len] = b'\n'; + len += 1; + } + buf[len..len + name.len()].copy_from_slice(name.as_bytes()); + len += name.len(); + } + crate::checksum::fnv1a64(&buf[..len]) } /// `vulcanus_rock_noise` (`planet-vulcanus-map-gen.lua` ~line 872). @@ -523,4 +628,56 @@ mod tests { names.dedup(); assert_eq!(names.len(), before, "duplicate tile name"); } + + /// `from_name` is the exact inverse of `name` for every tile, and refuses + /// anything else. + /// + /// The refusal half is the one that matters: `fixtures.rs` resolves the + /// oracle's recorded tile names through this, and a mapping that answered + /// `Some` for a name Vulcanus does not place would make that grader read a + /// typo as a real tile. + #[test] + fn from_name_inverts_name_and_refuses_anything_else() { + for tile in TILE_ORDER { + assert_eq!(VulcanusTile::from_name(tile.name()), Some(tile)); + } + assert_eq!(VulcanusTile::from_name("lava-warm"), None); + assert_eq!(VulcanusTile::from_name("water"), None); + assert_eq!(VulcanusTile::from_name(""), None); + } + + /// The cliff-blocking set is exactly `lava` and `lava-hot`, frozen. + /// + /// Frozen on purpose. The set was established by measurement - switching + /// the two out of the tile autoplace category and regenerating - so a + /// future change to it is a new measurement, and this test is what makes + /// that change deliberate rather than incidental. Update the count and the + /// names together, and say in the commit what was regenerated. + #[test] + fn exactly_two_tiles_block_a_cliff_and_they_are_the_lava_pair() { + let blocking: Vec<&str> = TILE_ORDER + .iter() + .filter(|t| t.is_cliff_blocking()) + .map(|t| t.name()) + .collect(); + assert_eq!(blocking, vec!["lava", "lava-hot"]); + } + + /// The exported hash is over the SORTED names, so the TypeScript side can + /// reproduce it without knowing catalog order. + /// + /// Computed here the long way rather than restating a magic constant: a + /// hardcoded digest would still pass if `cliff_blocking_names_fnv1a64` + /// hashed the wrong thing and someone updated the constant to match. + #[test] + fn the_exported_hash_is_fnv1a64_of_the_sorted_names_joined_by_newlines() { + let mut names: Vec<&str> = TILE_ORDER + .iter() + .filter(|t| t.is_cliff_blocking()) + .map(|t| t.name()) + .collect(); + names.sort_unstable(); + let expected = crate::checksum::fnv1a64(names.join("\n").as_bytes()); + assert_eq!(cliff_blocking_names_fnv1a64(), expected); + } } diff --git a/crates/fmw-wasm/src/lib.rs b/crates/fmw-wasm/src/lib.rs index dbe7af5d..bdfe18ff 100644 --- a/crates/fmw-wasm/src/lib.rs +++ b/crates/fmw-wasm/src/lib.rs @@ -1058,6 +1058,24 @@ pub extern "C" fn vulcanus_field_count() -> u32 { VulcanusParity::FIELD_COUNT } +/// FNV-1a 64 over the sorted names of the tiles that block a Vulcanus cliff. +/// +/// The set is written on both sides of the port - here as +/// [`fmw_noise::tiles::vulcanus_catalog::VulcanusTile::is_cliff_blocking`], in +/// TypeScript as `VULCANUS_CLIFF_BLOCKING_TILES` - and before #364 nothing +/// asserted the two agreed. A spec hashes the TypeScript set through this +/// module's own [`fnv1a64`] and compares, so the two sides cannot drift. +/// +/// Names rather than a count, because a count cannot tell `lava` from +/// `volcanic-folds`. Sorted, so the sides need not also agree on an order. +/// +/// **Returns a `u64`, so the same signed-BigInt caveat as [`fnv1a64`] applies:** +/// the caller must apply `BigInt.asUintN(64, x)`. +#[unsafe(no_mangle)] +pub extern "C" fn vulcanus_cliff_blocking_names_fnv1a64() -> u64 { + fmw_noise::tiles::vulcanus_catalog::cliff_blocking_names_fnv1a64() +} + // --------------------------------------------------------------------------- // The render boundary (#223). See `abi.rs` for the request layout and // `render.rs` for what it does. diff --git a/src/noise/cliffs/cliffCatalog.ts b/src/noise/cliffs/cliffCatalog.ts index 4b779b8d..3b1cfd56 100644 --- a/src/noise/cliffs/cliffCatalog.ts +++ b/src/noise/cliffs/cliffCatalog.ts @@ -441,7 +441,13 @@ export function cliffCollisionTileBox( * rule while this one exists. * * Lives here rather than beside the Vulcanus renderer because that renderer is - * deleted by #227 and this rule is not. The Rust carries the same pair inlined - * at `crates/fmw-noise/src/cliffs/vulcanus_fields.rs:218`. + * deleted by #227 and this rule is not. + * + * **This is the only definition on the TypeScript side, and the Rust side's only + * definition is `VulcanusTile::is_cliff_blocking` in + * `crates/fmw-noise/src/tiles/vulcanus_catalog.rs`.** The two are held together + * by `test/wasmVulcanusParity.spec.ts`, which hashes this set and compares it + * against the module's `vulcanus_cliff_blocking_names_fnv1a64()`. Before #364 + * the set was written out four times with nothing checking the four agreed. */ export const VULCANUS_CLIFF_BLOCKING_TILES: ReadonlySet = new Set(["lava", "lava-hot"]); diff --git a/src/noise/wasm/engine.wasm b/src/noise/wasm/engine.wasm index a5cf8f59c3b241bc8d6fb1861bb92571982c113b..276e7e5a1ce8fd69a727ceefa420bf8276100e3f 100755 GIT binary patch delta 29108 zcmeHw3tUyj*7%;sfddERfQldr`y53PeBkSlX6e>^UDICw%G7IFw})u?$h7>-gQ-R3 zgWT#+VWF9#V&XmMw(=_ZnNev{YNA=vy*;oju_yWb)|$PK9DLj(H@*IT|Ds^lTC-+m z&CHsaH8X3r2e#J#@`L&j%7}*_B#NStW31cSi1maiWUBra(ZW{KRg}_j8ZOrECH3`0 zI-7LRSJ9?zJ9groIq`&r(J&%^D6xPHLasteNC^H7C!BZbqW?lWhIUnytKmmaXL0QL zkmkMtY%fhop7_Yn9xw3^&G;;tg{cd~+H(P{Jv8atCdNrz6SGw6`h`HK9Dd_g`Z|0JK0gCv)}K$Bmjd2|tdiM~wp={%~_`E&uDP3O?(Y52NrF{|jW zb`t8<+usuJ`#Gd9X}dMABujBl&&tYD6o*!}3oxy%mbDwHGc0NjVn8j~Eskc=2EM8G z5TXWS)yLk{+jl6;Nv;pZ8CX;>UcI;DR(jy1p67_CyHDzdBZaKfD;zCd6(_eoq4bHQ zD$|q{;E)(onM$=s$GNFTJ?`)TJiC% zdeg{`@iMU~!|nhXK*EucF1r1sJ}WYz-F7oZt0v>Lt;eARkkiE?2o?%7w_;IU3<%qg z>mNtnKsO%OH$+|C<0qMvs~{58Li2h|5iu?4sbo;6N>t{dO`!Sc1g-cOX6~Z22kn6s zC_AYysn;%|%*+H@0|=ki+n6MC)x4Q*;-O{taU6e~|G-=9^kG5QDXZFTH_afAse9UVM99mQHI2 z^JHBz_B|Z`xhjkk_7OP(ea&26$AcALX3r*dGyN_@ z%bWJpWyn~Xl)>{2u&07E*X;5x8E?A_o_n2NuY4VPpR8M_U4|L^BJK5EeRcll()q_; zIy-?H*V$h8EgUrVKRPDXr`lun;Wz#e^rYA~_NH4e?J2P@`j)Hyw3hqc)->|ZA^*o7 z@_+O#`k&VFzq!pjzf}`R{oSUOH>kB7Slp#IkGxqIb)0$GgaDKJicUaYytTb-wF_Rk zFf3f!9KMQ?KVNZU`KC**4Zc@LXJ7GZUF06%!-t;!&n>z7kL2lt#&rv7&B=TJBjYad zy}xh3!@t7{t^dfLD<$`eN&JdiqL)GIAD{dy58Lnt4sKQ3pg-(M9=9R?w8rQNpJMs zu>3e`TpXltg*!wekc>^7;I@xLHZ?@GgS&(2ctOCE#P4@OTp`kT0-Z~U-|ZsAEb^1- zZQ5D>9BL?fnSj(#DSKv=!}RcweiYhN)s{k=Od2P>DKchUm1N2n(i=TA{PHP&Zwjcx zoEZsiOi4llBnffZQ4%D9_+}%z?0};K$c$Se{gQx-26&TrX12iQ8b0$~3|c)o35iRd z+=STOPtAz&ixyfVTJ=*}%;G1qvXltw2#{t#w%;Lz1UjS;m(6S#bVxbVGB&ei7G&Sq zz^tXB(i_lK1Z!G;Qnx?t^5&z{i7OPEh;&sXqGg>zl1692LIb=vMoy~)cZJzntpf6LQWw%f!iYDY>c zRPBHnTWQ5w%#sSRUzo8~R;yqmu6vC)o@EPpeM}4*;cH{ zO(hj#XCT=_bF5g4SyCZ(mKmFC#ahgg3bFIe*nAlaXW|yOBtvwb8C_r{wzwq~Vwak+ zMOLiEEU6G%XvS`^Vl8G#h1g;Cn;VZ4!e8d9;Gw3Ep4&{7qreSmVIbN1!$6V*K<$|@Tfm4}GdCT^f&Tg2 z-Y}FPgV+e}_udOpxqcJm2wB~m(;`NW7FQTdj?PR?X@$;li*n+cojab;J8+{lcmOOp zu9xTBG0dC=Ljq<&=s$NTtQm@2Gi>IX5vtnGtQpcV&%bEE?1ppWSEK-n~~Y!Xm54V;&$i(uK3=BEC20#ifTJLh#V zy&$1u%npLJu4Zn;2~8!V%()t>3@z^_nXsVYEGVZ{73yrdNg`*amI4lw(5>J<3KQ>P z)CFrUwq{$1lM_E?29qiE&`xGPfSUP$uvje4vcP2W??%w#m#}{0yaxJC-H96Wx!y2A z=3(;6rgTegImPSI^Ha!jz3cobWSPG5#U%aX`NK(tuk(TlA(HSoASrpv!t}nyk@_dE zj@0|Faq0IidxR9|pDbJ0jJ+?m8v}79ifIf&Dby?*;R%_ZjtOj zickUbtzB-jky5>1@myb@)r{7!oKph3r&Xqht;(VK%JpGuHb<5(0=gPDU#*u4b6ZML zrbALJd{NQFs52Z6lgGVwxS49c3jK?zX0Lsa=FZViti8LzhFZmdY+At_-}t}pA*9sT zuXq|cpB%me>vo66_;r!?fYsI1#x4IqZ`*JGF@UVsGd6WZ(_!?cr~NCXY3t~8bFc2} zv9%&N^q}9RHYC#x))6e#NDa2l*Jo_*9BK6><_e>F+h%V@{;?9!gl#W;I?o0!yfv#R zmaMlV{RJFm0$>hITD1Lf^{lEJAzpf z3`4M5<{6G)l?0)7X|z&;5eQaDFcQIX3D!d}`y>|ZjY2p}!cGLMParuO!72&HAXq8E z`UqA?umOVQ5^RWInFM1IER|p*1WP0shu{VY#v@oH!Nv#{cqN>GaJ~eaAebw`rU>Rp zFcHCQ2__+!B|#OzYS|Jl1gj+IMzB(X%@C}RVDp~Dm8=y>m}nbhNVSr93AE5kW!O?H zlVOTheq0~6rHQxf6t;#GuT+9kyb=jY@is_MidQ5-DPDmDrFi)gl;Y(|P>Po$K`CCg z1e>FzWwIMm5iB)(Ck^2e3AaXYg9O_kSR_HII0X`vijyxvsW`b3l!}uhL8&-qw@bw_ zdtEAy+3Qkq%wCs@Q)%{&)Eu+hrRJF3E)~b@cBwdKw@bw_yIm@d+3iws%x;&8Qy{xf zDvsIfQgO^)Z;O>Pd%c|(VYA!Q5j4BKJ%VPpcR=TMaR9^`xyuqNRF!z%$Hyn1al?W6~P<{UX5V31iK-aCBbVD zG>7T62%5vRJA&pg?SY^|}VcN52TM9Z`E<2BC6*8n+r3_nWRWfX;Rm(61b|Jd! z?cS+oT40w$9oS_E>@oy)83OxpU(VY-4cvZ9U9DvSxP3-P9Y5*umF){x}CVgY7FpVlOfuS zp;k;h#4I_cpW4xazH-dh;)4~8<}UDk_~9TzihSy>PPB!Y&K=Q4QQUUTKH*GR?nX#T z7%y}1P2Q7BF^dM}!w9Y1p^x8}q9^WGZ85Ntm*|`K-I-kW9LRNMsY7~2Es8^RYE^o# z{U^yQKJ63DksNG&!0cv-X=8cpjS-qd%U*|sE!JZdyH?7a`4*x;)@L*9#uqPIigrT2 zeDQnJC~A}zp%v?qU;nFl_upI2besrD768+@7SR?sJuGv zz>8S2|M5)C(T9G2Wm9kET#WRu@X#Gvc`XVWW2J$VOY6%hR)@1VJ>ih!`rmzsAFyES z;}0cY+6@17s1vF7#eMT6p@oa|{BPqx3ts>BI#k0`hu@t-Gt?nu{j65Gy{oKwcE zo(#?v{h~`xKYFg{{EX;PKb$LiNR8;5e~8uJJ=*3R(JwlK?mwI>`b8(+{6kkb27+M| z(BI52x=E&%8epDVWk;KIzXI6K)CK7+Rp-D+MRlvN-ved^b`>z&s4cY|9iq<+P8^D5 z^CU%o?Z?nmP&BQ=#4Q6IDOI&7m?lftSN!-OoxVel`zhaT&ctpz%#Ju+MzsMYg*@?S zYP=eanEuc&WAy$%-%MW7^L~yWY$i|}v{6Rm0HvpNtSLPC`0ub(5ZehxLY zCz;|-Ra@O0NnnTNP-CEO)q3PF-Tl(U{99?pQ5;9VBw|2*O@hGZ{xu#p2l`#riScSQ z)Uk5@*)5g5Kwo^Sk?)!6v4mzn=R5IRV-hD93%N*GwH}Wv66D(Uc;4BfIBGSFWD`eP z5Vv0MM0m8>CfYRYEttF^hl3}YqImsITy<$OI(5SHyEAGiK4ZiVVu?ydT>7q)3o}Y* z2euRHOL;8{E&!FaD5yczzWh_i3C-8nEg~by#mXg$7zt}+H5E>GW>bLxnOb8#{Y0$U zjBr?>del}!U<7#d#m8G+vh1PadL5ckitF#$8ga#Yj5L9)3~0k-@}D-Ko`0&LafgjG zBr#byKr`5e!|?7Cj4YNt&&UcPBFSHG7=v zGoa-q$%dqmGddhZnpiO=g~Xb(YNl3GGpQ?kEpyR7x!}BOuAUGLJ9Ruz&`Qpk_Ht@b zP}TEmQBc*30x8tst;oPDeoZRMQb|uWm^q~X>rnHd!I+?Z&fM$Qh1ae|>(PfA1{Fel zl}3tD@$y=eRJ!<>C~ZxW=#pdNP;1f}%x%%A4SDHrr3+9Fzlj+E&0c;ErLqd518HLHODBnhoG_}}lY{>sUUM4DJCknp zXjZZKvKKVMR^!F%$o>!rCk`MtV7<3pPr3$w3NO?_HJp6J$aceVH(5(* z_BvzJ-^eybPKlWhKs%o@N**BZ(9n_%sHtnjoKYk(v`8Xrg>MvbU)qYfMw8ZYwfB~X zalfZ^)|Tc8h>Z_gA@R~^Qgp#xqH*7Yq>v?@kVA^3v2<6s3PB27P{6F)IulQYom-9e z;|M1WSK6FsOD#>w$ypDo55x=?_MrP!A^SBmt< zNW37Ak+x)|0q`zDmKpqi$cvZO8`t=D3ON!IRW7jcnrVkIbQTzgrjyoTuI!C~xl`LH z%RdIdm7xN-NP_JU)IT;nv&rier^2IiNn8`_G@~8bJci}HRdmMr ziiQN4sz;>dz^%Y5$BacfSx!l*$b6nO6AvsTO*2YsOQUww%Fm%xoc z^30>0RFuG@y<}oi9*?%j#AL&G;F}ECENQ^?@-Z>x1(K2Iub!E9Nv*W7zI^xsiN&T{ zyokh#(=U)@4;-murL4l1)k^Sct-?ZOVaZU~@?&Dii==Z$km{& zsg_2j0t6lZ_zIHYx3PskT}$IC?Hm}Uqw2o7bSz%Q0{X&l*sNN^x=x>Vf#Oh~&1bz*7tlou7|BF=Zw)OCqrQ zv35hbejIi~g=@)hS4P!qkSag#M2Nc=+}kQ?ENhLQaKo*zSF|H7+fQ?KFbUO$wu>~U zwEQ*W{ypSoHoOjIFSJXkq7?)lcABKzTBI(>m2Hy5XXPZ`NZ&_>!|wQ)$k|W)=T1AivGcc zD^;y+6^@?#nsb$iXmAPuQdO-(DacyOs$o*#um>nP^Nqh9AO<1-Yc%*|Rg~5nDu7G3b)UCZw^Fy~dNz&>2ox;vFwixP^Pg7?MY0!~cB1 zb>^JqqOJw4i@!_8JM=QXpD_9r(6+=`y&aU`YQ>IzHm#JZ?c&K5v^weuyoBtkaOr)c zoD#EFf_4<6cTiV9YvCf{T196%pFviS5^82JeTP`TijIV{0JV_*20`Q1v|7>&d&u$WV5O4ylVm=9>o`SfmpCV-un(^U#Ui1Q}!Np7rZW8W*D?gjtOFWRhvzR?NuST%Jx8e?N`}tG_=e**U4zclIqlW zC78Q&jn#O;Dqs{fV_rf&73s+=m3(RpO=kDAMmcXtVWotou%?9jQrIStFt-&OLqf}B zL2E_5v^q*yfVp41giocho9$P6888NSV!a93Ene!(dcbmauru?(au(}hcU|$%fEhn` zW5bBE;7w>&?JY$sMF1?}5!bSOLRJdegSCwfG+5P8JUm02RrJu9Hgq*f>fmjh6?qmV~@#yfcX1MC)xt@z9^Q0i3tKZ?w3H^(C-ReCTHO z9vq)`xrOb#;+HMSa`Fl~{t9npY5dZfT+N2C0vfbs#ka%QKR|_7{FU8r3(9Sz-@}$N zQfBPAkI_&F%O7M_B(ZEg^r-)gQOjKinD#y#-b!+T@zoes#DQ5%V4Zo;G6a+nZ`dAV zd+SG(<0eBIh85*T24DT^gBr5JC1YZp#`8C#)!aPFPGPgXi zZsB9-QepJZ0$T~HeMbt_-v0P!e> zL|f4cL^Ht7#n@JGa3IkFMD6%e0i40@E=GI6S^&R5%_o^E%S1;;4o8#dqvO@^Ng8y2 z48ADCbO5sn@W?^4YaDa~cr@C_&EW|O`sjnVrkFN?{@XXK^%D}N6xF89>0I_F!AsrUSQYd$UEd(lv@AzC{D6=U~n2f~} zI;L^dQMXM6QB>YP!TAcVXKp)Uqf|TiQePhlpLA{onS{fe1O^4TIbd!_21v@)a10#F zp#(=yHG4S}=nca+AMgjo#s`JaY9VkyNF)wY+s$T!_Z3<86Aq>4tJ|^{m*y@1ZGcPG z65ds+;BX%S4Vq;i5DAT~a!HeI4Ka3=p9lww7JC8Ps*h}KHUWx-D=+SKG3*Q3>2TN=i$Tvgssc>1^X$Nag%XW()J6N)McsC?>T8wdUw*3NT_dL;lLI7;P zmv^u&@TgYY^8vdzX1jdDG!=?v6Y;_C_LE}&2dw{@*S1hc>mjh{yp#2XH&0^fPKIB& z7tilxDc#peAyE%On?Zj$%o}tZG#}LsZD;&%+h@AWo8ND-t{5-i_`irJd#)qu- zcj-l<;A6$TAF-umg9zWnZVPI58Ckp7uavwl9{reo3c+oAVJG;yF>No4L+T6r*k2&4 zFZZzw@`llLKPv*N@xcLBPTJ?;ag4ciJ7A|?z8aOFzBWseE#-g#gtNX1a}Ib$?EQ>& zZu16qrwcV4POf2j1A!z?kmJ+;9kNuN9 z#KD(%b z1xHy|M+wRg3r)1M#Mpn7728O$nENa1)n@Z~D`ry9uGnVd^sg++Mz$ERcRFAr+r(kY z;}f>YEZ}q<^sFKdm*DC|^JssYp)$VGPTn!zckn5+(NbJ_&108YCWxE#pTzKb95?Ar zqxcx&{Q|qhxfU~p+St_?XP!Ax0qi^j2Qv~ej~Q0Fjab&j?%v66BO;m`A!LWx+K6{0 zJB-tfcoDj`;4yj9h22Zw*gbAx0w2|==21bQ5F5l33497UAWkRn!O(<*oA7vQZ9|Q9 ziF_ZyE`epf(FKm~#Wt0vledM-&FhOhUA(Di>Eg`;2!A!NJF!HhZx)s#)W5b=wSJMmE5FBXY#Oc<2%mu4BvkkwU956m@ z!$T-kr^nM5u8xtA&R;<9mG$lUObokq94uomYHEEpF<|dy{Vr(U-r#$YG7SY^!m8?Ou{ zN5vzfd3SQuST~x_Vxbjs7r$2Y8_PR@e(UwVjvub5N$1>b)p&&k4$)wh4CLK_vcQ}o zqPNicM#wncgVp8eRXhJAalm+b0w0J&XYa%d4;@i&67K-*rA^`RIa5^ur)T`(EO z_!05?WIi+aYBT(Qc*>t&7p++%>eBa@jQ4WhREgo!cyUlY5FMW2y{tt-y#55w0tC z;c>kSkcK;IeB2`u({x;lbjfK#U@I0{$-URZ<{oo_53|??MG~h^d_RY$WXO21HgMaF znOOO$G?Joem$wH-sI~+Cg(U?1hQD)`7&w_^(@J%(fo%! zH5h@;=fr}!d;mD`d_I>qX#1?W^?`EXUu2S79uJKGY(8dzTOYeyX#>}N@F&dqtZ0_Q zM+EPMr8#^EDHNx3c)!?+k1!)VOTcpl32|qk`0I20SvqTvm_3ij3pS7Ux)9^AdAyy) zIFSg0>pY3nWok-@~Q`KD!0q2c}oJwPmcQ02ICxm|d9BuMjxF>WnCNTL_ooXSAJTN6d;31L?;!pHl%x&nD9pbWAIL$Za}w<$_< zz>CGZKAr&*kz&5no-}&I#1Z)0=7BL26haxdg@oF}!W|Kj^`e~7F~-h~{B26QiUpf_ z#+kfnhyp`YQHDZwnn4%~A|zs2;=9eFWGvFt*bb>g-_}+lRK5Rkor6L#X|ge6~GYz_(6aZ0`N}&Mt|5eHL`yAnr?|Hi(2p(jQ}HRHUjT}79u>E~%g2PDvQS5o_~2cT zD=x{5PEHbi-UGY?;8@f(Nn#V?{Ve>JB;k3Ve--{f3#Vd-_y<~u7TY0yEZ}e=r;KeO zUIIL9GT@*J%4E@S2b4Y)P|yTrs+f&vVLhkRl0sqo0MO|Wi-k=WkADEl>lN4R1mnqT KJie13jQKwpfc;Yd delta 27732 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 number; scratch_len: () => number; + fnv1a64: (len: number) => bigint; vulcanus_field_count: () => number; + vulcanus_cliff_blocking_names_fnv1a64: () => bigint; checksum_vulcanus: (requestLen: number, field: number) => bigint; } @@ -496,6 +499,63 @@ describe("Rust and TypeScript agree bit for bit across the Vulcanus field graph" expect(FIELD_NAMES).toHaveLength(engine.vulcanus_field_count()); }); + /** + * The two ports agree on WHICH tiles refuse a cliff, not merely how many + * (#364). + * + * The set is written once per side - `VULCANUS_CLIFF_BLOCKING_TILES` here, + * `VulcanusTile::is_cliff_blocking` in Rust - and before #364 it was written + * four times with nothing comparing them. This is what makes a change to one + * side that misses the other loud. + * + * **Names, not a count.** A count cannot tell `lava` from `volcanic-folds`, + * and swapping one blocking tile for another is exactly the drift worth + * catching. + * + * **Hashed through the module's own `fnv1a64` rather than reimplemented + * here.** A second FNV-1a written in TypeScript would be one more thing that + * can disagree, and its disagreement would look identical to the drift this + * is trying to find. + * + * Sorted before hashing, so the two sides do not also have to agree on an + * order - catalog order is ground truth for the argmax's tie-break, but a + * `Set` of two strings does not carry it. + */ + it("agrees with the module about which tiles block a Vulcanus cliff", async () => { + const engine = await instantiate(); + const joined = [...VULCANUS_CLIFF_BLOCKING_TILES].sort().join("\n"); + const bytes = new TextEncoder().encode(joined); + expect(bytes.length).toBeLessThanOrEqual(engine.scratch_len()); + new Uint8Array(engine.memory.buffer, engine.scratch_ptr(), bytes.length).set(bytes); + + expect(u64(engine.fnv1a64(bytes.length))).toBe( + u64(engine.vulcanus_cliff_blocking_names_fnv1a64()), + ); + }); + + /** + * The control on the test above: it can actually fail. + * + * Without this, a bug that made both sides hash the empty string - or made + * `fnv1a64` ignore its length - would leave the parity assertion green while + * comparing nothing. Same argument as `verify-rust.sh`'s anti-vacuity phase, + * at the scale of one test. + */ + it("would notice a set that gained, lost or swapped a tile", async () => { + const engine = await instantiate(); + const rust = u64(engine.vulcanus_cliff_blocking_names_fnv1a64()); + const hash = (names: string[]): bigint => { + const bytes = new TextEncoder().encode(names.sort().join("\n")); + new Uint8Array(engine.memory.buffer, engine.scratch_ptr(), bytes.length).set(bytes); + return u64(engine.fnv1a64(bytes.length)); + }; + const actual = [...VULCANUS_CLIFF_BLOCKING_TILES]; + + expect(hash([...actual, "volcanic-folds"])).not.toBe(rust); + expect(hash(actual.slice(1))).not.toBe(rust); + expect(hash(["lava", "volcanic-jagged-ground"])).not.toBe(rust); + }); + it("folds 676 grid points identically for every field, at two slider settings in two windows", async () => { const engine = await instantiate(); let compared = 0;