Skip to content

Commit 73da5c9

Browse files
wormeymanclaude
andauthored
Port the Vulcanus cliff stack to Rust and render cliffs through the engine (#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>
1 parent da0a888 commit 73da5c9

26 files changed

Lines changed: 4025 additions & 74 deletions

CLAUDE.md

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -555,8 +555,21 @@ Add future phases the same way.
555555

556556
Two more things about that job, both measured on its first run (#230):
557557

558-
- **It is 19s**, of which `scripts/verify-rust.sh` is 2s, the pinned-toolchain
559-
sync is 10s and cargo-deny is 1s. It is the cheapest job in the workflow.
558+
- **It WAS 19s and is not any more.** On its first run (#230) it was 19s, of
559+
which `scripts/verify-rust.sh` was 2s, the pinned-toolchain sync 10s and
560+
cargo-deny 1s, and it was the cheapest job in the workflow. #225's cliff half
561+
ended that: `the_apply_stage_beats_the_crossing_stage_on_three_counts_and_
562+
loses_on_none` is 33s in the normal arm and **93s under poison**, taking the
563+
script alone to **1m50s** locally. Poison is the expensive half because
564+
`crossing_result` turns every lattice edge into a crossing, so far more cells
565+
place and the `onDestroy` cascade recurses over a dense set.
566+
567+
It is kept because it is the ONLY grading of `cliffs::connections`, a
568+
445-line module on no render path - without it that port would have unit tests
569+
and no measurement against anything. It is still far under the test shards, so
570+
it does not move the gate wall; it is simply no longer free. Anyone adding a
571+
second fixture test of that shape should re-measure this job first.
572+
560573
- **It runs `bash scripts/verify-rust.sh` directly**, the one deviation from
561574
"the YAML names only package.json scripts". That does not reopen the drift
562575
the rule guards against, because `verify:rust` _is_ that one line, so the
@@ -1409,16 +1422,89 @@ engine.** Landed: `vulcanus_helpers`, `vulcanus_cracks`, `vulcanus_climate`,
14091422
`tiles/vulcanus_catalog`, `vulcanus_stack`, and the `terrain` render path
14101423
behind ABI v2. `vulcanus_shared` needed no port - it is
14111424
`starting_spot_at_angle`, done in #279 - and `vulcanus_seed` landed in phase 2.
1412-
Still out: the cliff, resource and rock OVERLAY stacks, so every composite view
1413-
keeps the TypeScript path and a test asserts that rather than assuming it.
1425+
1426+
**Phase 5's second half adds the CLIFF stack, and `cliffs` renders through the
1427+
engine too.** `cliffs/{catalog,placement,connections,vulcanus_fields,
1428+
vulcanus_ore_rejection}` plus the ore footprint slice of
1429+
`resources/vulcanus_catalog`. Still out: the resource and rock OVERLAY stacks,
1430+
so `rocks`, `resources` and `all` keep the TypeScript path and a test asserts
1431+
that rather than assuming it.
1432+
1433+
**Three of the nine TypeScript files in that directory pair were NOT ported, and
1434+
each for its own reason.** Read this before "finishing" them:
1435+
1436+
- `cliffFields.ts` and `rocks/rockField.ts` are NAUVIS. They need
1437+
`nauvis_shared`, `elevation_nauvis`, `aux` and `moisture` - 464 more lines
1438+
that are the core of #226 - and neither reaches a Vulcanus view. They belong
1439+
to phase 6.
1440+
- `cliffConnections.ts` WAS ported, and it is the odd one: it has **zero `src/`
1441+
consumers**. `grep -rln` finds it imported by 23 investigation specs and by
1442+
nothing the renderer runs. It models `Cliff::updateConnections` /
1443+
`onDestroy`, which is #84's subject, and it was ported so that investigation
1444+
can be run against the engine rather than only against the TypeScript.
14141445

14151446
**Tier 3 for Vulcanus** (`test/wasmVulcanusRenderParity.spec.ts`) is
1416-
byte-identical against the TypeScript across four windows, and **12,423 of
1417-
929,686** compared pixels against the game's own 1024x1024 PNG - 98.664%, which
1418-
is the TypeScript's own number to four decimal places, reached through a
1419-
separate path. It is asserted as an EXACT count where
1420-
`previewAgreement.spec.ts` uses a 2% bound, because byte-identity means it can
1421-
be.
1447+
byte-identical against the TypeScript across four windows for BOTH the `terrain`
1448+
and the `cliffs` view, and **12,423 of 929,686** compared pixels against the
1449+
game's own 1024x1024 PNG - 98.664%, which is the TypeScript's own number to four
1450+
decimal places, reached through a separate path. It is asserted as an EXACT
1451+
count where `previewAgreement.spec.ts` uses a 2% bound, because byte-identity
1452+
means it can be.
1453+
1454+
**The cliff stack's tier 1 is the game's own cliff entities, four columns, both
1455+
rejection arms** - and every one of the 24 numbers was measured on the
1456+
TypeScript side too and agrees exactly, so they describe the distance BOTH ports
1457+
sit from the game:
1458+
1459+
| arm | game | ours | matched | orientation |
1460+
| --------- | ---: | -------: | ------: | ----------: |
1461+
| lava only | 1569 | 1570 | 1525 | 1492 |
1462+
| shipping | 1569 | **1547** | 1525 | **1504** |
1463+
1464+
`orientation` is four bits per cell against `LuaEntity.cliff_orientation` where
1465+
position is one, and it is what says the two ports produce the same cell CODES
1466+
rather than merely the same positions. The ore rejection removes 23 cells, none
1467+
of them a cliff the game kept, and takes wrong orientations **33 -> 21** - which
1468+
is exactly the figure `renderVulcanusCliffs.ts` records having measured, reached
1469+
through a separate implementation.
1470+
1471+
**`cliffiness_basic` is exact at all 12,675 captured corners**, with the clamp
1472+
saturating at 8,431 of them - read the count with its clamp, the way
1473+
`vulcanus_biomes`' three clamped biomes are read.
1474+
1475+
**The corner fixture's `elevation` column is the TILE channel, and grading
1476+
`cliff_elevation` against it is a category error worth 60.6 tiles.** That is
1477+
issue #83 - `multisample`'s offsets are in the consuming program's grid units,
1478+
so the 4-tile cliff lattice and the 1-tile tile lattice read different values.
1479+
Both ports score the same 419 of 12,675 against it, because both read the right
1480+
field and the fixture holds the other one. The test now grades the TILE-channel
1481+
field (786 of 12,675, worst 4.393e-2, identical on both sides) and asserts the
1482+
two grids DISAGREE at 2,519 corners - turning #83 from a comment into a live
1483+
assertion. The gap is **sparse and large** rather than a uniform offset, which
1484+
is why the wrong channel cost seven points of recall instead of being obvious.
1485+
1486+
**The cliff pass needed THREE poison hooks, not one.** `crosses_cliff` returns a
1487+
tri-state classification a numeric hook cannot reach (`poison::crossing_result`,
1488+
which ROTATES rather than negating - negating `0` is `0`, the answer most edges
1489+
give, so a sign flip would leave most of the lattice untouched). And
1490+
`fixImpossibleCells` has no value to bend at all, only a choice of which edge to
1491+
clear, so it gets `poison::sweep_order`. Both have their own test in
1492+
`POISONED_TESTS`, because under poison the crossing hook moves every edge in the
1493+
lattice and the end-to-end test would be red whether or not the sweep had a
1494+
control.
1495+
1496+
**ABI v2's Vulcanus block grew 248 -> 280 bytes with NO version bump**, and that
1497+
is the per-planet split working rather than a shortcut: the prefix declares its
1498+
own block length, `BadParamsLength` refuses a writer that disagrees, and
1499+
Fulgora's request did not move a byte. A version bump is for a change to the
1500+
COMMON prefix, which every planet reads. The new field is the cliff
1501+
`cell_query_box`, four `f64`, and it is **sent rather than derived** - the halo
1502+
is asymmetric, its two directions CROSS, and it needs the FULL image's geometry,
1503+
which the prefix does not carry and only the tiled renderer knows.
1504+
`test/fixtures/verify-wasm-request.py` grew five more planted breaks for it, all
1505+
caught by its per-edge value check; its two structural checks (four distinct
1506+
edges, not inverted) constrain the FIXTURE rather than catching a break, and the
1507+
comment says so rather than claiming credit for the five.
14221508

14231509
**`vulcanus_stack` is TWO structs, and that is ownership rather than taste.**
14241510
`VulcanusBiomes`, `VulcanusElevation` and `VulcanusResources` all borrow the
@@ -1931,6 +2017,27 @@ time, under the greedy-accept rule.
19312017
source, profile and pinned toolchain give the same bytes and the same sha256 on
19322018
macOS/aarch64 and on an ubuntu x86_64 runner. That is why the gate can use
19332019
`cmp` instead of rebuild-and-retest.
2020+
- **A `engine.wasm` diff can be pure LINE NUMBERS, and a DOC COMMENT is enough
2021+
to cause one.** Seen twice while landing #225's cliff half: a 9-line struct
2022+
added to `vulcanus_resources.rs` moved 2 bytes (two `core::panic::Location`
2023+
line numbers for that file's `RefCell` borrow sites, 427 -> 436 and 469 ->
2024+
478), and a **19-line `///` block on its own** in `cliffs/placement.rs` moved
2025+
9 bytes - six Locations in that file, every one shifted by exactly 19. No code
2026+
byte moved either time and every section kept its exact size. So a
2027+
comment-only edit in a reachable file makes `verify-rust.sh` report "stale",
2028+
and that is the gate working rather than a false positive.
2029+
2030+
The fingerprint: tiny `cmp -l` count, every changed offset inside the `data`
2031+
section, all section sizes identical, and a `u32` delta equal to the lines you
2032+
inserted. **The trap is alignment** - the record is `{file_ptr, file_len,
2033+
line, col}` and it is NOT 4-byte aligned in the data image, so reading a `u32`
2034+
at `offset - (offset % 4)` gave "delta 4864" and looked like a moved string
2035+
table; realigned, the same field is 716 -> 735 and 4864 is just `19 << 8`.
2036+
Locate the record from its file pointer and length, not from alignment. The
2037+
build itself is deterministic - a no-change rebuild reproduces the bytes
2038+
exactly, checked while chasing this - so a diff after an edit is always the
2039+
edit.
2040+
19342041
- **The `poison` feature is the gate's anti-vacuity control, and it needs ONE
19352042
HOOK PER OP.** It perturbs an op's returned value; `verify:rust` builds with it
19362043
and asserts a **named list** of tier-1 tests goes red. The list is why: while

0 commit comments

Comments
 (0)