feat: WP-07 site and asset pipeline - #119
Conversation
WP-07 begins by making the separation structural rather than documented. The legacy descriptors mix collision, air, spawn and current data with features, visual zones, atmosphere and decoration rules in one object, so "change how a site looks" and "change where the diver can swim" were the same edit. scripts/generate-site-resources.mjs partitions the descriptors into two renderer-neutral documents and refuses to run if a descriptor grows a field that is in neither list. A new field must be deliberately classified, never silently dropped or silently promoted into collision. src/sites/site-resources.ts validates on import and throws rather than serving geometry that corrupts quietly: - lerpProfile walks points assuming ascending x. Unsorted input does not throw; it interpolates against the wrong segment and returns a plausible depth for the wrong place. - An inverted structure box can never be hit by solidAt, so a wall silently stops existing. tests/parity/site-geometry.test.ts replays floorAt, ceilingAt, solidAt, overheadAt and badAirAt against the real legacy source across a sampled grid of every site, including beyond the profile ends where clamping applies. That test earned its place immediately: the first implementation folded lerpProfile's division into its multiplication, which is algebraically identical and numerically is not, and it broke parity on two sites by one ULP. The operation order is now preserved deliberately, with a comment saying why.
The Pixi slice built its wreck from hand-written Graphics calls and read no
site data at all, which is the scaling limit WP-07 exists to remove. This adds
the data path without yet rewiring the renderer.
The manifest maps every authored feature kind to one asset, with an atlas, a
layer and a minimum quality tier. The mapping is hand-authored because it is
art direction, but completeness is enforced by test in both directions: a new
feature kind fails until someone assigns it an asset, and an orphaned entry
fails until someone removes it. Neither can rot quietly.
The layer factory turns presentation data into an ordered, culled placement
list and builds no Pixi objects, so it is testable without a GPU and a second
renderer could consume the same output. Draw order is sorted rather than
inherited from authoring order, so the same inputs always produce the same
scene. Quality tiers drop decoration before the things a diver navigates by.
Feature shapes are modelled as authored rather than as assumed: some are
points with a `d`, others span `dTop`/`dBottom`, and kinds carry their own
extra fields. The first version assumed a uniform `{kind, x, d}` and the
typechecker rejected it against the real data.
WP-07's acceptance criterion now has a test: re-pointing an asset at a
different atlas and frame leaves every gameplay value byte-identical, and the
two documents are asserted structurally disjoint so an art edit cannot reach
collision again.
The renderer added its children in a flat list, so draw order was an accident of call order and there was nowhere for authored content to go. It now owns named layer containers matching the manifest, and populates them from the layer factory. Placement markers are pooled. Which features are visible changes many times during a dive, and allocating a Graphics per feature per resync would churn the heap for a scene whose contents barely change. Resync is also skipped until the camera has actually travelled, rather than running every frame. Markers are deliberately plain shapes. Production atlases are BLOCKED_EXTERNAL, so this draws a placeholder rather than art guessed at here; the manifest already fixes the atlas and frame contract they will load through, so substituting real textures does not touch this file's structure. The hand-authored hull, seabed and engine stay as they are. They are bespoke art for one scene, not authored site content, and replacing them with markers would degrade the slice to prove a point that the decoration layer already proves.
One question worth settling before this merges: which side of the boundary do props live on?Tracked as #123. Not a defect in this PR — a question about the contract it establishes, raised here because this is the PR that locks it in (the generator's classification lists, the schema, and the disjointness test all land in this diff). What the split currently decides
That is the PR working exactly as designed, and the guarantee is a good one. The consequence is just worth naming explicitly. Why it matters nowThose props currently have no collision at all. Verified on
For the cave columns this is a documented choice — // Neither introduces collision — solidAt() only tests the AABB
// structures list, and neither column is added there.The two ways this resolves
Either answer is fine. I'd just rather it be a decision than a default, since after merge "give this car a hitbox" stops being a one-line edit next to the car and becomes a deliberate gameplay-side entry. Unrelated note on the same subsystem
Context: this came out of re-verifying the findings in |
The generator and the parity test each hardcoded MAX_DEPTH as 100. The real value in src/constants.js is 300, and sites.js uses it for the reef descriptor, so the generated resources were wrong: reef.maxDepth, both floor abyss endpoints and the blue-water zone all landed at 100 instead of 300. The parity test could not catch this. It injected the same literal into the legacy source that its own reimplementation used, so both sides agreed at 100 and every assertion passed against data that does not match the running game. Reverting reef to the old values now fails that test with 480 mismatches. Both sites read the constant from constants.js and fail loudly if the declaration moves. It cannot be evaluated into the vm sandbox — constants.js declares MAX_DEPTH with const, which never becomes a property of the context object — so the value is extracted from the source text. Also adds `npm run sites:check`, wired into both workflows, so an edit to sites.js that skips regeneration fails CI rather than leaving the resources quietly stale. It compares only the `sites` payload; sourceCommit moves with every commit and would otherwise fail on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pushed a fix:
|
| Value | Runtime | Generated |
|---|---|---|
reef.maxDepth |
300 | 100 |
reef.floor endpoints (x = ±26, ±200) |
300 | 100 |
reef_blue_water zone d2 |
300 | 100 |
Verified against the running game rather than inferred — DIVE_SITES.reef.maxDepth is 300 in the browser.
Why the parity test passed anyway
It injected the same literal into the legacy source that its own reimplementation used:
const MAX_DEPTH = 100;
const legacy = new Function("MAX_DEPTH", `…${legacySource}…`)(MAX_DEPTH);
function floorAt(site, x) { return Math.min(MAX_DEPTH, lerp(site.floor, x) ?? MAX_DEPTH); }Both sides agreed at 100, so every assertion passed against data that does not match the running game. This is the one class of bug the suite exists to catch, so it seemed worth fixing properly rather than just correcting the number.
The fix
Both sites now read the constant from constants.js and throw if the declaration moves or becomes computed.
Worth noting for anyone touching this later: it cannot simply be evaluated into the vm sandbox. constants.js declares MAX_DEPTH with const, which never becomes a property of the context object — sandbox.MAX_DEPTH comes back undefined. I confirmed that before picking source-text extraction.
Also added: npm run sites:check
There was no guard that the committed resources match a fresh regeneration. Added a --check mode to the generator, wired into pr.yml and deploy.yml, so editing sites.js without regenerating fails CI instead of leaving the resources quietly stale.
It compares only the sites payload — sourceCommit moves with every commit, so a plain git diff --exit-code would have failed on every run.
Verification
Checked both directions, since a guard that only passes proves nothing:
| Check | Result |
|---|---|
sites:check on corrected data |
passes |
sites:check with sites.js perturbed (mast dBottom 18→19) |
fails, exit 1 |
test:parity on corrected data |
28 passed |
test:parity with reef reverted to the old values |
fails, 480 mismatches |
That last row is the point — it's the exact data that passed before this commit.
Full suite green locally: typecheck, lint, build, test:unit (67), test:parity (28), test:e2e (22).
Left alone
Object.freeze(sites) at site-resources.ts:108 is shallow — nested structures / floor arrays stay mutable. Real but minor, and a deep freeze risks breaking something in an otherwise-green PR, so I didn't widen the diff for it. Worth a follow-up.
The sampling grid in the parity test is fine as-is — I initially suspected it was too narrow, but x = 26 is inside the sampled range and carries a MAX_DEPTH floor point, so it already exercises the corrected values. The shared constant was the whole problem.
Two defects in the new site modules, both found while reviewing for merge. Culling compared the camera window against `featureDepth()`, which is a feature's anchor — for a span it returns `dTop`. The cave columns span dTop=56..dBottom=98 and dTop=60..dBottom=95, so a diver swimming toward the cathedral floor left the anchor's window while the columns still filled the screen: at depth 90 one of the two disappeared, at 95 both did. Culling now does an overlap test against the whole extent via `featureDepthRange()`. Anchoring is unchanged; it was only ever culling that needed the span. `Object.freeze` is shallow, so freezing the site documents left every nested `structures`, `floor` and `features` array writable. The immutability both resource modules advertise was nominal — a consumer holding a reference could have mutated collision geometry in place for everyone else. `deepFreeze` is its own module because the gameplay and presentation resources must not import each other. Both are covered by tests that fail without the fix: the span tests read 1 and 0 columns against the expected 2, and the immutability tests see unfrozen nested arrays. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two more fixes pushedFound on a closer read of 1. Tall features were culled by their anchor, not their extent
With
At 95 m both 40 m columns had vanished while the diver was alongside them — they extend to 98 and 95. Culling is now an overlap test against the full extent via a new Latent today, since only 2.
|
| Test | Without the fix |
|---|---|
keeps a tall feature while any part of it is on screen |
fails: 1 and 0 columns vs expected 2 |
refuses in-place mutation of nested gameplay geometry |
fails: nested array not frozen |
refuses in-place mutation of nested presentation features |
fails: nested array not frozen |
Full suite green locally: typecheck, lint, build, sites:check, test:unit (72), test:parity (28), test:e2e (22).
One thing I did not change
Horizontal culling still uses feature.x alone, ignoring wTop/wBot on the cave columns (9–11 m wide). With a 34 m half-window plus 10 m margin, a 5.5 m half-width is far inside the margin, so it cannot cause the same popping. Inventing a general width convention from two kind-specific fields felt like the wrong call for this PR — flagging it rather than doing it.
Unrelated red check
Terraform Cloud/Infrastruktur flipped to failure on my first commit. This PR changes no terraform files (git diff main...HEAD -- terraform/ is empty), the same check passes on main, and nothing in terraform/ references anything I touched — so it looks transient or provider-drift related rather than caused here. Possibly connected to the pending Cloudflare v5 migration in #23. Worth a re-run.
…t order Addresses four review findings on WP-07. [P1] The committed JSON Schema had no caller, and presentation data had no schema at all. validateSiteGameplay() checks geometry relationships, not types, so a descriptor typo producing hasOverhead: "false" passed generation, parity, typecheck and CI while changing behaviour — the string is truthy, so an overhead site silently becomes one the diver can surface from. Adds a presentation schema and a suite that compiles both with Ajv and asserts they reject stringly-typed values, negative depths, unknown fields and bad provenance. This is what the "invalid site data fails at build/test time" criterion needed. [P2] sourceCommit was false provenance and could not be otherwise: generation stamps `git rev-parse HEAD`, which always names the commit before the one carrying the generated file. The shipped resources claimed 4046bda, whose sites.js produces the pre-fix MAX_DEPTH values. Replaced with sourceDigest, a sha256 over the actual inputs (sites.js plus the MAX_DEPTH it reads), which is a claim that can be true — and sites:check now verifies it instead of skipping it, so stale provenance fails CI like stale data does. [P3] The asset-replacement acceptance test built a `swapped` entry and never passed it anywhere; buildSceneLayers() resolved the unmodified manifest, so the test passed regardless of whether replacement worked. Rewritten in its own file with the manifest module mocked, so the swap actually reaches the layer factory, plus a guard that fails if the mock stops taking effect. [P4] The layer refactor put silt in `terrain`, below `structure`, where it had previously been drawn after the hull and route. Silt occupies y=34.2..35.4 and the hull's opaque fill covers down to y=35 across x=14..103, hiding 31 of its 48 particles. Moved to `foreground` between route and diver, restoring the pre-refactor draw order exactly. Each fix is verified by reintroducing the bug: the schema suite fails on an injected string, sites:check fails on a stale digest, the replacement test fails when placements stop reading the resolved asset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four review findings confirmed and fixed —
|
| Reintroduced bug | Result |
|---|---|
hasOverhead: "false" injected into gameplay.json |
schema suite fails |
sourceDigest replaced with a stale value |
sites:check exits 1 |
| placements stop reading the resolved asset | replacement test fails |
silt returned to terrain |
31/48 particles inside the hull polygon |
Green locally: typecheck, lint, build, sites:check, 84 unit (was 72), 28 parity, 22 e2e.
Not addressed
The screenshot gate still would not have caught P4, as you noted — it only covers the legacy client. That is the PR's own declared gap, and extending pixel comparison to the Pixi client is follow-up work before WP-08 rather than something to bolt on here.
The digest I added in 38b26b7 hashed raw file bytes, which made it depend on the checkout platform. With core.autocrlf a Windows working tree holds CRLF where Linux CI holds LF — identical content, different bytes — so the value generated on Windows (9c6a4125…) disagreed with the one CI computed (58e238f5…) and sites:check failed the build for a machine difference rather than a data one. Normalise CRLF to LF before hashing. The generated resources never depended on line endings, so their provenance must not either. The regenerated digest now equals the value CI computed, and hashing all-CRLF and all-LF copies of sites.js produces the same result. Only the digest line changes; the sites payload is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught a flaw in my own fix — corrected in
|
Two schema gaps from review.
[P1] `entry` was declared as a bare `{ "type": "object" }`, was not required,
and was missing from SiteGameplay entirely — so `entry.x = "not-a-number"`
passed Ajv, typecheck and CI. Parity would not catch it either: the suite
compares floor, ceiling, solid, overhead and bad air, never entry points. Now
a closed `{ x: number }`, required on every site, and exposed on the
TypeScript contract as SiteEntryPoint.
[P2] The decoration rule was left open and declared 7 of the 14 fields
renderer.js actually reads. This matters more than an absent field would,
because every guard is shaped `rule.f != null && x < rule.f`: a string makes
the comparison NaN-false, so the guard stops guarding and props render at
every depth. The wreck already authors minDepth: 20, which is exactly that
path. Declared minDepth, maxDepth, minScale, maxScale, maxPerScreen,
rotationJitter and alpha with numeric constraints, and closed the rule, its
props entries, visual zones and atmosphere profiles against unknown fields so
a typo'd name fails validation instead of being read as undefined and
defaulted away.
Verified by re-running the probe that demonstrated both gaps: every case that
previously passed Ajv now fails, and the shipped documents still validate.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both confirmed and fixed —
|
| Case | Before | After |
|---|---|---|
entry.x = "not-a-number" |
passes | fails |
entry removed |
passes | fails |
entry = {totallyBogus:true} |
passes | fails |
minDepth = "not-a-number" |
passes | fails |
| unknown field on a rule | passes | fails |
Shipped documents still validate. Schema tests 8 → 24; unit total 100 (was 84). typecheck, lint, build, sites:check, 28 parity, 22 e2e all green locally.
Follows a full read of pixi-renderer.ts, which I had only skimmed before. The layer refactor said draw order was "a declared property of the scene rather than an accident of call order", but it was a sequence of addChild calls that nothing could read and nothing could assert — which is how silt ended up below the hull. RETAINED_LAYER_ASSIGNMENT moves it into data, and site-layers.test.ts asserts the invariants: things between the camera and the wreck are painted after it, the wreck is painted after the seabed it rests on, and the layer list runs furthest to nearest. Moving silt back to `terrain` fails two of them. Also fixes a latent ordering defect found in the same pass. buildSceneLayers sorts placements within a layer so the same data always yields the same scene, but the renderer skipped the re-add for a pooled marker reused in the container it already occupied. Pixi's addChild splices an existing child out and pushes it to the end, so that guard was skipping the reorder: the sort stopped being reflected in the display list after the first resync. Invisible while every marker is the same provisional circle, and would surface as soon as real atlas frames overlap. Unit tests 100 -> 114. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep review of
|
…erences [P1] `ceiling` was absent from the schema's required list although SiteGameplay requires it and every site declares it. Ajv accepted the cave with the field deleted, which drops ceilingAt() through to 0 — the cave roof goes from 14 m to 0 m at x=50 and the diver swims up through it. Parity cannot see this: legacy and generated data lose the field together and agree on 0. Now required, with null spelled out for open water. [P2] Culling used a fixed 20 m half-height. The camera fits a constant 58 m of WIDTH, so visible height is viewport.height / scale and scales with aspect ratio: at 390x844 the screen shows ~125 m of depth against a 30 m cull window including margin. Four on-screen placements were dropped at any camera position — the engine row at d=61 and the anchor at d=66 — while the diver was looking at them. Desktop and landscape hid it because their windows are shorter than the constant. The window is now derived from the camera, and resize invalidates the sync so an orientation change re-populates instead of waiting for 4 m of travel. [P3] The schema could say `rule.zone` is a string but not that the zone exists. A misspelled reference matched no candidate, so an entire scatter of props silently never rendered. validateSitePresentation() checks zone references, atmosphere-profile keys (also zone ids), duplicate zone and rule ids, and inverted zone extents, and throws at import time like the gameplay resource does. The first version of the P2 test passed with the bug reintroduced: it computed "on screen" from the same half-extents it culled with, so the two agreed with each other. The cull window now comes from a shared helper while the visible rectangle is computed from viewport arithmetic, and pinning the helper back to the constants fails four assertions instead of one. Unit tests 114 -> 133. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three confirmed and fixed —
|
| Viewport | Visible depth | Culled to (incl. margin) |
|---|---|---|
| 390x844 | -37.8…87.8 m | -5…55 m |
| 320x568 | -26.5…76.5 m | -5…55 m |
| 1280x800 | 6.9…43.1 m | -5…55 m |
| 844x390 | 11.6…38.4 m | -5…55 m |
Your three examples land at focus.x=20. Sampling across the wreck, it is 4 on-screen placements dropped at any camera position — the engine row at d=61 plus the anchor at d=66 near the bow, engines further aft elsewhere. Desktop and landscape hid it because their windows are shorter than the constant, which is presumably how 20 m looked reasonable.
Fixed both parts you named: the window is derived from the camera via a shared visibleHalfExtentM(), and resize() now clears #lastSyncFocus so an orientation change re-syncs instead of waiting for 4 m of travel. mount() also sizes before its first sync, which previously ran against the placeholder 1x1 viewport.
[P3] Cross-references unvalidated — confirmed
"typo-zone" passes Ajv; the zone does not exist. Shipped data is currently clean (no dangling refs, no duplicate ids, no inverted extents), so this guards future edits rather than fixing present ones.
Fixed with validateSitePresentation(), throwing at import time like the gameplay resource. It covers the three you listed plus one more: atmosphereProfiles is keyed by zone id — all 27 keys across the four sites resolve to zones — so an unresolved key is an atmosphere that silently never applies.
A correction worth flagging
My first version of the P2 test passed with the bug reintroduced. It computed "on screen" from the same half-extents it used for culling, so the two agreed with each other regardless of what either was. That is the same self-consistency failure as the original MAX_DEPTH bug — I caught it only because I ran the negative pass.
Rewritten so the cull window comes from the shared helper while the visible rectangle is computed from viewport arithmetic. Pinning the helper back to 34/20 now fails four assertions:
× covers everything on screen at 390x844
× covers everything on screen at 320x568
× never culls inside the visible rectangle, at any aspect ratio
× grows the window as a portrait viewport gets taller
Verification
| Reintroduced | Result |
|---|---|
ceiling removed from required |
schema suite fails |
| cull window pinned to the old constants | 4 layer assertions fail |
rule pointing at typo-zone |
import throws, naming the rule and the zone |
Unit tests 114 -> 133. typecheck, lint, build, sites:check, 28 parity, 22 e2e green locally.
presentation.json shows in the diff with zero changed lines — line-ending normalisation only. The digest hashes normalised endings, so it is unaffected.
Closes #127. Required before WP-08 starts moving real scenes. compare-rendering.mjs gates the legacy canvas client only, so nothing watched the Pixi one. The worked example is the silt regression in #119: silt assigned to the `terrain` layer, below `structure`, put 31 of its 48 particles behind the hull's opaque fill and passed lint, typecheck, unit, parity and e2e. It was found by reading the diff. Reference frames AND scene statistics, because neither is sufficient. Verified rather than assumed: reintroducing the silt regression fails all three scenes on frame delta (max channel delta 35 against a budget of 24) while no statistic breaches at all. Silt behind the hull and silt drawn correctly produce nearly identical frame-wide numbers, which is exactly the case statistics cannot see. Committed PNG references are viable here because Playwright's headless Chromium rasterises through SwiftShader — software, not the GPU — so captures are byte-identical across runs and across browser processes. Measured before building anything: 0 differing bytes. Whether that holds across operating systems is what CI will now tell us; if it does not, the guard says so rather than quietly passing. Determinism needed more than seeding Math.random. GameController.#tick takes the requestAnimationFrame timestamp and advances both the diver's position and `elapsedRealS`, which drives the bubbles, by however long the frame really took — so waiting a fixed number of milliseconds does not reproduce a scene. The first version of this guard failed against its own freshly recorded references with a max channel delta of 128. rAF is now replaced by a queue the harness steps explicitly, with a virtual clock advancing exactly one 60 Hz frame per step, and scenes are driven in frames rather than milliseconds. Two things worth knowing for anyone extending this: - Statistics are computed from the decoded screenshot, not by reading the live canvas back. Drawing a WebGL canvas into a 2D one returns transparent black without preserveDrawingBuffer, and the first version recorded meanLuma 0 for every scene — zero compares equal to zero, so that half of the guard would have passed whatever the renderer did. There is now an explicit check that refuses to record a uniformly black frame. - The capture pins locale to en-US. The boundary copy is localised and the button is matched by name, so without it the run fails on a German desktop and passes in CI. References live in tests/fixtures/reference-frames/pixi/ and are re-recorded deliberately with `npm run pixi:visual-update`, so an intended change lands in the diff as new frames for review rather than as a raised threshold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #127. Required before WP-08 starts moving real scenes. compare-rendering.mjs gates the legacy canvas client only, so nothing watched the Pixi one. The worked example is the silt regression in #119: silt assigned to the `terrain` layer, below `structure`, put 31 of its 48 particles behind the hull's opaque fill and passed lint, typecheck, unit, parity and e2e. It was found by reading the diff. Reference frames AND scene statistics, because neither is sufficient. Measured rather than assumed: reintroducing the silt regression fails all three scenes on frame delta (max channel delta 35 against a budget of 24) while no statistic breaches at all. Silt behind the hull and silt drawn correctly produce nearly identical frame-wide numbers. References are keyed by platform. The first version assumed SwiftShader made captures portable, since it is a software rasteriser and captures were byte-identical across runs and browser processes on one machine. CI disproved that: win32-recorded frames differed from linux by a max channel delta of 230 across 10.6% of pixels. Loosening the budget to absorb that would take it past the silt regression's delta of 35 — a guard reporting green while the defect it exists for ships — so each environment compares against frames recorded where it runs. The linux set was recorded by CI itself. Determinism needed more than seeding Math.random. GameController.#tick takes the requestAnimationFrame timestamp and advances both the diver's position and elapsedRealS, which drives the bubbles, by however long the frame really took, so waiting a fixed number of milliseconds does not reproduce a scene. The first version failed against its own freshly recorded references at a delta of 128. rAF is now a queue the harness steps explicitly on a virtual 60 Hz clock, and scenes are driven in frames. Four silent-failure traps found and closed while building it: - Statistics read back from the WebGL canvas returned transparent black without preserveDrawingBuffer, recording meanLuma 0 for every scene. Zero compares equal to zero, so that half of the guard would have passed whatever the renderer did. Statistics now come from the decoded screenshot, and a uniformly black frame is refused outright. - A scene named `wreck-torch-on` captured the torch OFF, because the controller starts with it on and pressing `t` disabled it. Scenes now declare the state they exercise and assert it before capturing; a mislabelled one fails instead of recording cleanly forever. - Both commands consumed dist/ without building it, so a check could report 0% against an older build and an update could record references from stale code. Both have build prehooks now. - A breach writes <scene>.actual.png and tells the reviewer to look at it, but CI discarded it with the runner. Both workflows upload it on failure, which matters because platform-keyed pixels cannot be reproduced on another OS. Locale is pinned to en-US: the boundary copy is localised and the button is matched by name, so without it the run fails on a German desktop and passes in CI.
Moves dive sites to validated, renderer-neutral resources with an asset manifest, layer factories, culling, pooling and quality tiers, per
Codex_Review.mdWP-07.Both acceptance criteria have passing tests
"Invalid site data fails at build/test time."
src/sites/site-resources.tsvalidates on import and throws, targeting the failures that are silent rather than loud:lerpProfilewalks points assuming ascendingx. Unsorted input does not throw — it interpolates against the wrong segment and returns a plausible depth for the wrong place.solidAt, so a wall quietly stops existing."One asset can be replaced without changing simulation or collision data." Re-pointing an asset at a different atlas and frame leaves every gameplay value byte-identical, and the two documents are asserted structurally disjoint so an art edit cannot reach collision again.
The separation is structural, not documentary
src/sites.jsmixed collision, air, spawn and current data with features, visual zones, atmosphere and decoration rules in one object — so "change how a site looks" and "change where the diver can swim" were the same edit.The generator partitions them into two documents and refuses to run if a descriptor grows a field in neither list. A new field must be deliberately classified, never silently dropped or silently promoted into collision.
Parity is proven against the real legacy source loaded via
?raw, not a snapshot, replayingfloorAt,ceilingAt,solidAt,overheadAtandbadAirAtacross a sampled grid of every site including beyond the profile ends where clamping applies.The parity test earned its place immediately
The first implementation folded
lerpProfile's division into its multiplication:Algebraically identical, numerically not. It moved results by one ULP and broke parity on two of four sites. The operation order is now preserved deliberately, with a comment saying why.
The typechecker caught a second assumption:
SiteFeaturewas modelled as a uniform{kind, x, d}, but real features include depth spans (dTop/dBottom) and kind-specific extras.Renderer
The Pixi renderer added children in a flat list, so draw order was an accident of call order. It now owns named layer containers and populates them from the layer factory, with pooled markers and resync skipped until the camera has actually travelled.
Two deliberate limits:
BLOCKED_EXTERNAL, so this draws something obviously provisional rather than art guessed at here. The manifest fixes the atlas/frame contract, so substituting real textures will not touch the renderer's structure.Known gap, worth raising before WP-08
The visual risk in the renderer change is unguarded.
compare-rendering.mjsgates the legacy client only, so nothing here would catch a Pixi visual regression. This was verified structurally — build, e2e, layer assignment — but not visually.Extending the pixel comparison to the Pixi client is the natural next work, and I would want it before WP-08 starts moving real scenes.
Verification
npm ci,npm run lint,npm run typecheck,npm run build, andnpm testall pass. No change to the legacy client, the numerical model or the golden traces.