fix(cliffs): multisample offsets are in GRID UNITS - #18 root cause - #83
Merged
Conversation
) vulcanus-multisample-NOTES proved multisample(e,dx,dy) == e(x+dx, y+dy) at 150/150 comparisons with residual exactly zero. That is correct - for LuaSurface.calculate_tile_properties, whose noise program has a 1-TILE grid. It was never checked in any other channel, and the primitive's own docs say it evaluates "in a separate noise program with a larger grid" whose "sub-grids are copied to the main program". That phrase is load-bearing. Asked through the CLIFF GENERATOR, whose grid is the 4-tile corner lattice, by routing a probe onto cliff_elevation with the rule collapsed so cliffs mark exactly where the field crosses 71: x column 70 baseline multisample(x, 0, 0) column 70 identity multisample(x, 4, 0) column 54 shifted 16 TILES, not 4 multisample(x, 0, 4) column 70 null control dx = 4 moves the field by 4 x the 4-tile grid step. So the true rule is e(x + dx*G, y + dy*G) for the CALLING program's grid G. Consequence: vulcanus_basalt_lakes_multisample's min over {0,1}x{0,1} spans 4 tiles for cliffs and 1 tile for every per-tile consumer. min is erosion, so the cliff channel's elevation is much smoother. The port used the 1-tile field for both, making the cliff elevation too rough and over-placing by ~40%. before after wrong orientation 175 = 12.5% 37 = 2.4% recall 0.806/0.938/0.853 1.000/0.973/0.965 [0,0] worst region 29.8% wrong 2.5%, recall 1.000 level sweep ratio 1.20-1.49 below 120 1.00-1.09 at EVERY level VulcanusElevation now exposes cliffElevation beside elevation. Both hang off one stack and share every sub-expression below the multisample, so the cost is a second memo table - a private DAG for cliffs was tried first and is much dearer. Do not collapse them back together: they are different fields, not a cache miss. Nothing here refutes the multisample port or the per-tile consumers. calculate_tile_properties and the tile renderer both live in the 1-tile channel where e(x+dx, y+dy) is exactly right. Specs that pinned the defect are inverted rather than deleted, because the inversions are the evidence: the s=0 smoothing arm is now exact, all four collapsed arms are exact, the level sweep matches at every level, and the corner-fields substitutions now MOVE cells - those fixtures are the tile channel, so they are the right numbers for the wrong consumer, and their agreeing for months is how the wrong channel stayed invisible. cliffResidual's Vulcanus block is retired: its mismatched population is now too small to compare. The lesson is the third form of the same trap. Not a fixture captured at the wrong SITE (#70's grid_offset), not a wrong value - a fixture captured through the wrong CHANNEL, agreeing with a port that made the same mistake. Ask which code path CONSUMES a value, not only which coordinates it is sampled at. And note that no sweep inside the port could have found this: the smoothing, band and rule sweeps all searched a family that shared the defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB
This was referenced Aug 2, 2026
wormeyman
added a commit
that referenced
this pull request
Aug 2, 2026
An audit after the multisample grid fix. Several documents still asserted the opposite of what is now measured, and three of them were TITLES - the lines a reader trusts fastest. - `cliffs-NOTES.md` gains a STATUS banner at the top. The root cause sat at line 636 under 635 lines of superseded investigation, so a cold reader met the wrong conclusions first. The banner carries the current recall and orientation numbers, points at the ROOT CAUSE section, and names the specific stale figures below it (the ~90%, the 12.5%/29.8%/8.1%/11.7% tables, the 0.806/0.938/0.853 recalls) as pre-fix. - Two headings there were plainly wrong: "validated ~90% tile-for-tile", and "Validation result: EXACT since 2026-07-30 (the residual is resolved)" - which was only ever true of Nauvis. - `vulcanus-cliffs-NOTES.md` gains the same banner, and its "## The residual is in the RULE, not the fields" section is marked FALSIFIED. Its experiment and numbers are sound; the verdict is not. The fixture it substitutes came through calculate_tile_properties (1-tile grid) while the cliff generator reads a 4-tile grid, so the fixture and the port shared the mistake and could never disagree. - `client-preview-ROADMAP.md` said the residual "is now believed to be a RULE error rather than a field error (PR #57)". It was a field error. Corrected with why PR #57's substitution missed it. - `vulcanusCliffCornerFields.spec.ts`'s doc header and `vulcanusOreCliffSeparation.spec.ts`'s describe name both still claimed the fields are exact and the residual is in the rule. Both now say the fields are exact IN THE TILE CHANNEL, which is the accurate and more useful statement. - CLAUDE.md's spec-file count was 143; it is 152. No behaviour change. `pnpm run verify`: 310 files clean, 1306 passed / 3 skipped, preview 12 passed - the suite is green on an unloaded machine, which also confirms the earlier local timeouts were load and not a regression. Claude-Session: https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman
added a commit
that referenced
this pull request
Aug 2, 2026
…#84) (#87) #83 left an assumption behind: that cliffs are the only consumer reading a multisample-bearing field on a grid other than 1 tile. That was never measured, which is exactly the status the cliff case itself had before #83. Closes #84 item 4. The surface is much smaller than "audit every consumer", and that is the first finding. multisample appears in factorio-data @ 2.1.12 in exactly ONE place - vulcanus_basalt_lakes_multisample, used only by vulcanus_elev. No other planet uses the primitive. So the audit reduces to "who consumes Vulcanus elevation": | consumer | reads elevation? | grid | how | | ------------------------- | ---------------- | ---- | ---------------- | | cliff generator | yes | 4 | measured, #83 | | tile generator (19 range) | yes, via elev | 1 | measured, here | | vulcanus_temperature | yes | 1 | same program | | tungsten/coal/calcite/SA | no | n/a | read the Lua | | rocks, geyser | no | n/a | read the Lua | The resource row had to be checked rather than assumed, because the generic path DOES couple to elevation: starting_resources_lake_mask = clamp((elevation-1)/10, 0, 1) feeds starting_patches' spot_favorability_expression, and CLAUDE.md flags that coupling as exactly what changed at 2.1.9. Vulcanus does not take that path - each of its four resources uses its own vulcanus_*_region expression, and those four definitions contain 0 references to elevation. The tile generator reads grid 1, measured against the game. Substituting the cliff generator's 4-tile elevation into the tile resolver, over the 381 oracle positions: | | grid 1 (ships) | grid 4 | | ------------------------ | -------------- | ------- | | tile-name agreement | 0.9816 | 0.8609 | | lava misclassifications | 0 / 381 | 27 / 381 | 46 tiles named wrongly, and the binary lava call - the only thing the cliff collision rejection reads - goes from exact to 27 wrong. The metric is demonstrably sensitive to the swap, which is the part worth insisting on: PR #57's substitution failed precisely because "nothing changed" could not be distinguished from "the substitution never ran". Both arms carry that guard explicitly. Side result: this clears the lava perimeter that costs 13 real cliffs their placement (#86). Reading the other elevation channel makes lava dramatically worse, not better, so the perimeter error is not a channel mistake - it is somewhere else in vulcanusCatalog. Also asserts the max(-500, ...) clamp never bites at these positions, so the swap is exactly "the same field at grid 4" rather than something subtly else. Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 2, 2026
wormeyman
added a commit
that referenced
this pull request
Aug 2, 2026
cliffOrientationOracle.spec.ts counts the residual and bounds it. This pins what it looks like, because the shape is the lead and a change in shape is a change in cause even when the count holds. Measured after #83 (multisample grid), #86 (lava rejection) and #90 (raw collision box): - 37 of 1531 matched cells, and all 37 differ in EXACTLY ONE edge. Not one two-edge difference survives; before #83 the dominant mode was two edges (125 of 175), a whole corner on the wrong side of a band. - Every one is an OVER-detection: the game reports a -to-none orientation and the port reports a crossing on that edge, never the reverse. - Spread over all four edges (L11/R6/T7/B13) and all three regions (7/26/4), so it is not a directional off-by-one. Two candidate causes are already eliminated, which is why the shape is worth pinning rather than re-deriving next session: - crossesCliff is EXACT. Disassembled at 0x10160c914 under 2.1.12 (the VA in cliffs-NOTES.md had moved); cliffPlacement.ts reproduces it line for line including the a<0||b<0 early-out, the boundary<e0 check, and the strict >0.5 gate and strict crossing comparisons. No >=-vs-> slip exists to find. - cliffiness_basic is EXONERATED. Substituting the game's own corner cliffiness leaves the count at exactly 37/1531. So the residual is in the grid-4 cliff-elevation field - the one input in the chain with no direct per-corner oracle. A single-edge, strictly one-directional over-detection is what a small positive field offset looks like. The direction assertion is the load-bearing one: if under-detections ever appear, the cause has changed and that reading is dead. Claude-Session: https://claude.ai/code/session_01WRKSNgkidxc6daeHGJHqpt Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman
added a commit
that referenced
this pull request
Aug 3, 2026
) * feat(cliffs): port the ORE -> CLIFF rejection (#84 item 1) #99 characterised the rule and stopped short of porting it, flagging one open sub-question: whether driving it from our own resource model, rather than the game's entities, is accurate enough. It is - and it costs exactly one cell. Scored across all three oracle regions, driving `makeVulcanusOreRejection` off the same field stack the ore overlay paints from: | region | game | placed | fires | false rejections | surplus | | --- | --- | --- | --- | --- | --- | | [0,0] | 283 | 283 | 0 | 0 | 2 -> 2 | | [1500,1500] | 885 | 900 | 20 | 0 | 42 -> 22 | | [-1200,800] | 401 | 387 | 0 | 0 | 1 -> 1 | Precision at [1500,1500] 0.953 -> 0.975 with the 858 true positives untouched. **Recall is not touched anywhere**, which was the gate: this rule may only ever cost precision. Three variants were scored and the two that lose are kept in the spec rather than dismissed in a comment, because #88/#90 already paid for that lesson here: | variant | fires | correct of 31 | false rejections | | --- | --- | --- | --- | | base box, ores only (SHIPPED) | 20 | 20 | 0 | | base box + geyser | 21 | 20 | 1 | | per-orientation box | 23 | 21 | 2 | The geyser arm is strictly HARMFUL - one more false rejection and not one additional correct suppression - so it is implemented behind `includeGeyser`, defaulting off. The per-orientation rotbb box catches one more true cell and pays two kept cliffs for it; higher `correct` is exactly the trap. Not claimed: the mechanism is still open (the disassembly says cliffs are placed before any resource entity exists), and 11 of the 31 stay unexplained with the box deliberately not widened to cover them. The spec pins that 11. Lands as `CliffBands.cellRejects`, an opaque per-cell predicate beside `tileCollides` - so the shared cliff core stays planet-agnostic, and the model the specs score is the model the renderer ships. The predicate enumerates no entities: the overlapping tiles follow in closed form (2 tiles for an ore against the lava rejection's ~30), guarded by a wider brute-force scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c * test(cliffs): the budget FLIPPED - recall is now the bigger defect (#84) Every cliff defect found since #18 has been a rule the port over-places without (lava collision, the rotbb box shape, the ore suppression), so "find another rejection" has been the shape of the work throughout. After #100 that is no longer where the error is. | region | surplus | missing | lava-killed | ore-killed | never generated | | --- | --- | --- | --- | --- | --- | | [0,0] | 2 | 2 | 2 | 0 | 0 | | [1500,1500] | 22 | 27 | 3 | 0 | 24 | | [-1200,800] | 1 | 15 | 1 | 0 | 14 | | total | 25 | 44 | 6 | 0 | 38 | **The port now misses more cells than it over-places, 44 to 25**, and 38 of the 44 are cells the crossings stage never produces at all - a different defect in a different part of the port from everything solved so far. `[0,0]` generates every cell the game does: its whole miss is the two the lava rejection took, and `neverGenerated` is zero there. All 38 sit in the two far-field regions, which agrees with #93 finding the port exact at [0,0] and [-1200,800] at cliff_smoothing = 0 and still wrong at [1500,1500]. Also closes item 3 (the entity half of `Surface::wouldCollide`) UNPORTED, by size rather than by difficulty - the same move that retired fixImpossibleCells as a suspect. It is a rejection, and rejections can only remove cells: total surplus is 25, which bounds what rocks and craters together could ever be worth against a 44-cell recall gap they cannot touch. The crater arm is settled exactly, since craters are already in the fixtures: all 8 sit in [-1200,800] and not one touches a cell the port over-places, nor any cliff the game kept. Worth zero. The rock arm needs no fixture - the ceiling covers it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c * test(cliffs): the recall gap was a QUERY-WINDOW ARTIFACT - recall is 0.9961 (#84) Supersedes this branch's first commit, whose central claim was wrong. `find_entities_filtered` selects entities whose BOUNDING BOX touches the query area; `placedCells` emits cells whose CENTRE lies inside it. Different inclusion rules, so the fixtures carry cliffs centred just outside the box and every one has been scored as a miss. | region | game rows | centred inside | centred OUTSIDE | | --- | --- | --- | --- | | [0,0] | 283 | 283 | 0 | | [1500,1500] | 885 | 861 | 24 | | [-1200,800] | 401 | 387 | 14 | That is 38 cells - the entire apparent recall gap - and the port places 38 of 38 once the query box includes their centres. Every one is an agreement being scored as a failure. The widening arm is the load-bearing one: "we never looked there" alone is equally consistent with the port being wrong. Corrected budget, both sides scored alike: | region | game | port | matched | surplus | missing | | --- | --- | --- | --- | --- | --- | | [0,0] | 283 | 283 | 281 | 2 | 2 | | [1500,1500] | 861 | 880 | 858 | 22 | 3 | | [-1200,800] | 387 | 387 | 386 | 1 | 1 | | total | 1531 | 1550 | 1525 | 25 | 6 | **Recall 0.9961, precision 0.9839.** The 0.972 recall in the notes divided the same 1525 matches by 1569 rather than 1531 - the match count was never wrong, only the denominator. All 6 missing cells are ones our own lava rejection removed; there is no cell the port simply fails to generate. So precision is the only real defect left. Consequently item 3 (the entity half of Surface::wouldCollide) is RE-OPENED. The earlier commit closed it by size, arguing a rejection cannot help a 44-cell recall gap - that argument died with the gap. With recall at 0.9961 the dominant defect is the 25 surplus cells, which is exactly what a rejection removes. The crater arm stays settled at zero (all 8 sit in [-1200,800], none touches a surplus cell). The rock arm has no oracle capture at all, so capturing one is the next step - now with a 25-cell target rather than a ceiling against it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c * test(cliffs): the rock arm fails on the mechanism's own geometry (#84) Completes the item 3 thread in this branch rather than opening a second PR for it, since the previous commit had just re-opened it. No rock capture is needed to kill it. `computeInternal` runs `generateCliffs` before `generateEntities`, and `apply` runs `applyCliffs` (+124) before `applyEntities` (+164), so within a chunk no rock exists when the cliff is applied. A rock can only block a cliff from an ALREADY-GENERATED NEIGHBOUR, which confines the whole mechanism to cells near a 32-tile chunk border. | | n | near chunk border | | --- | --- | --- | | surplus | 25 | 11 = 44.0% | | matched | 1525 | 673 = 44.1% | The base rate to three significant figures. The surplus has no chunk-border character at all, so the one geometry the mechanism is confined to is not where the errors are. The direct overlap test agrees and is deliberately the weaker arm: 3 of 25 surplus cells overlap a modelled rock against a 6.6% base rate (~1.7 expected), which is nothing - and our rock placement is a salt-dependent roll whose individual positions are unreliable exactly as the geyser's were in #100. So item 3 explains approximately none of the 25, and is closed on the mechanism's geometry rather than on the ceiling argument that died with the recall gap. Remaining unexplained: 25 surplus, 6 missing (all lava-rejection over-rejections), 33 wrong orientations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c * test(cliffs): the orientation residual is not a boundary tie (#84) Stacked on the error-budget branch. The residual's known shape - exactly one edge per wrong cell, always an OVER-detection - has an obvious cheap explanation that turns out to be wrong, and ruling it out eliminates a whole class of cause. `crossesCliff` decides on the sign of `elevation - boundary`. If an endpoint sat within float noise of a band boundary, the ~1e-6 our fields agree to would flip the crossing, and the residual would be an irreducible precision limit with nothing to fix. Measured: every crossing edge in a wrong cell sits at least 0.205 from its boundary, median ~9.9 - four to seven orders of magnitude clear of float noise. For the game to disagree its elevation must differ from ours by more than 0.2, which is a real field or rule difference. Non-vacuity: the overall minimum across all 2,920 crossing edges is 6.4e-3, thirty times tighter, so "far from the boundary" is a property of the wrong cells and not of the sample. Also pins WHICH channel the corner fixture holds, as a number rather than as prose: against our per-tile elevation the worst corner differs by 4.8e-2; against the grid-4 cliff channel the generator actually reads, by 96.09. #83 was the most expensive mistake this subsystem has made and prose is not a guard. The gap that leaves is the point: the grid-4 cliff-elevation channel has NO per-corner oracle, is the only placement input never checked against the game corner by corner, and after the margin result is the only remaining candidate that could move an endpoint the required 0.2. Capturing it is the next step - and not via calculate_tile_properties, which is the 1-tile program that produced the wrong-channel fixture in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GyN97UwFQmwZs1cg4QHS1c --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman
added a commit
that referenced
this pull request
Aug 19, 2026
Completes the chain to vulcanus_elevation. Ports vulcanusSpawn.ts,
vulcanusBiomes.ts and vulcanusElevation.ts, and closes the
vulcanus_temperature the climate layer deferred, since it reads vulcanus_elev
and could not be ported until that existed.
Tier 1 grades fourteen more fields as frozen exact f32 match counts. Every one
was measured again on the TypeScript side against the same fixture and all
fourteen agree, so they are the distance both ports sit from the game:
spawn, of 410 startingArea 371, startingCircle 247, ashlandsStart 61
biomes, of 434 mountain_volcano_spots 359, mountains_raw_volcano 163,
mountains/ashlands/basalts_biome_full 128/107/127,
mountains/ashlands/basalts_biome 403/402/408
elevation, of 434 elev 113, elevation 113, temperature 196
Three readings worth keeping.
The oracle cannot see the -500 clamp, and that was checked rather than
assumed. vulcanus_elevation is max(-500, elev), and the captured elev bottoms
out at -58.77 - so the two columns are the same field at all 434 positions,
0 of 434 differ, and a port that dropped the max would score 113 as well. Both
are graded anyway because they separate for free the day a capture reaches a
deep lake, and the clamp's real test lives in the module, where it constructs
the case the fixture does not.
The clamp flatters a count, now measurably. The three clamped biomes score
403, 402 and 408 against their own unclamped sources at 128, 107 and 127. Same
quantity, times two, clamped: nothing improved between them, the clamp
saturates at 0 or 1 over most of the map, and a saturated position is exact
for free. The *_biome_full row is the port's real score.
A discrete output scores like a discrete output. mountain_volcano_spots at
359 is the highest unclamped count in the Vulcanus port, and it is dominated
by which single candidate survives per region - a choice a sub-ULP error
almost never changes. That is the same property CLAUDE.md records for
voronoi_cell_id.
Elevation's worst residual is 1.332e-1, which is only alarming until the scale
is read: the field spans -58 to +1024 here, so it is about 1.3e-4 relative,
the same order as every layer upstream. An absolute bound would need re-tuning
per field for scale alone, which is a third argument for counting matches.
Notes on the port itself:
- vulcanus_biomes keeps a real cache, unlike every layer so far. raw_spots at
one point reads selected spots from up to four neighbouring regions, which
is cross-position state, so the region cache is a RefCell<BTreeMap> and eval
stays &self. BTreeMap rather than HashMap because a determinism-critical
port should not carry a container with unspecified iteration order, even
where nothing iterates it yet.
- volcano_area is evaluated at every spot candidate and pulls the whole
pre-volcano chain there. The TypeScript memoizes those; this recomputes
them. Nothing on the render path reaches this layer yet, so it is
correct-first by choice, and the note says to measure before that changes.
- The mountains pre-volcano split is a named method rather than an inline
local. Collapsing the two stages would be an infinite recursion, which
announces itself, but reading volcano_area off the POST-volcano raw would
not.
- Three numbers in spawn are transcription traps and each has its own test:
basalts_start's distance is a bare 250 where both its siblings scale by r,
mountains takes half the distortion coefficient of the other two, and the
three results carry different multipliers.
- min(a, a) in vulcanus_ashlands_func is transcribed as written. The two basis
calls are byte-identical in the Lua, so it is the identity, and a test says
so - collapsing it later should be deliberate.
- cliff_elevation is a separate entry point because multisample's offsets are
in the CONSUMING program's grid units: the cliff generator's 4-tile lattice
moves the field 16 tiles for a dx of 4, which #83 measured.
All seven Vulcanus tier-1 tests were watched going red under --features
poison. engine.wasm is unchanged and still byte-matches its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG
wormeyman
added a commit
that referenced
this pull request
Aug 20, 2026
Completes the chain to vulcanus_elevation. Ports vulcanusSpawn.ts,
vulcanusBiomes.ts and vulcanusElevation.ts, and closes the
vulcanus_temperature the climate layer deferred, since it reads vulcanus_elev
and could not be ported until that existed.
Tier 1 grades fourteen more fields as frozen exact f32 match counts. Every one
was measured again on the TypeScript side against the same fixture and all
fourteen agree, so they are the distance both ports sit from the game:
spawn, of 410 startingArea 371, startingCircle 247, ashlandsStart 61
biomes, of 434 mountain_volcano_spots 359, mountains_raw_volcano 163,
mountains/ashlands/basalts_biome_full 128/107/127,
mountains/ashlands/basalts_biome 403/402/408
elevation, of 434 elev 113, elevation 113, temperature 196
Three readings worth keeping.
The oracle cannot see the -500 clamp, and that was checked rather than
assumed. vulcanus_elevation is max(-500, elev), and the captured elev bottoms
out at -58.77 - so the two columns are the same field at all 434 positions,
0 of 434 differ, and a port that dropped the max would score 113 as well. Both
are graded anyway because they separate for free the day a capture reaches a
deep lake, and the clamp's real test lives in the module, where it constructs
the case the fixture does not.
The clamp flatters a count, now measurably. The three clamped biomes score
403, 402 and 408 against their own unclamped sources at 128, 107 and 127. Same
quantity, times two, clamped: nothing improved between them, the clamp
saturates at 0 or 1 over most of the map, and a saturated position is exact
for free. The *_biome_full row is the port's real score.
A discrete output scores like a discrete output. mountain_volcano_spots at
359 is the highest unclamped count in the Vulcanus port, and it is dominated
by which single candidate survives per region - a choice a sub-ULP error
almost never changes. That is the same property CLAUDE.md records for
voronoi_cell_id.
Elevation's worst residual is 1.332e-1, which is only alarming until the scale
is read: the field spans -58 to +1024 here, so it is about 1.3e-4 relative,
the same order as every layer upstream. An absolute bound would need re-tuning
per field for scale alone, which is a third argument for counting matches.
Notes on the port itself:
- vulcanus_biomes keeps a real cache, unlike every layer so far. raw_spots at
one point reads selected spots from up to four neighbouring regions, which
is cross-position state, so the region cache is a RefCell<BTreeMap> and eval
stays &self. BTreeMap rather than HashMap because a determinism-critical
port should not carry a container with unspecified iteration order, even
where nothing iterates it yet.
- volcano_area is evaluated at every spot candidate and pulls the whole
pre-volcano chain there. The TypeScript memoizes those; this recomputes
them. Nothing on the render path reaches this layer yet, so it is
correct-first by choice, and the note says to measure before that changes.
- The mountains pre-volcano split is a named method rather than an inline
local. Collapsing the two stages would be an infinite recursion, which
announces itself, but reading volcano_area off the POST-volcano raw would
not.
- Three numbers in spawn are transcription traps and each has its own test:
basalts_start's distance is a bare 250 where both its siblings scale by r,
mountains takes half the distortion coefficient of the other two, and the
three results carry different multipliers.
- min(a, a) in vulcanus_ashlands_func is transcribed as written. The two basis
calls are byte-identical in the Lua, so it is the identity, and a test says
so - collapsing it later should be deliberate.
- cliff_elevation is a separate entry point because multisample's offsets are
in the CONSUMING program's grid units: the cliff generator's 4-tile lattice
moves the field 16 tiles for a dx of 4, which #83 measured.
All seven Vulcanus tier-1 tests were watched going red under --features
poison. engine.wasm is unchanged and still byte-matches its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG
wormeyman
added a commit
that referenced
this pull request
Aug 20, 2026
Completes the chain to vulcanus_elevation. Ports vulcanusSpawn.ts,
vulcanusBiomes.ts and vulcanusElevation.ts, and closes the
vulcanus_temperature the climate layer deferred, since it reads vulcanus_elev
and could not be ported until that existed.
Tier 1 grades fourteen more fields as frozen exact f32 match counts. Every one
was measured again on the TypeScript side against the same fixture and all
fourteen agree, so they are the distance both ports sit from the game:
spawn, of 410 startingArea 371, startingCircle 247, ashlandsStart 61
biomes, of 434 mountain_volcano_spots 359, mountains_raw_volcano 163,
mountains/ashlands/basalts_biome_full 128/107/127,
mountains/ashlands/basalts_biome 403/402/408
elevation, of 434 elev 113, elevation 113, temperature 196
Three readings worth keeping.
The oracle cannot see the -500 clamp, and that was checked rather than
assumed. vulcanus_elevation is max(-500, elev), and the captured elev bottoms
out at -58.77 - so the two columns are the same field at all 434 positions,
0 of 434 differ, and a port that dropped the max would score 113 as well. Both
are graded anyway because they separate for free the day a capture reaches a
deep lake, and the clamp's real test lives in the module, where it constructs
the case the fixture does not.
The clamp flatters a count, now measurably. The three clamped biomes score
403, 402 and 408 against their own unclamped sources at 128, 107 and 127. Same
quantity, times two, clamped: nothing improved between them, the clamp
saturates at 0 or 1 over most of the map, and a saturated position is exact
for free. The *_biome_full row is the port's real score.
A discrete output scores like a discrete output. mountain_volcano_spots at
359 is the highest unclamped count in the Vulcanus port, and it is dominated
by which single candidate survives per region - a choice a sub-ULP error
almost never changes. That is the same property CLAUDE.md records for
voronoi_cell_id.
Elevation's worst residual is 1.332e-1, which is only alarming until the scale
is read: the field spans -58 to +1024 here, so it is about 1.3e-4 relative,
the same order as every layer upstream. An absolute bound would need re-tuning
per field for scale alone, which is a third argument for counting matches.
Notes on the port itself:
- vulcanus_biomes keeps a real cache, unlike every layer so far. raw_spots at
one point reads selected spots from up to four neighbouring regions, which
is cross-position state, so the region cache is a RefCell<BTreeMap> and eval
stays &self. BTreeMap rather than HashMap because a determinism-critical
port should not carry a container with unspecified iteration order, even
where nothing iterates it yet.
- volcano_area is evaluated at every spot candidate and pulls the whole
pre-volcano chain there. The TypeScript memoizes those; this recomputes
them. Nothing on the render path reaches this layer yet, so it is
correct-first by choice, and the note says to measure before that changes.
- The mountains pre-volcano split is a named method rather than an inline
local. Collapsing the two stages would be an infinite recursion, which
announces itself, but reading volcano_area off the POST-volcano raw would
not.
- Three numbers in spawn are transcription traps and each has its own test:
basalts_start's distance is a bare 250 where both its siblings scale by r,
mountains takes half the distortion coefficient of the other two, and the
three results carry different multipliers.
- min(a, a) in vulcanus_ashlands_func is transcribed as written. The two basis
calls are byte-identical in the Lua, so it is the identity, and a test says
so - collapsing it later should be deliberate.
- cliff_elevation is a separate entry point because multisample's offsets are
in the CONSUMING program's grid units: the cliff generator's 4-tile lattice
moves the field 16 tiles for a dx of 4, which #83 measured.
All seven Vulcanus tier-1 tests were watched going red under --features
poison. engine.wasm is unchanged and still byte-matches its source.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG
wormeyman
added a commit
that referenced
this pull request
Aug 20, 2026
* Port phase 5: Vulcanus helper layer (#225) Ports src/noise/expressions/vulcanusHelpers.ts - vulcanus_detail_noise, vulcanus_plasma, vulcanus_threshold, vulcanus_contrast, vulcanus_biome_noise, the vulcanus_scale_multiplier program constant and the six vulcanus_wobble_* fields. It lands first because climate, biomes, elevation and the resource stack all read it, so an error here would arrive blended into seven files rather than localised to one. Tier 1 grades the three leaf closures the oracle captured, as frozen exact f32 match counts out of 38: wobbleX 38/38, worst residual exactly 0 mountainPlasma 7/38, worst residual 2.807e-3 detailNoise 1/38, worst residual 7.778e-5 The TypeScript was measured against the same fixture and reaches the identical 38, 7 and 1 with the same residuals, so these describe the distance both ports sit from the game rather than a gap between them. detailNoise is the row worth keeping: it has the SMALLEST residual of the three and the FEWEST exact matches. A field can be uniformly close and almost never right, which is the whole argument for counting matches instead of bounding error. Also recorded: test/vulcanusHelpers.spec.ts bounds vulcanus_wobble_x at 4e-4 and its comment claims a measured worst of 2.32e-4. Re-measured here the worst residual is exactly 0 at all 38 positions, so that bound is inert - it would not notice the field losing four digits. Left alone rather than fixed, because it belongs to #256 with the other 86. Unit tests pin what a residual cannot see: the +12243 detail-noise seed offset against the plasma's 12643 and 13423 + seed, which differ by a transposed digit; that only the plasma's SECOND term depends on its seed parameter; that biome_noise uses its seed1 raw; and that each wobble pair is two independent fields. Prepared moves from expressions::fulgora_shared to multioctave_noise. It is a general multioctave-hoisting utility rather than anything planet-specific, and a Vulcanus module reaching into a Fulgora one for it would be the wrong shape. Four Fulgora importers updated; no behaviour change. The poison entry was watched going red, not assumed: under --features poison wobbleX drops 38 -> 6. engine.wasm is unchanged and still byte-matches its source. Nothing fmw-wasm exports reaches the new module yet, so it is dead-code-eliminated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG * Port phase 5: Vulcanus crack and climate layers (#225) Ports src/noise/expressions/vulcanusCracks.ts and vulcanusClimate.ts - the five crack and flood fields the elevation chain samples, and the two climate fields. Both stack on the helper layer that landed in the previous commit. Tier 1 grades all seven as frozen exact f32 match counts out of 61, each with its worst residual: hairlineCracks 3 1.853e-3 floodCracksA 15 4.440e-4 floodCracksB 40 1.122e-4 floodPaths 10 5.460e-4 floodBasaltsFunc 8 6.387e-4 aux 40 4.584e-4 moisture 20 1.117e-4 The TypeScript was measured against the same fixtures and reaches all seven identically, so these describe the distance both ports sit from the game rather than a gap between them. hairlineCracks at 3 of 61 is the finding, and it points at #269. It is the SHALLOWEST expression in the layer - a bare plasma with nothing composed on top - so its weakness cannot come from anything this file builds. plasma is two basis_noise_expr results subtracted, and that adapter returns the un-narrowed f64 product #269 is about. The scales here are 0.3 * 0.325 and 0.6 * 0.325, neither exact in f32, which is precisely the case #269 records the oracle-basis fixture as blind to by construction: that fixture was captured at output_scale 1 and input_scale 0.125, both exact. So this is a second independent fixture pointing at the same adapter. Do not chase these five counts before #269 is settled. The two climate counts are flattered by their clamp. Both fields clamp to [0, 1] and every position where the clamp saturates is exact for free, because both ports and the game all return the bound itself. Read 40 of 61 as an upper bound on what the arithmetic achieves rather than as a measure of it. vulcanus_temperature is deliberately not ported. It reads vulcanus_elev, which arrives with the elevation chain, and wiring it half-finished would mean a field graded against nothing. Every min and max goes through eval::math::min2/max2 with the argument order the TypeScript writes, per the signed-zero divergence recorded in CLAUDE.md. This layer has four such sites. One unit test earned its comment by failing first, and the port was right: aux is min(abs(noise), 0.3 - 0.6 * flood_paths), so nudging flood_paths DOWN raises the arm the min does not select and nothing moves. The dependency is real but observable in one direction only, and that failure looks exactly like a missing wire. Both new tier-1 tests were watched going red under --features poison. engine.wasm is unchanged and still byte-matches its source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG * Port phase 5: Vulcanus spawn, biomes and elevation (#225) Completes the chain to vulcanus_elevation. Ports vulcanusSpawn.ts, vulcanusBiomes.ts and vulcanusElevation.ts, and closes the vulcanus_temperature the climate layer deferred, since it reads vulcanus_elev and could not be ported until that existed. Tier 1 grades fourteen more fields as frozen exact f32 match counts. Every one was measured again on the TypeScript side against the same fixture and all fourteen agree, so they are the distance both ports sit from the game: spawn, of 410 startingArea 371, startingCircle 247, ashlandsStart 61 biomes, of 434 mountain_volcano_spots 359, mountains_raw_volcano 163, mountains/ashlands/basalts_biome_full 128/107/127, mountains/ashlands/basalts_biome 403/402/408 elevation, of 434 elev 113, elevation 113, temperature 196 Three readings worth keeping. The oracle cannot see the -500 clamp, and that was checked rather than assumed. vulcanus_elevation is max(-500, elev), and the captured elev bottoms out at -58.77 - so the two columns are the same field at all 434 positions, 0 of 434 differ, and a port that dropped the max would score 113 as well. Both are graded anyway because they separate for free the day a capture reaches a deep lake, and the clamp's real test lives in the module, where it constructs the case the fixture does not. The clamp flatters a count, now measurably. The three clamped biomes score 403, 402 and 408 against their own unclamped sources at 128, 107 and 127. Same quantity, times two, clamped: nothing improved between them, the clamp saturates at 0 or 1 over most of the map, and a saturated position is exact for free. The *_biome_full row is the port's real score. A discrete output scores like a discrete output. mountain_volcano_spots at 359 is the highest unclamped count in the Vulcanus port, and it is dominated by which single candidate survives per region - a choice a sub-ULP error almost never changes. That is the same property CLAUDE.md records for voronoi_cell_id. Elevation's worst residual is 1.332e-1, which is only alarming until the scale is read: the field spans -58 to +1024 here, so it is about 1.3e-4 relative, the same order as every layer upstream. An absolute bound would need re-tuning per field for scale alone, which is a third argument for counting matches. Notes on the port itself: - vulcanus_biomes keeps a real cache, unlike every layer so far. raw_spots at one point reads selected spots from up to four neighbouring regions, which is cross-position state, so the region cache is a RefCell<BTreeMap> and eval stays &self. BTreeMap rather than HashMap because a determinism-critical port should not carry a container with unspecified iteration order, even where nothing iterates it yet. - volcano_area is evaluated at every spot candidate and pulls the whole pre-volcano chain there. The TypeScript memoizes those; this recomputes them. Nothing on the render path reaches this layer yet, so it is correct-first by choice, and the note says to measure before that changes. - The mountains pre-volcano split is a named method rather than an inline local. Collapsing the two stages would be an infinite recursion, which announces itself, but reading volcano_area off the POST-volcano raw would not. - Three numbers in spawn are transcription traps and each has its own test: basalts_start's distance is a bare 250 where both its siblings scale by r, mountains takes half the distortion coefficient of the other two, and the three results carry different multipliers. - min(a, a) in vulcanus_ashlands_func is transcribed as written. The two basis calls are byte-identical in the Lua, so it is the identity, and a test says so - collapsing it later should be deliberate. - cliff_elevation is a separate entry point because multisample's offsets are in the CONSUMING program's grid units: the cliff generator's 4-tile lattice moves the field 16 tiles for a dx of 4, which #83 measured. All seven Vulcanus tier-1 tests were watched going red under --features poison. engine.wasm is unchanged and still byte-matches its source. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG * Record phase 5 in CLAUDE.md, and correct two comments (#225) Three documentation changes. No behaviour, and engine.wasm is unchanged. CLAUDE.md's Rust engine section stopped at phase 4 and still listed Vulcanus as future work, so the repo's own map said six ported layers did not exist. Adds a phase 5 section with the 24 frozen counts' four readings, the biome layer's real cache and why it is the only one, the pre-volcano split, and the cliff multisample grid. Fixes an inverted comment on the ashlands basis leaf, found by review on #285. It said the input scale "divides by the volcanism scale multiplier" where the code has that multiplier in the NUMERATOR. The code was right and matched both the TypeScript and the Lua; only the comment was wrong. Worth more than a typo fix: scale_multiplier is exactly 1 at the default preset, which is what every fixture and the poison gate run at, so m/50/3 and 1/50/3/m are bit-identical throughout and an edit "fixing" the code to match the comment would diverge from the game only at a non-default volcanism frequency, with a green gate. Corrects the crack layer's #269 note, which blamed the wrong number. It said the layer is discriminating because its INPUT scales (0.3 * 0.325 and 0.6 * 0.325) are not f32-exact. That is wrong, and measurement says the discriminator is the OUTPUT scale alone: basis_noise returns an f32, so a power-of-two output scale is a pure exponent shift and narrowing the product is the identity, while any other output scale can leave the f32 grid. Over 90,000 samples at a fixed input scale, output scales 1, 0.5, 0.25, 2, 4 and 64 change 0.00% of products; 0.6 changes 79.88%, 0.75 and 3 change 56.32%, 150 changes 97.46%, 125 changes 98.38%. Holding the output scale at 1 and sweeping input scales 0.125, 0.205, 0.51, 0.6, 1.5 and 0.002 changes 0.00% every time. So exactly ONE of the crack layer's twelve basis calls is exposed: hairline_cracks' first term at output scale 0.6, where 80.10% of products differ. The other eleven sit at 1, 0.5 or 0.25. That is a one-term explanation for the one bad count in that layer, and why the four fields built only from power-of-two sites score 15, 40, 10 and 8 rather than 3. The wrong reasoning is recorded in the comment rather than quietly replaced, because it is the plausible guess: the input scale is the number that looks inexact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG * Re-score phase 5 against the landed #269, and correct the prediction (#225) Rebased onto `df3e39e`, which narrowed `basis_noise`'s output scale. Six of the 24 frozen counts moved. The point of this commit is not the counts - it is that this branch predicted, in writing and before it could check, which ones would. hairlineCracks 3 -> 2 directly exposed at output scale 0.6 floodCracksA 15 -> 15 not exposed floodCracksB 40 -> 40 not exposed floodPaths 10 -> 10 not exposed floodBasaltsFunc 8 -> 9 exposed VIA hairline_cracks mountainPlasma 7 -> 11 directly exposed at 125 and 625 elev 113 -> 115 directly exposed at 250 and 150 elevation 113 -> 115 same field as elev in this fixture temperature 196 -> 196 unmoved despite reading elev ## Correction one: exposure is transitive `fixtures.rs` said "do not expect the other four to move", reasoning that eleven of this layer's twelve DIRECT `basis_noise_expr` calls sit at power-of-two output scales and are blind by construction. Three of the four held. `floodBasaltsFunc` did not. The reason is in the layer's own verbatim transcription at the top of `src/noise/expressions/vulcanusCracks.ts`: flood_basalts_func = min(max(flood_cracks_a - 0.125, flood_paths), flood_cracks_b) + 0.3 * min(0.5, hairline_cracks) It READS `hairline_cracks`, so the single exposed term reaches it. The three fields that held are exactly the three that never touch it. The rule is that a field is exposed if it reads an exposed site directly OR through any field it composes - counting call sites misses that, and this is the correction. ## Correction two: hairlineCracks got WORSE, 3 to 2 That is not evidence against #269. The primitive is graded 196/196 against the game at five output scales in `test/basisOutputScale.spec.ts`, which is as settled as this project gets. It is the both-directions movement #273 measured: these are deep composed chains carrying other unported narrowings, so correcting one term shifts values slightly and a position that happened to land exactly right can stop doing so. A count falling by one at 61 positions says the field is still wrong for reasons #269 does not address - the `input_scale` question recorded on that issue is the next term to look at. Recorded rather than smoothed over, because a count that moves the wrong way is the kind of thing a port quietly re-baselines and then cannot explain later. Comments carrying the old numbers are updated in the same pass: the helper, crack, elevation and temperature blocks in `fixtures.rs`, and the phase 5 section of CLAUDE.md. Worst residuals are left labelled as measured before #269, since only the counts were re-measured after it. 175 Rust tests pass. Full gate green at VERIFY_RC=0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X2wz8eFazisYuENEkmBvvG --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 24, 2026
wormeyman
added a commit
that referenced
this pull request
Aug 24, 2026
…ngine (#225) (#306) * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj * 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DUQvbMXKFerAcSJrYt1MXj --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
wormeyman
added a commit
that referenced
this pull request
Sep 7, 2026
…with the field (#403) A lever on the INPUT to the cliff rule with the rule held fixed (#84). Neither vulcanus_volcanism slider touches the cliff rule - frequency is the input scale of the mountain and crack noise, size sets the volcano spot radius, spacing and density - so a sweep over them changes the elevation the cliff bands sit on while placement, both collision tests and the ore rule stay put. scripts/probes/vulcanus-cliff-volcanism/capture.ts is the first cliff capture through factorio-oracle. It reuses buildCliffControlLua from test/oracle/oracle.ts verbatim (forced-seed create_surface, the same one-drain chunk protocol, autoplace_controls read back off the surface) and runs one create per arm and region at 2.1.17, 2 seconds each. Four arms - default, frequency 0.5, frequency 2, size 3 - over the three regions of oracle-vulcanus-cliff-entities. Graded by vulcanus_cliffs_track_the_volcanism_sliders in fixtures.rs with the same four counts #307's table uses; no engine source changed and engine.wasm is byte-identical. Settled before any count was read: the default arm reproduces the 2.1.12 fixture cell for cell at 2.1.17 (283/885/409, same order), so the game did not move and that fixture keeps its stamp. R1 [0,0] is blind to volcanism on both sides - the engine moves 0 of 65,536 cliffs-view pixels there at any setting, the game places the same 283 cliffs in every arm, and the port scores 277/4/2/2 in every arm - because the whole square is inside the starting area. It is the control. And the 38 cells #307's table leaves out are all region-boundary entities from find_entities_filtered's bounding-box select, none with an orientation the port lacks; they are counted per arm as `unscored`. The sweep, shipping model, R2 + R3 errors over comparable cells: default 41 of 1248 3.29% frequency 0.5 21 of 1359 1.55% frequency 2 23 of 793 2.90% size 3 14 of 959 1.46% Two arms halve the rate, each about 2.9 sigma from default, so the residual depends on the elevation input and is not a fixed placement defect. It is not monotonic in feature scale either: a scale- proportional field error (#83's shape) predicts frequency 2 worst in both regions, and it is worst in R3 only and better than default in R2. With 14 to 41 events per arm that is a factor of two at 2.9 sigma and nothing finer. Full table and reading in docs/noise/vulcanus-cliffs-NOTES.md. Claude-Session: https://claude.ai/code/session_01EadT1PyhSjDbjJwvLfo3zr Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause of #18, found and fixed.
vulcanus-multisample-NOTES.mdprovedmultisample(e,dx,dy) == e(x+dx, y+dy)at 150/150 comparisons, residual exactly zero. That is correct - forcalculate_tile_properties, whose noise program has a 1-tile grid. It was never checked in any other channel, and the primitive's own docs say it evaluates "in a separate noise program with a larger grid" whose "sub-grids are copied to the main program". That phrase is load-bearing.Asked through the cliff generator (grid = the 4-tile corner lattice) by routing a probe onto
cliff_elevationwith the rule collapsed, so cliffs mark exactly where the field crosses 71:xmultisample(x, 0, 0)multisample(x, 4, 0)multisample(x, 0, 4)dx = 4moves the field by 4 × the 4-tile grid step. The true rule ise(x + dx*G, y + dy*G)for the calling program's gridG.So
vulcanus_basalt_lakes_multisample'sminover{0,1}²spans 4 tiles for cliffs and 1 tile for every per-tile consumer.minis erosion, so the cliff channel's elevation is far smoother. The port used the 1-tile field for both - too rough, over-placing ~40%.Result
[0,0](worst region)VulcanusElevationnow exposescliffElevationbesideelevation. Both hang off one stack and share every sub-expression below the multisample, so the cost is a second memo table - a private DAG for cliffs was tried first and is much dearer. Do not collapse them back together: they are different fields, not a cache miss.Nothing here refutes the multisample port or the per-tile consumers -
calculate_tile_propertiesand the tile renderer both live in the 1-tile channel wheree(x+dx, y+dy)is exactly right.Specs are inverted, not deleted
The inversions are the evidence: the
s=0smoothing arm is now exact, all four collapsed arms are exact, the level sweep matches at every level, and the corner-fields substitutions now move cells - those fixtures are the tile channel, so they are the right numbers for the wrong consumer, and their agreeing for months is how this stayed invisible.cliffResidual's Vulcanus block is retired: its mismatched population is now too small to compare.The lesson
The third form of the same trap: not a fixture captured at the wrong site (#70's
grid_offset), not a wrong value - a fixture captured through the wrong channel, agreeing with a port that made the same mistake. Ask which code path consumes a value, not only which coordinates it is sampled at. And no sweep inside the port could have found it: the smoothing, band and rule sweeps all searched a family that shared the defect.Local test note
pnpm vp check: 310 files clean. The full suite currently shows 8 timeout-only failures on cleanmainas well - verified by stashing and re-running - after hours of Factorio captures loaded this machine. No assertion failures on either. CI is the arbiter.🤖 Generated with Claude Code
https://claude.ai/code/session_01GRx1CU29RKLsRuUAErEDRB