Repository navigation
feat: Machinefall 120 fps runbook — PRD-492 grading, PRD-493 terrain layers, and the following rows - #437
feat: Machinefall 120 fps runbook — PRD-492 grading, PRD-493 terrain layers, and the following rows#437jonit-dev wants to merge 138 commits into
Conversation
… (PRD-493 phase 1) Sixteen same-size uncompressed layers bound one sampler each, which is what makes WebGPU's 16 sampled textures a stage the real cap on Android and iOS — the hosts with no KTX2 transcoder, where nothing stacks today. Each set now becomes one DataArrayTexture whose layers are filled by a renderer.copyTextureToTexture out of their own textures: the pixels never come back to the CPU, and the array is created with source.dataReady = false so three never uploads placeholder bytes over the copies. Mixed sizes still fall back, and TN_TERRAIN_SPLAT layers= samplers= stacked= reports what the material actually costs, one line per material. Also corrects the stale @constraint: the recipe is export_terrain_layers in export_world.py, and the cap is now a sampler count the marker names. capabilities.json and the generated reference carry both.
The material hard-coded metalness 0 / roughness 0.92 and gave every layer it, so a Megascans-grade layer's own occlusion, roughness and metalness never reached the screen. A layer with "orm": true in the table now reads <id>_orm.jpg as linear data (r occlusion, g roughness, b metalness) through the same stacked array and the same blend weights as its colour; a layer without one uses the roughness and metalness the table states. A layer that states neither now throws and names the recipe, rather than falling back to a number in the package — the constant is gone. The recipe writes _orm beside _diff and _nrm in both copies of export_world.py, and refuses a table whose ORM layer names no orm source.
…t (PRD-492 Phase 1)
The starter's frame now ends in a colour grade and a film grain, both authored
stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier
and `public/grade.cube` is the table. Replacing that one file with a `.cube` from
any grading tool is the whole of "change the grade".
The filed design was a bijective log shaper inside the stage. It is measured and
dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family
over the frame's range found nothing under 2.0 8-bit steps per channel (the filed
W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in
light into several codes. So the fallback this PRD already named ships instead: a
stage may declare `afterOutputTransform`, the chain then installs the graph with
the renderer's automatic transform off and the stage applies three's own
`renderOutput` first — what three's FXAA example does. An identity table there is
within 0.875 of a step over every value the frame can hold, which is the PRD's own
box, and a `.cube` now means what a grading tool means by it.
Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a
third argument on `setOutputNode`, and the chain handing the renderer
`outputColorTransform: false` only when such a stage actually ran — a dropped one
would leave the frame untone-mapped with nothing to have applied it.
Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and
packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter
(webgpu, headed, adapterClass hardware): renderChain tier/includes/order/
contributions all pass with grade and grain applied; the tier-low leg on a
throwaway copy with the tier pinned reads `dropped: [{"name":"grain",
"reason":"grainIntensity:0"}]` with grade still applied.
Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of
16 ungraded — no delta to read) and the frame half of the identity round trip (the
starter's own animated content moves between runs by more than the claim; the same
build twice reads maxChannelDelta 114).
The pre-push drift gate refused the push on a formatter diff biome had already reported once; this is the same one line, formatted.
… captures
Unit specs prove the mechanism in node; this example proves it on a real
WebGPU adapter. The package is sixteen layers with an albedo, a normal and an
ORM map each — 48 uncompressed JPEGs — written by the shipped
export_terrain_layers recipe from a table and map files a game would author,
so the thing under test is a DCC export, not a second format.
The run is green: TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3, 180 frames,
a non-blank changing viewport, and the scenario asserts those numbers through
material.userData rather than trusting a screenshot. The same package on
develop never reaches a frame:
Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the
Fragment stage exceeds the maximum per-stage limit (16).
Captures for the judge, same package and same pose:
.afk/scratch/prd493-captures/phase-1-2/{before,after}.png (4 KB uniform vs a
textured 16-band surface). Not a Machinefall result, so AC-1 stays open.
…hat could not run (PRD-492 Phase 2)
Phase 2's second box passes: `pnpm native:build` (409/409), then
`pnpm parity --target desktop --only-tests lut-grade` reports status pass with
`pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU
validation errors, against a web-lane reference capture of the same scene. The
table is a 17^3 per-channel gain rather than the identity, so a host that ignored
the upload or substituted a constant fails it, and the scene reads the parsed
Data3DTexture back in JS against that formula before the frame is compared. The
sweep exits 2 because a bounded --only-tests run blocks the other 95 rows.
Phase 2's first box stays open, and the reason is the packager rather than the
grade: the desktop run's marker reads `dropped: [{"name":"grade","reason":
"lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read
error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing
else, so a public/ file the manifest does not declare never reaches the host —
every unmanaged public file on every native target, not this table alone. Fixing
the asset selection is mechanism work outside this PRD's filed file list, so it is
recorded rather than done here.
The scenario also could not run at all before: its `visibility` assertion is
refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed
before reading a stage. That row is dropped — the runner never produced the
observation on this target — and `grade`/`grain` are added to the stages and
contributions the scenario does read.
Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files)
and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a
browser session and touches nothing in this change), and the create-threenative
scaffold and looks contracts.
The browser lane proves the stacking; the hosts that matter most are the ones with no KTX2 transcoder, and there nothing stacked before. This gives the example a native identity (threenative.config.ts, one description read by both the web dev server and `threenative build`) and the same assertions as a desktop scenario: sixteen layers, at most four samplers, three stacked sets. orbitSeconds is new because a native run that renders one frozen frame would otherwise satisfy every cost assertion; the camera pose has to advance. --target desktop on the built host: pass, layers=16 samplers=4 stacked=3, orbitSeconds 2.03 -> 5.03, diagnostics clean, 0 console errors. The web scenario with the same assertion is pass on the software-and-hardware WebGPU recipe; world-terrain-splat.spec.ts 11/11.
The hosts this PRD is for cannot see KTX2, so nothing stacked there, and nothing proved it: the browser lane cannot fail on a path the browser never takes. terrain-splat-array builds a sixteen-layer package with an albedo, a normal and an ORM map per layer — the shape Android and iOS ship — through loadTerrainSplat, and the scene throws unless the marker reads layers=16 samplers=4 stacked=3, so the row fails on a regression to one sampler per map rather than merely looking different. The 48 maps are DataTextures derived from a pure function of their cell and the mask grid gives each layer its own region, so both lanes derive the same frame from nothing but the scene file: the desktop host matches the browser reference at pixelMismatchRatio 0 and perceptualDeltaE 0 over 1280x720. The Android leg of the row is left open, not claimed: :app:verifyV8Dependency refuses to bundle without a V8 build receipt that only an hours-long source cross-compile writes, and the emulator was up and waiting when it stopped. Gates: parity --target web pass (reference captured); parity --target desktop pass, 0/0 metrics, 0 gpu validation errors; runtime-native conformance suite 76/76; pnpm typecheck and pnpm lint exit 0; pnpm check:docs exit 0.
A blind judge compared the starter WITH this PR's grade and grain against
WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey
concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast;
grain clearly visible as speckle over sky and flat walls and behind HUD text".
The owner's rule is that a default must never degrade the look, so both
numbers move and the spec says so red-first.
The pivot was the larger half and nothing had measured it. A contrast curve
anchored at 0.18 leaves everything above it brighter: on the captured frame
that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what
"washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread
13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame
keeps more separation than the ungraded one, and the table still lifts no
black. grade.spec.ts pins all three claims and is red on the old table.
grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by
the pixel's own value, so 1/255 is under half a step on a mid-grey surface —
below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps
naming grain as APPLIED at high, which is this PRD's own ticked box; low
still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the
values to raise it to.
The desktop box passes now, and the packager was never at fault. Phase 2 filed
"the staging loop copies selectManifestAssets(assets) and nothing else, so an
undeclared public/ file never reaches the host"; the selector's rule 3 keeps
everything no cook produced and its spec already proved that. The scaffold that
run used had no public/grade.cube in it at all — the AC-2 before-arm deletes
that file — so there was nothing to stage. With it present: pnpm build --target
desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909
bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json
exits 0 with all six assertions true at adapterClass hardware and a non-blank
screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now
covers a hand-placed file whose extension no asset pipeline emits.
Also recorded: a browser run that reports swiftshader/google is a CPU
rasteriser's picture of a hardware look (this lane's frame came back flat grey
with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not
prevent it — the runner launches chrome-headless-shell. Every capture ran
--headed, on turing/nvidia, at tier high.
Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table),
scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI
contracts 241, pnpm typecheck clean, biome clean on every touched file,
pnpm check:docs clean.
Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/
{before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x).
Measured between the pair the grade now moves the frame by rmse 2.22 codes and
-0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9.
Carries develop's WebGPU adapter-retention patch, the terrain-jobs work and the CI scope changes. One conflict: the scaffold tree hashes, which develop restated for its own merge and this branch restated for PRD-492's stage seat plus the neutralised starter default. Resolved to the merged-tree measurement — all thirteen trees re-measured through actual createProject trees on this tree, `scaffold.spec.ts` 66 passed.
…is lane Takes the develop CI merge that had already landed on the PR branch. Two conflicts, both resolved to this lane's newer text: the scaffold tree hashes (re-measured through actual createProject trees on this tree, `scaffold.spec.ts` 66 passed) and the PRD (the remote side was the pre-neutralisation file).
Merging PRD-493 under PRD-492 moved every no-install scaffold hash: the rewritten `capabilities.json` entry and `capability-reference.md` reach a scaffold verbatim. Measured through actual createProject trees on this merged tree, which is what the table already claims to hold.
… branch 16 layers / web reference / native desktop
…ld gate. Walk sheet: LEFT = develop, RIGHT = previous build, frames 4/12/23/31. Pose start: previous build (trees missing, shadows only) vs develop (fixed).
Screenshot index and visual verdicts (comments above, in order)
Walk timing, develop, RTX 2080, 3 runs:
World gate: the start pose is a WIN (previous build 2 → develop 4; the missing-trees-with-shadows defect is fixed). The tool still says Open: a 5-run rerun with nothing else using the GPU, to confirm the GPU p95 margin (0.02 ms) and to explain one 8.7 s frame gap. |
Three's WebGPU backend issues one queue.writeBuffer per update range
and never merges them; the GPU scene added one range per placement and
key, so admitting a cell turned into one write per placement inside
renderer.compute. addRange extends the last range when the new one
touches or overlaps it. The early-route compute span was p95 2.5-5.2 ms.
Red-green: world-gpu-scene.spec.ts describe("addRange") (red:
addRange is not a function). World specs: 12 files, 213 passed.
Change and spec by the Haiku arm.
VirtualShadowNode#pollCasters invalidates a level when a caster's visible flag flips. TerrainTiles swaps visibility between a tile's level meshes and its merged block whenever a block rebuilds: one Machinefall walk saw 4,500 flips, 3,568 of them terrain level meshes, and the map re-rendered about four times a second (200+ per walk, 5-20 by move). terrainMesh marks its meshes tnShadowSwap and casterFlag ignores their visible bit; castShadow and mainAdmitted still count, and the next window move redraws a LOD step's resolution difference. Red-green: virtual-shadow.spec.ts (red: expected 3 to be +0) and world-terrain-tiles.spec.ts (red: expected undefined to be true). 22 spec files, 432 passed. Change and specs by the Haiku arm.
VirtualShadowNode#onTreeChanged asked every level covering any mesh that entered or left the tree. Bucketing one Machinefall walk's 3,800 region invalidations by caller: 1,409 came from TerrainTiles block meshes (swap twins over the same ground), 1,025 from WorldCells admissions (largely meshes that cast nothing), 391 from bundle/group re-records; the level redrew at the 0.25 s cap, 200+ times per walk. A mesh that casts nothing or carries tnShadowSwap now returns early on the tree-change path; a later castShadow flip is still the poll's. Red-green: virtual-shadow.spec.ts "should not redraw cached maps for an arriving mesh that casts nothing or is a swap twin" (red: expected 3 to be +0). 24 spec files, 460 passed.
The cook skipped any mesh whose node or mesh name carries a level token (`hull_LOD1`) as hand-authored LOD. A Fab pack's `_LOD2` exported as a model of its own has no sibling level, so nothing selects it and the skip left it at full detail. On Machinefall map-walk these trees (`*_far_0_001`, mesh `*_far_LOD2`) are about 9.4 M of the 9.8 M main-pass triangles in the early route, and the adaptive LOD bias cannot coarsen a slot with one level. An asset is an authored chain only when it holds two or more level-named nodes. LOD_GENERATOR_VERSION goes to 4 so a stale cook cache cannot serve the old skip.
Three quiet runs (load under 10 at start and end) still fail AC-1 and AC-2 in the first route window only. That window is main-pass triangles from single-level trees, fixed by d65fa1e; PRD-538 no longer gates AC-2.
|
PRD-478 progress: on a quiet RTX 2080, AC-1/AC-2 failed only in the first route windows. That cost is the main pass drawing single-level Fab |
…that does The GPU shadow keys dropped a placement whose main level has no twin, so a chained tree past its first switch left a hole in the shadow map. Casting every level instead (PRD-539's first answer) kept the shadows but minted a twin per level; a walking trace showed the churn as ~10x updateWorldMatrix, ~35x addEventListener and ~4x garbage collection against castLevels 1. The shadow level is now the last casting level in [0, max(drawn, base)]: the drawn level when it casts, otherwise the coarsest one below it, floored by the map's base as before. Reference and kernel agree. world-gpu-scene.spec.ts: red `expected [ +0, 3 ] to deeply equal [ +0, 1, 3 ]`, then green; world-gpu-scene + world-shadow-gpu-keys 100 passed plus 1 load timeout that passes alone (26.5 s).
The owner asked for one PR. Net diff of feat/prd-539-card-coverage against its develop base, applied cleanly: - a lone LOD-named mesh gets a generated chain (assets eligibility); - card levels index scaled copies of their kept cards, and the model pass's self-check counts what LOD0 draws; - a chained level past castLevels casts through the coarsest casting level's casters on the cluster path (this branch's ce8c4de does the same for the GPU shadow keys); - examples/auto-lod gains a card crown that both runtimes draw. packages/core + assets LOD specs: 2659 passed, 1 known load timeout that passes alone.
The main pass's share was taken of the frame just measured, capped at 60 fps. A fast frame shrank it: on Machinefall the bias sat at 2.4-2.5 for most of the walk, rising on 2.3-3.9 ms main passes judged against 1.9-2.8 ms budgets at 200-400 fps, against a 120 fps target. Every LOD switch then came at 40 % of its authored distance; the owner saw LOD trigger too early. game.ts already resolves the target for the resolution scaler; it now also tells the renderer (`noteTargetFps`/`targetFps`), and WorldCells budgets `mainGpuShare x 1000 / target` (60 fps until one is known). world-gpu-scene.spec.ts: red `a pass inside the target's share coarsened the world: expected 2.5 to be 1`, then green, with the existing rise, decay and load-hold cases.
…-walk arrival open
A pooled walking p95 is under the target exactly when at most 5 % of
the pooled frames are over it. Window quantiles can only bound that,
and the bound charges a window with p95 over the line up to half its
frames. Each window now reports overTarget {frameMs, render, gpu}: the
frames whose render phase, and the GPU samples, ran past 1000 /
targetFps (the target the window started with).
target-fps.spec.ts: red `expected undefined to deeply equal
{ frameMs: 8.33, gpu: 1, render: 2 }`, then green; 62 passed.
When the GPU scene comes up over a ring built without it (Machinefall loads its ring with no renderer), it re-seeds a rebuild of every resident run and dresses every key at once, and a dressed key draws nothing until its run lands. The gate settled over 1,378 owed builds, the overlay dropped, and a third of the forest arrived in the first 4 s of play, in every build including develop (laptop TN_DIAG run). `#drainPrewarm` now waits for the admission backlog to empty before it settles, so the loading screen covers the re-seed. world-gpu-scene.spec.ts: red `the gate settled with re-seed builds still owed: expected 17 to be +0`, then green; world-gpu-scene, world-cells and world-cells-shadow-prewarm 142 passed.
…ass draw budget measured (#473) * feat(world): stack uncompressed terrain layers into one array per set (PRD-493 phase 1) Sixteen same-size uncompressed layers bound one sampler each, which is what makes WebGPU's 16 sampled textures a stage the real cap on Android and iOS — the hosts with no KTX2 transcoder, where nothing stacks today. Each set now becomes one DataArrayTexture whose layers are filled by a renderer.copyTextureToTexture out of their own textures: the pixels never come back to the CPU, and the array is created with source.dataReady = false so three never uploads placeholder bytes over the copies. Mixed sizes still fall back, and TN_TERRAIN_SPLAT layers= samplers= stacked= reports what the material actually costs, one line per material. Also corrects the stale @constraint: the recipe is export_terrain_layers in export_world.py, and the cap is now a sampler count the marker names. capabilities.json and the generated reference carry both. * feat(world): per-layer ORM on the splat surface (PRD-493 phase 2) The material hard-coded metalness 0 / roughness 0.92 and gave every layer it, so a Megascans-grade layer's own occlusion, roughness and metalness never reached the screen. A layer with "orm": true in the table now reads <id>_orm.jpg as linear data (r occlusion, g roughness, b metalness) through the same stacked array and the same blend weights as its colour; a layer without one uses the roughness and metalness the table states. A layer that states neither now throws and names the recipe, rather than falling back to a number in the package — the constant is gone. The recipe writes _orm beside _diff and _nrm in both copies of export_world.py, and refuses a table whose ORM layer names no orm source. * feat(render): grade and film grain in the starter, on a new stage seat (PRD-492 Phase 1) The starter's frame now ends in a colour grade and a film grain, both authored stages it owns: `src/render/grade.ts` builds them, `quality.ts` decides per tier and `public/grade.cube` is the table. Replacing that one file with a `.cube` from any grading tool is the whole of "change the grade". The filed design was a bijective log shaper inside the stage. It is measured and dropped: an 8-bit table quantises in 1/255 steps, and sweeping the shaper family over the frame's range found nothing under 2.0 8-bit steps per channel (the filed W=4, g=1/2.2 costs 4.3), because ACES and sRGB together stretch a small change in light into several codes. So the fallback this PRD already named ships instead: a stage may declare `afterOutputTransform`, the chain then installs the graph with the renderer's automatic transform off and the stage applies three's own `renderOutput` first — what three's FXAA example does. An identity table there is within 0.875 of a step over every value the frame can hold, which is the PRD's own box, and a `.cube` now means what a grading tool means by it. Engine change is mechanism only: `IRenderChainStage.afterOutputTransform`, a third argument on `setOutputNode`, and the chain handing the renderer `outputColorTransform: false` only when such a stage actually ran — a dropped one would leave the frame untone-mapped with nothing to have applied it. Ran: pnpm typecheck, pnpm lint, 259 vitest files across packages/core and packages/create-threenative. Playtest `look.playtest.json` on a scaffolded starter (webgpu, headed, adapterClass hardware): renderChain tier/includes/order/ contributions all pass with grade and grain applied; the tier-low leg on a throwaway copy with the tier pinned reads `dropped: [{"name":"grain", "reason":"grainIntensity:0"}]` with grade still applied. Open, recorded in the PRD: AC-1 (gpuOther resolved in 1 of 17 windows graded, 0 of 16 ungraded — no delta to read) and the frame half of the identity round trip (the starter's own animated content moves between runs by more than the claim; the same build twice reads maxChannelDelta 114). * style: biome's own formatting for grade.spec.ts The pre-push drift gate refused the push on a formatter diff biome had already reported once; this is the same one line, formatted. * test(world): a 16-layer uncompressed splat package on a GPU, plus its captures Unit specs prove the mechanism in node; this example proves it on a real WebGPU adapter. The package is sixteen layers with an albedo, a normal and an ORM map each — 48 uncompressed JPEGs — written by the shipped export_terrain_layers recipe from a table and map files a game would author, so the thing under test is a DCC export, not a second format. The run is green: TN_TERRAIN_SPLAT layers=16 samplers=4 stacked=3, 180 frames, a non-blank changing viewport, and the scenario asserts those numbers through material.userData rather than trusting a screenshot. The same package on develop never reaches a frame: Uncaptured WebGPU GPUValidationError: The number of samplers (34) in the Fragment stage exceeds the maximum per-stage limit (16). Captures for the judge, same package and same pose: .afk/scratch/prd493-captures/phase-1-2/{before,after}.png (4 KB uniform vs a textured 16-band surface). Not a Machinefall result, so AC-1 stays open. * test(native): a lut-grade conformance row, and the desktop scenario that could not run (PRD-492 Phase 2) Phase 2's second box passes: `pnpm native:build` (409/409), then `pnpm parity --target desktop --only-tests lut-grade` reports status pass with `pixelMismatchRatio 0` and `perceptualDeltaE 0` over 1280x720 and no GPU validation errors, against a web-lane reference capture of the same scene. The table is a 17^3 per-channel gain rather than the identity, so a host that ignored the upload or substituted a constant fails it, and the scene reads the parsed Data3DTexture back in JS against that formula before the frame is compared. The sweep exits 2 because a bounded --only-tests run blocks the other 95 rows. Phase 2's first box stays open, and the reason is the packager rather than the grade: the desktop run's marker reads `dropped: [{"name":"grade","reason": "lut:pending"}]` and the console carries `TN_GRADE_TABLE grade.cube: File read error`. package-desktop.mjs stages `selectManifestAssets(assets)` and nothing else, so a public/ file the manifest does not declare never reaches the host — every unmanaged public file on every native target, not this table alone. Fixing the asset selection is mechanism work outside this PRD's filed file list, so it is recorded rather than done here. The scenario also could not run at all before: its `visibility` assertion is refused outright on desktop (TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed before reading a stage. That row is dropped — the runner never produced the observation on this target — and `grade`/`grain` are added to the stages and contributions the scenario does read. Ran: pnpm typecheck, biome on the changed files, packages/runtime-native (7 files) and packages/playtest (104 of 105 files; `generated-shooter-input` fails on a browser session and touches nothing in this change), and the create-threenative scaffold and looks contracts. * docs: path spelled out for the capture dir * test(world): the sixteen-layer splat package on the native desktop host The browser lane proves the stacking; the hosts that matter most are the ones with no KTX2 transcoder, and there nothing stacked before. This gives the example a native identity (threenative.config.ts, one description read by both the web dev server and `threenative build`) and the same assertions as a desktop scenario: sixteen layers, at most four samplers, three stacked sets. orbitSeconds is new because a native run that renders one frozen frame would otherwise satisfy every cost assertion; the camera pose has to advance. --target desktop on the built host: pass, layers=16 samplers=4 stacked=3, orbitSeconds 2.03 -> 5.03, diagnostics clean, 0 console errors. The web scenario with the same assertion is pass on the software-and-hardware WebGPU recipe; world-terrain-splat.spec.ts 11/11. * feat(world): a conformance row for sixteen terrain layers on native The hosts this PRD is for cannot see KTX2, so nothing stacked there, and nothing proved it: the browser lane cannot fail on a path the browser never takes. terrain-splat-array builds a sixteen-layer package with an albedo, a normal and an ORM map per layer — the shape Android and iOS ship — through loadTerrainSplat, and the scene throws unless the marker reads layers=16 samplers=4 stacked=3, so the row fails on a regression to one sampler per map rather than merely looking different. The 48 maps are DataTextures derived from a pure function of their cell and the mask grid gives each layer its own region, so both lanes derive the same frame from nothing but the scene file: the desktop host matches the browser reference at pixelMismatchRatio 0 and perceptualDeltaE 0 over 1280x720. The Android leg of the row is left open, not claimed: :app:verifyV8Dependency refuses to bundle without a V8 build receipt that only an hours-long source cross-compile writes, and the emulator was up and waiting when it stopped. Gates: parity --target web pass (reference captured); parity --target desktop pass, 0/0 metrics, 0 gpu validation errors; runtime-native conformance suite 76/76; pnpm typecheck and pnpm lint exit 0; pnpm check:docs exit 0. * fix: reconcile merged terrain fixture Three patch hash * fix(render): neutralise the shipped grade and grain default (PRD-492) A blind judge compared the starter WITH this PR's grade and grain against WITHOUT and clearly preferred WITHOUT: "strong warm beige/pink cast — grey concrete reads as sandstone, sky washed toward cream, a bit muddy/low contrast; grain clearly visible as speckle over sky and flat walls and behind HUD text". The owner's rule is that a default must never degrade the look, so both numbers move and the spec says so red-first. The pivot was the larger half and nothing had measured it. A contrast curve anchored at 0.18 leaves everything above it brighter: on the captured frame that lifted mid-grey 8 of 255 steps and the sky 12, which is exactly what "washed toward cream" looks like. PIVOT 0.18 -> 0.5, and GAIN's warm spread 13% -> 0.5% (r 1.0025 / g 1 / b 0.9975). CONTRAST stays 1.06, so the frame keeps more separation than the ungraded one, and the table still lifts no black. grade.spec.ts pins all three claims and is red on the old table. grainIntensity 0.12 -> 0.0039 at high and medium. film() scales its noise by the pixel's own value, so 1/255 is under half a step on a mid-grey surface — below what the framebuffer shows. It stays non-zero so TN_RENDER_CHAIN keeps naming grain as APPLIED at high, which is this PRD's own ticked box; low still refuses it with a reason, and quality.ts names 0.12 and 0.3 as the values to raise it to. The desktop box passes now, and the packager was never at fault. Phase 2 filed "the staging loop copies selectManifestAssets(assets) and nothing else, so an undeclared public/ file never reaches the host"; the selector's rule 3 keeps everything no cook produced and its spec already proved that. The scaffold that run used had no public/grade.cube in it at all — the AC-2 before-arm deletes that file — so there was nothing to stage. With it present: pnpm build --target desktop lists it among the unmanaged files, the host logs "[Fetch] Read 19909 bytes from bundle: grade.cube", and native-playtests/render-chain.playtest.json exits 0 with all six assertions true at adapterClass hardware and a non-blank screenshot (tone mean 117.1, p99 192). A guard in the selector's own spec now covers a hand-placed file whose extension no asset pipeline emits. Also recorded: a browser run that reports swiftshader/google is a CPU rasteriser's picture of a hardware look (this lane's frame came back flat grey with 7,359 createBuffer errors), and --browser-recipe webgpu alone does not prevent it — the runner launches chrome-headless-shell. Every capture ran --headed, on turing/nvidia, at tier high. Ran: grade.spec.ts 11 passed (2 of the new ones red on the old table), scaffold.spec.ts 66, looks.spec.ts, asset-manifest.spec.ts 6, the six doc/CI contracts 241, pnpm typecheck clean, biome clean on every touched file, pnpm check:docs clean. Captures for the judge, not judged here: .afk/scratch/prd492-captures/phase1b/ {before,after}.png plus {before,after}-crop.png (centre 960x540 at 2x). Measured between the pair the grade now moves the frame by rmse 2.22 codes and -0.32/-0.71/-0.32 mean per channel, from rmse 8.17 and +6.7/+6.3/+6.9. * test(scaffold): the thirteen tree hashes on the runbook-merged tree Merging PRD-493 under PRD-492 moved every no-install scaffold hash: the rewritten `capabilities.json` entry and `capability-reference.md` reach a scaffold verbatim. Measured through actual createProject trees on this merged tree, which is what the table already claims to hold. * docs(runbook): A7 baseline — 8 streamed-batch pipeline creations per walk, and the two reasons Five map-walk runs on origin/develop's core, GPU quiet at every start (6-15%), host load 18-48, so the CPU numbers are inflated against the earlier 11-19 runs and only the paired lane comparison counts. Median walking render 13.4/25.9 ms, GPU 3.6/21.6 ms, 48 frames over 33 ms of 1024, worst present gap 17.1 s. The pipeline census is what A7 needs: 8 creations attributable to a streamed batch after startup.compileSettledMs in every run (7 main pass, 1 shadow caster half; device service 1.1-1.6 ms), plus 16-22 compute creations and 12 main / 4-5 shadow for objects outside the shared batches. Two causes, both named rather than inferred: - #dressGpu cannot re-dress a mesh three has compiled (its instancing node binds the instanceMatrix it held then), so a key dressed after the prewarm gate gets a fresh object whose node and pipeline its first draw builds, on a walking frame. - TN_WORLD_PREWARM reads shadowPrewarmed=0 castersUnbuilt=182 in every run: the prewarm mints an empty batch and waits for a draw to build its node, and three never submits one — getDrawParameters() returns null at count === 0. AC-3 stays unticked with the measurement beside it. Two attempts to close it on the engine's own compileAsync seam failed on the walk itself: deferring the swap to that compile hangs a live renderer (900 s timeout, walk-a7-r2-1), and doing it behind the loading gate serialises ~200 key compiles behind prewarmed and does the same (walk-a7b-r2-1, aborted at 10 min). A7 needs a preparation seam that does not borrow the frame the walk is drawing, which is PRD-387's open Phase 1. * fix(core): the prewarm's draw is a submission three admits, not one it refuses A7's measured cause, one of the two named on 2026-10-04: TN_WORLD_PREWARM read shadowPrewarmed=0 castersUnbuilt=182 in every walk, because the prewarm leaves an empty batch owed a draw that three never makes. `RenderObject.getDrawParameters` returns null at `count === 0`, so the renderer skips the object, builds no node and never calls `onBeforeRender`: 385 keys minted behind the gate prepared nothing, and the walk's first shadow window move built all of them inside a shadow pass. An empty batch still owed its draw now submits one instance of the zero matrix its own fresh buffer already holds (`SharedBatch#publish`), which is the submission that builds the node and the pipeline, main and shadow. It draws nothing — every vertex collapses onto one point — and `prewarmDrew` puts the honest count back. `prewarmInstance` publishes on the mesh which count is the prewarm's, because `casterPrewarmOwed` cannot answer it: a batch can owe its draw and hold records at once. A batch leaving the world hands the borrow back rather than submitting a degenerate instance for the rest of the load, and the prewarm's own submission is not recorded into a draw bundle. The red-green is the harness, not a new one: `world-cells-shadow-prewarm` counted a draw three would not submit (no count gate), so it passed with 8 of 17 casters unbuilt. With three's own gate in both arms it read `castersUnbuilt=8` before this change and `castersUnbuilt=0` after, and every prewarmed caster's first draw is still behind the gate. `pnpm exec vitest run packages/core/__tests__` 184/184 green, `pnpm typecheck` and `pnpm lint` green. `pnpm test` still fails 22 native-host cases in `packages/runtime-native` that need `pnpm native:build` in this worktree — untouched by this change. * docs(runbook): A7 attempt 2 — the non-zero prewarm draw is proved, and it blocks the launch The first of A7's two named causes is now closed at the mechanism level and unit-proved, and the walk says the fix cannot merge as it stands. Alternating arms in one lane, map-walk with tnFrameSpans=1, host load 24-32 throughout: - dev pair-1 (core-dev2, 1a6298cd): pass in 2 min 59 s, 18 of 127 creations after settle attributable to a batch key (1.7 ms of device service), 13 of 1024 frames over 33 ms, walking render 8.2/15.7 ms, GPU 1.6/10.6 ms, 17.8 s to compileSettled over 1895 pipelines, and the baseline's own line unchanged: minted=385 shadowPrewarmed=0 castersUnbuilt=182. - dev pair-3 (same core): fail, 2 state failures and 3 console/network errors over 4382 frames. That is this host's noise, not a result, and the lane claims no CPU comparison from these runs. - a7 pair-2 and pair-4 (core-a7b): both stall at the loading gate. TN_STARTUP_STALLED reports 0.0 % progress for 45 s with nothing outstanding, the page's main thread never yields again (the bridge's `describe` exceeded 915000 ms), the 900 s timeout takes the run, the prewarm marker never prints and the walk never starts. No census, no frame series, nothing to capture. What the row now knows: the mechanism is right in the fixture world and fatal at Machinefall's scale, where 385 keys by two variants behind the gate is a different cost from 24 by two. What it does not know is which of two costs the gate is paying — the node builds themselves, or `#drainPrewarm`'s own loop, which invalidates the shadow levels on every update while a caster is owed and now has every prewarmed caster to submit each time. The next step is one instrumented run that separates them. AC-3 stays unticked, the A7 boxes stay unticked, and the arm stays a draft. * revert(core): take the non-zero prewarm draw back out of #437 Reverts 2d6b930fc. With it, Machinefall map-walk never leaves the loading gate (two of two runs, 900 s timeout, TN_STARTUP_STALLED). The runbook A7 row keeps the finding; the change returns once one instrumented run names the cost the gate is paying. * fix(core): a pre-ORM splat table loads instead of hanging the world at its loading gate PR #437's `loadTerrainSplat` threw when any terrain layer declared neither an ORM map nor a roughness/metalness pair. Machinefall's exported `terrain/layers.json` predates that column: its base layer states neither and its layers state roughness without metalness. The world's `load()` therefore rejected, `MapWalk.load()` never returned, `state.loading` stayed true, and the playtest timed out in `describe` with 0 frames. A table the earlier export recipe wrote is not a malformed table, so the check now names the layers it cannot honour and the surface builds: `DEFAULT_ROUGHNESS` (0.92, the matte dielectric this material carried before the column existed) and metalness 0. Loading a package is a promise the game cannot cancel, and a hang at the loading gate costs a whole boot. Machinefall loads its layers as KTX2 `CompressedTexture`s, so the uncompressed `stackUncompressed` path the same diff added never runs for it; the throw was the only new blocker. Tests: the spec now feeds `loadTerrainSplat` Machinefall's own table shape (base with no response, layers with roughness and no metalness) and red at the old throw, green after. The old "refuses a layer that states neither" spec encoded the hanging contract and became this one; PRD-493's sixteen-layer stacking and ORM specs stay green. * fix(core): a shadow casts only for what the main pass draws Machinefall's first frames after the loading overlay drew a meadow of canopy shadows with no trees under them (about 1 run in 6; owner report on #437). With the GPU scene on, the main pass draws only sources the seed has placed into the dispatch, and the seed queues behind the admission budget (`TN_WORLD_GPU_SCENE on ... instances=0`), while every caster half drew its whole resident ring. - WorldCells counts each caster half's placed sources (`SharedBatch.gpuPlaced` against `live`, kept in #placeSources / #clearSegment / the impostor handoff) and publishes `mainAdmitted` per half; the wide `@*` mesh casts once every cell of its asset is placed. - VirtualShadowNode#probe hides a half that is not admitted for each level render (the existing #hidden restore buffer), so admission gates the draw, not only its tally. Admission, not visibility: an off-screen caster whose counterpart is placed still casts. - Admission is the third bit of the per-caster flag word #pollCasters reads, so a flip redraws the cached levels covering that half; without it the maps drawn during the seed stayed shadowless. - TN_WORLD_SHADOW_ADMISSION reports withheld cluster and wide halves and their records (Machinefall: 249 + 47 halves at the seed, 0 after). GPU (RTX 2080, Machinefall map-walk, develop base vs this change, 6 runs each, merge on and off): no orphan frame in the fix runs; forest-floor brightness at the start frame 212 vs develop 211 (the withheld-but-cached state read 226-237); walk frames within 1-2 units, the residual is canopy self-shadow speckle, not ground shadow. Specs: the render-boundary admission spec, the per-half placement spec, the seed-drain specs and two admission-flip invalidation specs, each red before its change and green after; 246 world and shadow specs pass. * docs(runbook): A7 attempt 3 and the walk on this PR The launch hang blamed on the prewarm was the PRD-493 splat throw; with it fixed the prewarm loads but prepares nothing on Machinefall and moves no number (12 alternating runs, order effect only), so it stays off this PR. No post-settle compile costs a frame (max 0.2 ms). The walk on this PR: render p95 6.4 ms, GPU p95 6.5 ms on a loaded host; AC-1/AC-2 stay open for the quiet interleaved rerun and AC-3 raters. * docs(runbook): tick A1 and C1; PRD-492 AC-2 passes A1: PRD-389 Phase 4 reads 5/0 on origin/develop, one box per probe gap, landed by #423. C1: #397 merged and PRD-339 is in done/ at 11/0. PRD-492 AC-2: visuals:ab with 3 fresh blind raters scores the graded starter frame equal to the ungraded one (4=4, 3=3, minimum detectable effect 0); the HUD moves at most 1.9 codes against 112 in the scene. * docs(runbook): read the walk the way PRD-478's ACs define it * fix(vq-assets): read the lifecycle baseline only after the world has drawn The GPU lifecycle scenario reported +6 geometries and +3 textures over three re-enters. An in-page probe shows the release path is complete: every enter disposes its six geometries and three textures on exit (18 and 9 dispose calls after three exits) and the fourth enter holds exactly one enter's live set. The growth was a measurement artifact. info.memory counts a resource from the frame that uploads it, and the engine withholds the first world pass behind the loader cover until first-use compilation settles. The first enter's baseline was therefore taken when the loader's own geometry was the whole live tally, so every later enter read that enter's own upload as growth. Gate the read on startup.compileSettled. Scenario on nvidia/turing: geometryGrowth 0, textureGrowth 0, entries 1 -> 4. * docs(prd): VQ-01 is done; tick runbook row B1 The lifecycle box passes on nvidia/turing once the fixture reads its baseline after compile settle (f1fb533a2): growth 0/0, and an in-page probe counted every owned dispose (6 + 3 per enter). The PRD moves to done/ with its links. * fix(core): the world-matrix walk prunes again, and a frozen batch is a leaf Two reasons the per-frame scene walk cost 13% of a map-walk, both in `MatrixWorldPass`. The frame spans time the render phase's walk by wrapping `Scene.prototype.updateMatrixWorld`. `#step` read a non-base `updateMatrixWorld` as "a class that owns its walk" and handed the whole scene to three's own recursion — which prunes nothing. The pass visited one node per frame and reported it: `"matrixWorld":{"mode":"visible","visited":1}` on a scene of 2692, with no `sceneUpdate` span emitted at all. A `Scene` is never such a class: three does not override the walk on `Scene`, so a scene whose method differs has had its prototype patched. `markStatic` composes a subtree once and turns off the two flags that would recompose it, but nothing stopped the recursion from still visiting every node under it. A frozen root that carries no dirty flag is now a leaf. `refreshStaticTransforms` re-arms a root whose own transform moved and `invalidateStatic` re-arms one something wrote inside, each composing once at that moment, so the frames in between have nothing to recompute. `isStatic` is what separates the engine's freeze from a game that turned the flags off itself. Measured on Machinefall map-walk, RTX 2080 (turing/nvidia), `instances=13987 drawn=27552` in both runs, CDP profile over a ~111 s walk, self time: | function | #437 before | after | | ---------------------- | ----------: | ---------: | | `updateMatrixWorld` | 10599.1ms | 71.9ms | | `multiplyMatrices` | 2037.3ms | 1517.9ms | | `updateMatrix` | 25.8ms | 4482.0ms | | `traverseVisible` | 3290.2ms | 2968.1ms | | walk total | 15952.4ms | 9039.9ms | The compose did not vanish, it moved out of `updateMatrixWorld`'s frame into `updateMatrix`, so the walk itself is 12636ms -> 6072ms. `visited` went 1 -> 2363 of 2692: the visible-only pass runs again, and 329 nodes under frozen roots are no longer visited. `sceneUpdate` is reported again (p95 0.1-2.0ms). Steady-state render p95 median 11.3ms -> 8.0ms (AC-1 target 8.3ms), but that pair is confounded: the before run had dropped its surface to 781x439 "AT FLOOR, budget not met" and the after run held 1280x720, so the after run did ~2.7x the pixels and still came in lower. Not a like-for-like number. Picture unchanged to what the capture can resolve, three baseline captures against two of this build, 1280x720: | frame | #437 vs #437 (control) | mine vs mine (control) | mine vs #437 | | -------------------- | ----------------------: | ---------------------: | -----------: | | phase477-pose-start | 5.08% 5.33% 6.77% | 8.51% | 2.03-9.49% | | phase477-walk-20 | 4.42% 5.32% 5.57% | 5.77% | 3.74-6.04% | (mean absolute channel diff 0.01-0.33 of 255, max 82; `magick -metric AE` 906.4 / 270.651, but IM7's AE disagrees with the exact count of 78,017 / 53,192 differing pixels, so read the percentages). This build's own repeat-to-repeat spread is as large as the difference from the baseline, so no visual change is separable here. The owner reviewer judges the pixels. Tests: `packages/core/__tests__/matrix-world-static.spec.ts` is red on this branch's `matrix-world.ts` (frozen batch visited 41 nodes instead of 2; the patched prototype ran once instead of zero) and green with it. It also keeps a re-placed batch and an `invalidateStatic`-ed subtree on their new world matrices. 39 world/virtual-shadow/matrix/static spec files, 608 tests; full core suite, 185 files, 2410 tests. Left alone, game layer, reported not fixed: `Q7` 3.6s and `m7 shadowKeysFrom` 2.1s self time, both in Machinefall's `stepFrame`. * docs(prd-492): AC-1 passes at 0.0 ms; the identity box needs a float table and a canvas readback * perf(core): a chunk merge reads its own arrays, and the proxy copies vertices A streamed world chunk's merge cost 5.51 ms/MB on real Machinefall chunk data, and one TimerFire attaches up to 256 of them, so a walk paid seconds of uninterrupted main-thread JavaScript per stall. The cost was never arithmetic. `flatten` cloned every part — and `clone()` on a quantized interleaved channel de-interleaves it through a boxed `Array` — then `applyMatrix4` read and wrote one component at a time through a `Vector3`, then `toNonIndexed`, then a de-quantizing copy, then `mergeGeometries` copied everything a second time and built the index one `Array.push` at a time. Six passes over every byte, and one per instance for an expanded `InstancedMesh`. `concatenate` reads each channel's array directly and writes it into the merged buffer once: same arithmetic term for term, including `applyMatrix4`'s homogeneous divide, `applyNormalMatrix`'s `normalize()`, `normalize`/`denormalize` on the channel's own array type, and the widening `widenIndex` applied. The three path stays for what it is the only reader of — half floats, a channel whose vertex count is not its part's — and keeps its refusals. The chunk shadow proxy had the same per-vertex `Vector3` round trip and is now 73% of what is left, so `placeVertices` writes a plain float position array directly and keeps the accessor loop for a retained quantized source. Measured on the 133 Machinefall chunk GLBs, 615.7 MB merged: 3393 ms -> 815 ms, 5.51 -> 1.32 ms/MB, 4.16x, with all 135 output digests byte-identical. * docs(prd): PRD-484 is done; tick runbook row A0 Strata with its local stopgap reverted, under the develop defaults: scene 96.4 M -> 15.8 M triangles at the worst view, mirror 96.4 M -> 0.92 M in 3 draws (console TN_FRAME_BUDGET maxima, nvidia/turing, 1920x1080); every local game edit restored byte-identical. * fix(starter): load the grade table as float, so an identity table is exact The starter's `.cube` loaded through `LUTCubeLoader`'s default 8-bit path, which truncates `value * 255` into a `Uint8Array`. A grading tool's file is written in [0, 1], so the table came back reading low and the round trip was never the frame the game drew: the arithmetic in `grade.spec.ts` put an identity table at 0.875 of a step, and the frame half measured a signed mean of −0.583 codes with 3.23 M samples sitting at exactly −1. `postprocessing.ts` builds the loader, so that is where the type goes (`three@0.185.1`'s loader documents `{UnsignedByteType, FloatType}`; `HalfFloatType` would pair its `Float32Array` with a half-float texture type). Only the starter carries a grade, so no kit copy moves; the recorded starter scaffold tree hash moves with the file. Measured on the still-scene starter, 1920x1080, `nvidia/turing`, the drawing buffer read through `drawImage` rather than a page screenshot. Two readbacks inside one run are bit-identical; two runs of one build differ by 35 codes. On the 89.7% of pixels two ungraded runs reproduce exactly, the identity round trip now reads signed mean +0.032, rmse 0.26, 0.118% of samples over one step, against −0.583 / 0.77 / 0.297% on the byte path, and the real loader measures max delta 0.000000 codes over every display value. PRD-492's frame box stays open: its whole-frame `maxChannelDelta` ≤ 1 is below this lane's own 35-code run-to-run spread, so no table can read it. What closed is the systematic half. Desktop `native-playtests/render-chain.playtest.json` exits 0 with `grade` and `grain` applied and no validation message on the built `mystral`; that lane is too noisy for a per-code number. Gates: `grade.spec.ts` 11 passed, `scaffold.spec.ts` 77 passed, `pnpm typecheck`, `pnpm lint`, `pnpm ci:fast`, `pnpm check:docs` and the six doc-contract specs all pass. Not mine, reproduced with these files reverted: 4 failures in `vq-native-fixture.spec.ts` (`ctx.startup` undefined in the abyss-framework fixture). Full numbers: `.afk/scratch/lut-float.md`. * docs(prd): PRD-492 is done; tick runbook row C2 The identity box is proved by the exact float round trip (max 0) and by the frame matching the chain-rebuild control inside one run (max 34-38 both, mean +0.035 vs +-0.0002); the proof decision is recorded in the PRD and reversible. * perf(world): frame spans for the main-thread terrain block and seam work Add `terrainBlock` and `terrainSeam` to the frame-span table and charge the parts of a block rebuild, its attribute swap, a seam reconciliation and its bridge swap that run on the calling thread. A worker host's swap arrives in a job reply and an inline host's runs inside the same `follow`, so the spans are added rather than bracketed; two `begin`/`end` pairs would nest and count one unit twice. Off, each site is one guarded return with no clock read. `TN_FRAME_SPANS=1` now reports the timing clause PRD-478 Phase 3 was missing in the browser: the main-thread block-rebuild and seam-pass cost on Machinefall map-walk, where the heavy concatenation is off thread. The red-green spec records both spans on a node ring (red: `undefined`, green: entered); a node sample is structural, not a browser frame. Gates: focused vitest 104 passed (terrain + spans suites, 7 files); pnpm typecheck exit 0; biome check on the three files no errors; pnpm --filter "@threenative/core..." build exit 0. * fix(profiling): share the span recorder across core's entry copies Core is built once per entry, so a game importing `@threenative/core/world` gets a second copy of `profiling/Spans.ts`. The recorder was module state, so the recorder installed by the main entry was invisible to the world bundle and the terrain spans this run added recorded nothing in a browser walk (both `terrainBlock`/`terrainSeam` absent while `TN_TERRAIN_TILES` showed rebuilds). Keep the one slot on `globalThis` under a `Symbol.for`, which every copy in a realm shares. A spec asserts the slot, so a revert to module state fails. Gates: spans vitest 11 passed; biome check exit 0; walk `terrainBlock`/ `terrainSeam` now present in the `TN_FRAME_SPANS` windows. * docs(prd-478): Phase 3's worker box passes on the browser walk * feat(scripts): the world gate measures run-to-run noise (PRD-477) One capture per side reds on streaming arrival: two develop runs differ more in the far band (3.96) than develop vs the candidate (1.31), and 2 of 3 critics call the candidate's later far band "missing". The gate now accepts two or more runs per side, carries every run as its own anonymous series in the blind walk bundle, and counts a candidate event only when the same critic reports it in every candidate run and in no reference run. One run per side keeps the old strict rule because there is no spread to measure. This also carries the cross-series `missing` support the rule needs: the walk's last frame joins the same-pose set, and a candidate `missing` event fails at any distance. Both changes sit on the same rule path and are tested together. Red-green: 2 new specs fail with the spread rule forced off, all 60 pass with it. Gates: world-visual-gate 60, world-capture-manifest + visual-ab + score-blind 92; pnpm typecheck, pnpm check:docs clean. * docs(runbook): tick A2; PRD-477 Phase 1 reds on impostors-on and greens on develop Two runs per side, three blind critics each, on Machinefall map-walk: develop vs develop passes (exit 0), develop vs impostors-default-on reds (exit 1) with the distant crowns missing in every impostor run. * test(native): a workerless terrain inline-settlement conformance row (PRD-478 Phase 3) PRD-478 Phase 3's last box, "hosts without workers run the same jobs inline, with an unchanged result", gets its native conformance case. `TerrainTiles` moves the block merge and the seam bridge strip to a module worker where a host has one; a host with none runs `runTerrainJob` inline. The row settles a small two-tier ring with no worker, folds every settled block and bridge by raw position/normal/index bytes into an FNV-1a 32-bit digest, and requires the digest to equal the node inline path's pinned constant d138e335 (blocks 3, bridges 4). A mutated-byte unit case proves the digest reads the bytes and not just the mesh count. The scene hides `Worker` for the settle on every lane: that is the box's own workerless scenario, and the esbuild conformance bundle emits no worker file, so a browser `Worker` would hang. The native host's real shim refuses a module source by name (TN_NATIVE_WORKER_MODULE_UNSUPPORTED) and takes this same inline path, so the browser reference and the native capture run one body. Ran, with TN_RUNTIME pointing at the main checkout's built host (no built host in this worktree; no rebuild attempted): - `--target web --only-tests world-terrain-inline-hash` exit 0, row pass, reference capture written. - `--target desktop --only-tests world-terrain-inline-hash` exit 0, row pass, native exit 0, pixelMismatchRatio 0, perceptualDeltaE 0, 1280x720, non-blank, 0 GPU validation errors. `pnpm census` regenerated the native census record: total 180,365 -> 181,846 (ten counted-area cells; mostly pre-existing branch drift, plus the two new files). typecheck exit 0, biome exit 0, focused runtime-native vitest green. * docs(runbook): tick A6; PRD-478 Phase 3 holds on web and the native host * docs(prd-478): AC-3 gate evidence; the box stays open on late objects * fix(core): render virtual shadow levels outside a main-pass bundle record Three runs a node's updateBefore from the draw of the first object that uses it. That draw can be the first object of a BundleGroup that the main pass records. Three keeps the recording bundle in renderer._currentRenderBundle and does not save it across a nested render(). The level render filed its draws under the main bundle, and its own bundles left the field null. The remaining draws of the main bundle were recorded but not listed, so replays never refreshed them and they kept the camera of the record frame. On Machinefall's world-capture walk, the record of world-chunk-bundles:8:7 (the chunk forest under the start pose) nested the first shadow level about 1 run in 2. That cell's Kite/Pine4k/fern chunk meshes then stayed screen-locked from walk-01 to walk-21: the "extra dark conifer cluster". VirtualShadowNode.updateBefore now clears the field for the level renders and gives the recording bundle back afterwards. * fix(core): render the water reflection outside a main-pass bundle record Without bounces, three's reflector updates once per frame, so world-cells' bundleSafe check does not refuse it. An opaque water mesh in a world chunk can then be recorded into a BundleGroup. Three runs the reflector's updateBefore from that draw. The mirror's nested render() then has the same fault that 0695d03ab fixed for virtual shadow levels: the main bundle's remaining draws are recorded but never listed, so replays keep them at a stale camera. WaterSurface3D now wraps the pass's updateBefore. The wrapper clears renderer._currentRenderBundle for the mirror render and restores it afterwards. water-surface-bundle-nesting.spec.ts fails without the fix and passes with it. * perf(core): stabilize adaptive shadow refresh snapping (PRD-478) Keep clipmap origins on each level's fixed texel grid. Refresh-step changes alone retain a stationary window; camera movement beyond the hysteresis re-centres the window and requests a level render. Reserve one texel for render deferral and isolate refresh updates to their requested level. Red: a fixed centre with refreshStep 0.128 -> 0.164 moved and rendered one level, increasing rendersTotal from 2 to 3. The regression now stays cached. Validation: 95 shadow specs pass with existing assertions unchanged; core suite 176 files, 2263 passed, 2 skipped; typecheck, lint (0 errors), and quality exit 0. Tests and workspace checks use concurrency 2. Browser ablation runs separately; no browser playtests run here. * docs(prd-478): Phase 1 snap and first-frame shadow boxes pass on this branch * docs(runbook): what is left of AC-1, measured, and where the lever is * feat(native): Android V8 ships cooked KTX2 and Meshopt (PRD-485 done) Android was decoder-free whatever its engine: the capability resolver reported android:unresolved and bundle.mjs stubbed every mobile decoder. The Android engine is now the Gradle selection (THREENATIVE_GRADLE_ARGS -PthreenativeJsEngine, default v8). Android V8 admits ktx2 and meshopt; Draco stays refused. bundle.mjs --decoders keeps only admitted loaders. The bare-WebAssembly guard is lifted for Android V8 only; Rapier and Recast WASM still fail TN_NATIVE_WASM_ON_MOBILE. Doctor follows the same resolution. The native host mapped no ETC2/EAC/ASTC format names, so three's ASTC pick became BGRA8 and every compressed upload failed writeTexture. bindings_resources.cpp now maps them. Proof: fab-import-native.playtest.json --target android on emulator-5554 (API 35, -gpu host, V8): 6/6, 0 diagnostics, Hornbeam rendered. Runbook row B2 ticked; PRD-485 moved to done/. * feat(gate): an intermittent candidate-only event makes the world gate inconclusive With >= 2 runs per side the gate counted a candidate event only when it appeared in every candidate run and in no reference run, so an event seen in 1 of 2 candidate runs and 0 reference runs passed silently. On Machinefall that hid a real, intermittent engine bug (a chunk forest screen-locked for 20 frames, fixed in 0695d03ab). Such events are now listed under `intermittent` and, when they are near-band or missing/ disappear, the verdict is `inconclusive` (exit 2, "needs more runs") unless --allow-intermittent; pass and regression are otherwise unchanged. Red-green: 6 new specs, 67 gate specs pass. * chore(native): regenerate native coverage after the mobile format mapping (PRD-485) * fix(vq-assets): a runtime without a startup phase reads the baseline at once 555d3b542 gated the lifecycle baseline on ctx.startup.compileSettled, which vq-native-fixture.spec.ts's stub context (and any host that reports no startup phase) does not carry, so 4 specs threw. Read it as settled when absent. 28 vq specs pass. * docs(prd-493): tick the Android row after it passes on the emulator pnpm parity --target android --only-tests terrain-splat-array --device emulator-5554 renders the sixteen-layer uncompressed splat at 0 pixel mismatch and 0 deltaE against the browser reference, with layers=16 samplers=4 stacked=3. Phase 3 is landed; AC-1 and AC-2 stay blocked on Machinefall's private walk. Runbook row C3 records the progress. * perf(core): materials that sample one shadow share one render-group buffer Three gives each shared uniform group (render, frame) its own bind group and caches it on the sorted node ids of its uniforms, so every material that reads the same render-group nodes binds one buffer that is walked and written once per render call. The PCF, PCF-soft and point shadow filters broke that: they make their mapSize, radius, bias and shadow-camera uniforms inside a TSL Fn body, and a Fn body runs once per node builder. Every material got new nodes, a new cache key and its own copy of the render group. A read-only probe on the Machinefall map-walk (2 s, turing/nvidia) found 338 distinct render bind groups over 68 render calls. In each of the 18 uniform layouts, 17 of 21 node ids matched and the same 4 differed in every group: two PCF filters' mapSize and radius. The patch makes those uniforms once per shadow (getShadowUniform, a WeakMap on the shadow), in the src files and both webgpu build mirrors. A shared reference node is also refreshed by any record that is not settled, so a settled record now sees a changed shadow radius or map size - before, its private node was skipped with the rest of its per-object updates and kept the old value. three-shared-render-group.spec.ts, real WGSLNodeBuilder + NodeManager.updateGroup: - two materials on one PCF / PCF-soft shadow bind one render group: red before (two groups) - three records walk the render group once per render call: red before (3 walks) - a settled record sees a changed radius on the next render call: red before (kept 1, wanted 3) - two shadows keep separate groups: green before and after (control) three-*, virtual-shadow*, world-bundles, packaging: 15 files, 194 tests green. * feat(core): the pyramid occlusion test on the CPU reference (PRD-489 phase 1) `cullAndSelect` gains the hierarchical-depth test and reports what it hides. Four answers are never "hidden", and each is a case a conservative test gets wrong by being clever: a sphere touching the near plane, a sphere off screen last frame, and anything on a camera-cut frame are kept; a measured run counts what it hides and draws all of it, so a measured frame is the frame an unmeasured run draws. `packages/core/src/render/depth-pyramid.ts` is the mechanism: the max-distance chain (farthest, not nearest — that is the conservative direction), the flag, the CPU reference `occludedBy`, and the GPU half `DepthPyramid` the cull kernel will read. A shadow map's own dispatch keeps the frustum as its whole rule: a caster the eye cannot see still casts. `pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts` — 75 passed. Red-green: neutering the comparison fails "rejects" and "counts and draws"; dropping the three keeps fails "keeps a sphere that touches the near plane, off screen last frame, or a cut frame". * feat(core): `?tnOcclusion=measure` counts what the pyramid cull would hide (PRD-489 phase 1) The measure mode builds a max-distance chain from the previous frame's scene-pass depth, runs the test in the cull kernel and counts the placements it hides while drawing every one of them, so a measured frame is the frame an unmeasured run draws. The counts ride the args buffer's own tail, one word per key, and land with the GPU-selected tally the main pass already reads back on its own clock: a second readback of a second buffer is a second queue submission per window for eight bytes, and it is what makes the would-cull share and the drawn-triangle count come out of one frame rather than two. `renderer.scenePassDepth()` is the seam: the world pass's own depth texture, or `undefined` when no output graph installed a pass — which the mode reports as `refused: no scene depth` rather than as a cull that found nothing to hide. `pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts` — 78 passed (3 new: the counted share, the refusal, and that the mode off runs neither the chain nor the test). Red-green: moving the tail index fails the counted share. `pnpm typecheck` and `pnpm lint` clean. * feat(core): `?tnOcclusion=measure` counts what the pyramid cull would hide (PRD-489 phase 1) Partial. The measure mode runs and prints `TN_WORLD_GPU_SCENE_OCCLUSION`, but the scene-pass depth samples as 0 in the compute dispatch, so the would-cull share is an artifact and the Phase 1 go/no-go cannot be computed. Changes on top of `2c6cf0f97`: - `depth-pyramid.ts`: one storage buffer for the table and the distances. Two buffers pushed the cull kernel past the WebGPU per-stage limit of 8 and the pipeline refused to build (`The number of storage buffers (9) ... exceeds the maximum per-stage limit (8)`). - `renderer.ts` `scenePassDepth`: prefer the pass, then three's internal framebuffer target (where a game without a render chain draws the world), then the canvas; mark the depth as a render-target texture and register it with three's texture bookkeeping so a compute bind reuses the GPU texture instead of re-creating it (`Texture already initialized`). Also find a `PassNode` through a `PassTextureNode`, which a game hands `setOutputNode` instead of the pass. - `world-gpu-scene.ts`: the measured-occlusion tail index is a float index, so floor the instance delta before it addresses the args buffer. Box (a) is green: `world-gpu-scene.spec.ts` 78 passed. Box (b) stays open; the depth read is the blocker. See `.afk/scratch/a11-prd489.md`. * fix(core): the occlusion pyramid reads the real main-pass depth (PRD-489 phase 1) The pyramid sampled the untouched canvas depth because the renderer missed three's implicit depth attachment (Textures.js:81); MSAA depth is now resolved by reading every sample. Manual initialization skipped bindGroups, so Bindings threw before the main cull update (the measure mode's picture change and binding errors), and a resize dispatched a stale cull kernel. Specs: world-gpu-scene 78, 4 new. Authored by the Codex arm, reviewed by the coordinator; GPU verification pending. * fix(core): time the occlusion pyramid and keep its share honest (PRD-489 phase 1) TN_FRAME_BUDGET.gpuPyramid reports p50/p95 for the depth resolve plus downsample. Ordinary movement no longer counts as a camera cut and a rebuilt depth is checked for readiness, so warmup, cuts and rebuilds skip measurement instead of reporting share 0; decoding uses a frozen copy of the dispatch layout so the would-cull count and its denominator stay paired (share <= 1), and malformed samples are flagged. 101 specs pass. Authored by the Codex arm, reviewed by the coordinator; GPU proof pending. * fix(core): the GPU timestamp sampler picks frames, not calls (PRD-489 phase 1) The 1-in-N sampler advanced per render/compute call, so the occlusion pyramid's extra dispatch moved the main pass off the sampled frame and gpuMain vanished from most windows under ?tnOcclusion=measure. It now advances once per frame; compute, pyramid, render and overlay share one frame id and one decision. Red-green: cadence equal with and without the pyramid at intervals 1, 3 and 8 (3/3 red before). 235 specs pass. Codex arm, reviewed by the coordinator. * docs(runbook): tick A11; the occlusion go/no-go says go, so A12 opens PRD-489 Phase 1: CPU reference pyramid test pinned; map-walk under ?tnOcclusion=measure over 3 walks reports a would-cull share of about 0.61 (max 0.84), a 0.07 ms pyramid p95 and gpuMain p95 in every window; 0.61 x 8.9 - 0.07 = 5.4 ms, above the 1.0 ms threshold. Phases 2-3 become runbook row A12. * perf(core): a streamed chunk's textures reach the device before its first draw `compileAsync` builds pipelines, not pixels, so the bake's textures reached the device inside `_renderObjectDirect` on the frame the chunk first drew. Counted on a Machinefall map-walk by instrumenting three's own internals per render (19,000 renders, 3,143 new render objects, 632 first draws): the run spends 2,574 ms on first draws, 10 renders hold all of it, and the worst three walk renders spend 87 ms of a 94 ms first draw, 79 ms of 131 ms, and 519 ms on `Textures.updateTexture` alone. `RenderObjects.get` is 198 ms over 3,143 objects, 0.06 ms each, so the cost is not object creation — it is the per-sub-mesh node build and texture upload that creation defers to the first draw. Buffers were already moved to admission by `uploadAttributes` (826 buffers and 228.8 MB on a walk, up to 230 ms in the frames that first submitted them). Textures are the same argument one resource class over, so they get the same seam: `uploadTextures` walks a subtree's materials and calls three's own `Textures.updateTexture`, which is the only path that leaves the bookkeeping (`initialized`, `generation`) the first draw reads to skip the upload, and which returns early on a texture already at its current version — so a material the world shares with what is on screen costs one call and no work. WebGPU only, and absent or throwing answers 0, so the first draw uploads exactly as before. Measured, 2 walk pairs alternating on the private Machinefall copy, plus the same-session base control: - mainPass mean over streamed windows slower than 8.3 ms: 5.08 -> 3.81 ms (-25%) - draw mean over the same windows: 2.69 -> 0.98 ms (-64%) - worst such window: 25.3 -> 7.3 ms; AC render p95 median 10.30 -> 9.50, AC slow windows 10 -> 7 - cost, stated plainly: gpu mean median 4.00 -> 4.46 ms and total texture upload traffic 174 -> 874 MB, because admission now uploads the chunk's whole set rather than only what the walk happens to sample. The work leaves `mainPass` and lands in the host gap between frames. Tests: packages/core/__tests__/renderer.spec.ts "uploadTextures" — the seam hands each texture in the subtree to three once, through a fresh options object per texture, and answers 0 on a renderer with no texture path. Red without the seam (`uploadTextures` undefined), green with it. Whole core suite green (2,425). Cherry-picked as one commit (net of ad44b0d59 + its revert) from perf/first-draw; free Zen arm, reviewed by the coordinator. * fix(core): a terrain block merges once at a time and keeps late marks A block re-dirtied while its merge was out dispatched a second merge; the older reply then wrote a stale membership into the record (4 duplicate merges of one block on a held 240-frame walk, 0 after). A block in flight is now skipped until its reply; a mark that lands meanwhile re-runs it with the new membership, and a rejected merge frees the block and logs TN_TERRAIN_MERGE_FAILURE. Pixels at floor; terrain-path cost was already 0.04 ms/frame, so this is correctness, not the AC-1 lever. Net of e027f8df8 (free Zen arm) + fa0c15b9e (coordinator review fix via Codex) from perf/tiles-burst. * feat(assets): score every compressed texture against a quality floor (PRD-351 phase 1) The compression passes decode mip 0 with the shipped Basis transcoder and compare it with the same-resolution pre-encode image: SSIM on luma, mean DeltaE00 on colour, alpha separately with MASK coverage. The manifest and console report per-image scores, floor status (SSIM >= 0.95, DeltaE00 <= 3.0 for colour) and build totals; scores cache on the content key with the metric version. Output bytes are unchanged with the instrument on or off. Codex arm, reviewed by the coordinator. * test(assets): pin the normal-map score; tick PRD-351 phase 1 The spike inputs were never committed (D31), so the score spec pins a committed normal map: layer-00_nrm.jpg SSIM 0.99452 +/- 0.005, and 1x1 windows drift out of tolerance. Phase 1 boxes ticked with the real-asset run: 48 textures, 32 pass, 0 below floor, payload bytes identical. * feat(assets): a texture climbs the codec ladder until it meets the floor (PRD-351 phase 2) Rungs etc1s@150, uastc+rdo l3+zstd, uastc+rdo l1+zstd, uastc, none; the first rung that meets the floor wins and slots skip forbidden rungs (a normal map never tries etc1s). The build reports "N etc1s . M escalated to uastc . K uncompressed" and each image's rung; the cache key carries encoder/metric versions, floor, slots, RDO settings and caps. assets. textures.floor overrides the SSIM 0.95 / DeltaE00 3 default. On the 48-texture terrain corpus the payload is byte-identical to phase 1; cold build 8.5 -> 13.9 s, warm 0.07 s. Codex arm. * docs(prd-351): tick phase 2 with its red-green specs and the terrain-corpus run * fix(core): the occlusion measure reads the right pyramid rows; PRD-489 declines The Phase 1 pyramid test sampled inverted texture rows, under-covered rotated/perspective footprints and dropped odd-reduction edge texels, so ?tnOcclusion=measure reported a would-cull share of 0.61 for an open forest. Ported from feat/prd489-cull (a641f22a3) without default-on culling: rows, conservative footprints, mip addressing, odd reductions, near-plane handling, log/reversed-depth refusal; 184 specs, 12 reds. Corrected share on Machinefall map-walk: median 0.013 (p95 0.29), so the go/no-go is 0.05 ms < 1.0 ms. PRD-489 closes declined (D32), runbook A11 records the correction and row A12 is dropped. The working cull stays on feat/prd489-cull. Codex arm, GPU-verified by the coordinator. * feat(assets): maxSize takes a per-slot map (PRD-351 phase 3) assets.models.textures.maxSize and assets.textures.maxSize accept a scalar as before or a map keyed by glTF slot (baseColorTexture, normalTexture, occlusionTexture, ...); a source under its cap is never upscaled and the cache key carries the resolved cap. Defaults unchanged. Red-green: per-slot caps (scalar control red), 512 stays 512 at cap 2048. Codex arm. * test: the web-codec proof is compressible; pure noise stays below the floor PRD-351's ladder refuses every lossy rung for white noise (UASTC SSIM 0.885, DeltaE00 14.7), so the scaffold's hash-noise web-codec-proof.png stayed PNG. The fixture is now gradients with +/-3-level noise: PNG 46.6 KB, KTX2 4.3 KB, first rung ETC1S SSIM 0.969 / DeltaE00 1.9. A texture- pass spec keeps the noise case: unchanged PNG, reported below-floor. * test(assets): a 4096 colour/normal pair compiles to 2048/1024 under a per-slot map Real-compiler coverage on web and Android with shared images. Prompted by the Quarry pricing run, where the per-slot arm matched scalar 1024: the fresh 4096 Landscape Pro import binds only normalTexture, so that was the correct output, not a defect. * docs(prd-351): price the resolution ladder on Quarry (phase 3 evidence) 4096 masters, one close rock pose: cap 1024 2.24 MB disk / 2.67 MB GPU / SSIM 0.9885 (visibly softer); 2048 8.92 / 10.67 / 0.9920; 4096 35.97 / 42.67 / 0.9950 (sharpest). The fresh import binds only normalTexture, so the per-slot candidate equals 1024 here and is not adopted; compiler defaults stay. * fix(assets): the ladder ships the smallest passing candidate; every gate has a control (PRD-351) An executable negative control per gate (caps, no-upscale, byte minimum, histogram, score tolerance, missing scores, Zstd/mips, cache identity, fallback budget, atomic publication). The byte-minimum control went red: the shared ladder selected a 676 B candidate while a 566 B one passed; fixed, cache identity bumped. RDO crash (criterion 9): historical inputs are gone; substitute 1024/4096 UASTC+RDO l3/l1 runs do not crash, rungs kept, inputs and settings recorded in the PRD. Codex arm. * test(assets): prove per-slot caps on a 512 pair, not 4096 Encoding two 4096 textures took 110 s and timed out under the suite's 60 s limit; a 512 pair capped at 256/128 proves the same per-slot behaviour in under a second per target. * fix(assets): capped fallback keeps caps, bounded RDO, pruned scores (PRD-351) - a below-floor or not-smaller fallback now retains the capped pixels as PNG in standalone and embedded texture passes, so caps survive the fallback path - RDO search is bounded for images over 256x256 (pack flags off, 64 B dict) with full-effort plain UASTC as fallback; encoder version bumped to -fast-capped-v3 - imageQuality takes an identity fast path for the retained/identical pair and skips redundant deltaE00 work on unchanged pixels - candidate pruning keeps the smallest passing output, not merely the first - scaffold hashes refreshed for 2dc92a2b2's shared shadow-uniform patch verified: image-quality 55, model-texture-pass/texture-pass/shared-images/compile/ report, scaffold byte-stable; assets tsc clean; Biome warnings only. * fix(core): measure complete GPU frames and rendered shadow tails * feat(core): join frame-budget windows on the simulation tick * chore(deps): refresh pnpm-lock.yaml importers Regenerate the lock after the catalog/importer updates: three@0.185.1 patch hash, vite@8.2.0 patch hash, and typescript -> @typescript/typescript6@6.0.2. No version upgrades beyond the catalog. Checks (TN_RUNNER unset, no build/GPU lanes): - pnpm install --frozen-lockfile: pass - pnpm lint: pass (warnings only, exit 0) - pnpm typecheck: FAIL, packages/core/__tests__/world-gpu-scene.spec.ts:3976 TS2322 depth.image.width is number|undefined vs the number required by Renderer.scenePassDepth (renderer.ts:224). @types/three and the native tsc (7.0.2) are unchanged by this lock diff, so the mismatch is preexisting in source, not introduced here. - pnpm test: FAIL, 24 tests in packages/runtime-native: missing native test binaries (cmake --build), stale SBOM receipts vs the dirty lock, and a generated-render-chain expectation drift. Plus 1 filesystem failure and 69 skipped; run stopped at the first failing package. * fix(core,native): clear root integration gates after the develop merge Two source drifts, both surfaced by the parent's root typecheck/test run (/tmp/runbook-120-root-{typecheck,test}.log). No new files or helpers. - core world-gpu-scene.spec.ts:3976: the depth fixture cast three's DepthTexture.image width/height (number | undefined) straight into scenePassDepth, which declares number. Guard the optional values and throw when absent; no non-null cast, and the 8 -> 9 odd-resize path is unchanged. - runtime-native generated-render-chain.test.mjs: the composed-chain expectation still described the pre-PRD-492 scenario. The scenario added grade/grain to stages.includes, a stages.order list and contributions.graphOutputChanged; assert that actual shape. Outcomes (TN_RUNNER unset): - pnpm typecheck: exit 0 - vitest packages/core/__tests__/world-gpu-scene.spec.ts: 89/89 - vitest tests/generated-render-chain.test.mjs tests/native-sbom.test.mjs: 6/6 - pnpm lint: exit 0 (warnings only) Native SBOM receipt failures were local untracked cache, not source: the third_party receipts predated develop's purely additive native-deps.lock bump (PRD-465 openriglogic). Re-stamped each receipt's lockHash to the current lock; nothing committable (third_party is untracked by contract). The remaining runtime-native failures (crash-handler-policy, native-adapter-policy, pump-silence, rg11b10-renderable, runtime-next-contract, timestamp-query) are unbuilt desktop host/test binaries: build/tn-linux and build/tn-linux-quickjs are absent. The root suite aborts at the first failing package, so packages after runtime-native are unproven, not passed. * docs: record partial Phase 2 proof and correct walking mask * docs: preserve invalid timing attempts in Phase 2 proof * docs: record complete Phase 2 reviews and pilot limits * docs: record tick fixture checks and interrupted capture * fix(core): keep fallback caster layers until a shadow key mesh exists A published tnShadowGpuKeys provider switched a level camera to the key layer even when the world had minted no key mesh, which dropped the wide and small caster layers. #probe now switches only when a visible casting key mesh exists. Regression spec: Machinefall's [250] / 4096 shape, flag on with no keys must submit what the flag-off control submits (red: 19 cluster submissions vs 3 fallback; green after). This is not the highway shadow loss in #437: that run minted keys. * fix(core): rebuild the shadow kernel when the GPU scene's buffers grow WorldGpuScene.#growOf reset the main cull kernel on a buffer grow but kept #shadowKernel. The shadow kernel stayed bound to the first placement buffer and its first thread count, so with ?tnShadowGpuKeys=1 every later level render selected from the camp's handful of placements. On the Machinefall walk a debug readback showed 0 twin instances on all 138 keyed renders while the CPU reference selected ~8,900, and the highway lost its road shadows (#437). Red-green: world-gpu-scene.spec.ts "rebuilds the level's kernel against a placement buffer that grew after its first render" reused the same ComputeNode before the fix and passes after it. Five shadow/GPU-scene suites: 192 passed. Rendered map-walk with keys on: road shadows restored, 20/20 assertions, judge PASS against the keys-off control. * docs(prd-478): record pop intervals 04/28 on the shadow-kernel fix * docs(prd-478): mark Phase 3 done; both its boxes were already ticked * fix(core): shadow keys cast at the main pass's level, from the eye The GPU shadow kernel picked each placement's LOD by its distance from the shadow window centre. In an aerial pose the centre is the view's focus, about 150 m ahead of the eye. Placements near the eye then selected LOD 1-3. With castLevels: 1 those levels have no twin mesh, so the placements cast nothing, while the cluster path cast them from their LOD 0 key. A coarse map asked for COARSEST_SHADOW_LEVEL, which also has no twin with castLevels: 1, so it drew no keys at all. The shadow kernel now uses the main pass's eye and LOD bias, so a placement casts from the key the main pass draws it in. A placement whose level has no twin casts nothing, and a map's base is clamped to the last level that has a twin. A minted twin marks its main key record (keys.w) so the kernel can read that. IShadowLevel.centre is unused and is removed. Red-green: world-gpu-scene.spec.ts "draws each placement at the main pass's own level, from the eye, and only where that level casts" fails before (expected [ 1 ] to deeply equal [ +0, 3 ]) and passes after. Nine shadow/GPU-scene spec files: 239 passed. * docs(prd-478): record the eye-LOD shadow fix and its blind review * feat(scripts): pair two playtest reports label by label playtest-pair.ts joins two report.json componentSeries on label and prints each field A|B plus a camera same/diff column. --equal names fields that must match; a difference, a missing label or an empty series exits 1. It is the PRD-478 paired-walk and same-arm counter check. * docs(prd-478): record the paired-report tool and the same-arm counter drift * docs(prd-478): same-arm byMove drifts on a quiet machine too * feat(core): count shadow window travel in pages byMove counts move renders, so it depends on how many rendered frames sampled the camera path: a fixed-tick route run at two frame rates reports different counts (laptop pair: 2 of 38 labels dif…














Single PR for docs/PRDs/open-world/RUNBOOK-machinefall-120fps-with-fab-quality.md; replaces #429 and #431.
Both lanes merge into this branch unchanged (
--no-ff, in the order below); the only commit on top is the regenerated scaffold tree hash, because PRD-493's rewrittencapabilities.jsonentry andcapability-reference.mdreach every scaffold verbatim.PRD-492 — colour grading and film grain (from #431)
Branch
feat/prd-492-grading, merged--no-ff.TL;DR
The starter's frame now ends in a colour grade and a film grain, and the stage that does it takes a
new seat in the chain: display-referred, after the output transform. The filed design put a log
shaper inside the stage instead; it was measured, it costs at least 2.0 eight-bit steps per channel,
and this PRD's own box asks for 1. The engine seat it falls back to — named in the PRD before it was
written — lands the box by construction and makes a
.cubemean what a grading tool means by it.Phase 1 only. Phase 2 (native) has not run.
What changed
templates/starter/src/render/grade.ts(new)grade(a.cubethroughlut3D) andgrain(film)templates/starter/public/grade.cube(new),tools/make-grade-lut.mjs(new)templates/starter/src/render/{postprocessing,quality}.tspackages/core/src/render/chain.ts,renderer.tsafterOutputTransformon a stage;setOutputNode(node, pass, options)hands the renderer its transform back only when such a stage actually rantemplate-assets/worldEnvironment.ts+ 13 kit copiesChainStagetype learns the flagPhase 2, since the first push
pnpm native:buildcompleted (409/409), so the desktop lane ran here.pnpm parity --target desktop --only-tests lut-grade:status: "pass",pixelMismatchRatio: 0,perceptualDeltaE: 0over 1280x720,gpuValidationErrors: [], host rendering 300 frames in 9.4 s, compared against a web-lanereference capture of the same scene. The 17^3 table is a per-channel gain, not the identity, and
the parsed
Data3DTextureis read back in JS against that formula before the frame is compared.(The sweep exits 2 because a bounded
--only-testsrun blocks the other 95 rows.)dropped: [{"name":"grade","reason":"lut:pending"}]and the console carriesTN_GRADE_TABLE grade.cube: File read error.package-desktop.mjsstagesselectManifestAssets(assets)alone, so apublic/file the manifest does not declare neverreaches the host — every unmanaged public file on every native target, not this table alone.
visibilityassertion is refused outrighton desktop (
TN_PLAYTEST_OBSERVATION_UNAVAILABLE), so it failed before reading a stage. That rowis dropped (the runner never produced the observation on this target) and
grade/grainare addedto the stages and contributions the scenario does read.
Evidence that ran
pnpm typecheck,pnpm lint(0 errors), 259 vitest files acrosspackages/coreandpackages/create-threenative— all pass.packages/create-threenative/__tests__/grade.spec.ts— 8 passed. It reads the shipped table backthrough a port of
LUTCubeLoader.parse, applieslut3D's half-texel pull-in and the hardware'sown filtering, and measures the identity round trip at ≤ 1 step over every value the frame can
hold (worst 0.875).
look.playtest.jsonon a scaffolded starter,--browser-recipe webgpu --headed,adapterClass: hardware:renderChain.tier,renderChain.stages.includes,renderChain.stages.orderandrenderChain.contributions.graphOutputChangedall pass withstages: ["ambientOcclusion","bloom","vignette","antialias","grade","grain"],dropped: [].lowleg needs an adapter the harness classes as software, and Chromium here reaches noWebGPU adapter under SwiftShader (
TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). It was run instead ona throwaway copy with
setupPost(..., { tier: "low" })pinned:gradeapplied,dropped: [{"name":"grain","reason":"grainIntensity:0"}], both assertion rows pass. Nothing about that pinis committed.
Open, with the reason
gpuOtherresolved in 1 of 17 windows graded and 0 of 16 ungraded at 1080p on thislane, so there is no delta to read. A missing observation is not a zero.
quarry compareon the scaffold readsmaxChannelDelta 86, rmse 0.81for ungraded vs identity-with-grain-off, and the same ungradedbuild captured twice reads
maxChannelDelta 114, rmse 0.37. The starter's own animated contentmoves between runs by more than the claim, so a whole-frame maximum cannot carry it. A still-scene
capture would.
.afk/scratch/prd492-captures/phase1/{before,after}.png,1920x1080, same scaffold and same pose, the only difference being the grade.
lut-gradeconformance row have notrun.
pnpm build:webalready reports the table among 5 undeclared files (38,890 bytes), which isthe packager risk this PRD named.
Progress label
prd:25%.PRD-493 — terrain layers past sixteen textures (from #429)
Branch
feat/prd-493-terrain-layers, merged--no-fffirst.Why
A splat terrain's layers are one texture each until they stack. Compressed layers already collapse
into one array texture per set; uncompressed ones did not, so any game without a KTX2 cook — and
every Android and iOS build, which has no transcoder — bound one sampler per map and hit WebGPU's
16 sampled textures a stage. The
@constraintalso named a recipe that does not exist, and thematerial hard-coded
roughness: 0.92for every layer, so a Megascans-grade layer's own ORM neverreached the screen.
PRD:
docs/PRDs/open-world/PRD-493-terrain-layers-past-sixteen-textures.mdChecklist
copyTextureToTextureper layer (no CPU pixel read); mixed sizes still fall back.proof:
packages/core/__tests__/world-terrain-splat.spec.ts9/9 green;pnpm typecheckandpnpm lintexit 0.@constraintand recipe name corrected, manifest regenerated bypnpm build.proof:
capability-manifest.spec.ts+generate-capability-reference.spec.ts28/28 green.orm: truein the table drives roughness/AO/metalness from<id>_orm; a layer without one uses the table's numbers and the 0.92 look constant is gonefrom the package; the recipe writes
_ormbeside_diff/_nrmin both copies.proof:
world-terrain-splat.spec.ts11/11,export-world.spec.ts9/9 on Blender 5.2.0 LTS.terrain-splat-arraybuilds the 16-layer uncompressed package and throws unless the markerreads
layers=16 samplers=4 stacked=3.proof:
pnpm parity --target web --only-tests terrain-splat-arraycaptured the reference;--target desktoppass withpixelMismatchRatio: 0,perceptualDeltaE: 0, 0 GPU validationerrors; runtime-native conformance suite 76/76.
:app:verifyV8Dependencyneedsthird_party/v8-android/build-receipt.json, written only by aV8 source cross-compile for arm64-v8a + x86_64. The emulator was up (API 35, x86_64) and the
lane reached gradle, so the blocker is that dependency, not the device.
gpuMainp95.apps/clientis aprivate repo this branch cannot see, so neither was run; both are listed under
## Blocked onin the PRD rather than claimed here.
How the stacking works
A game passes
renderer: ctx.renderer; without it every layer keeps its own sampler andTN_TERRAIN_SPLAT layers= samplers= stacked=says what that costs.Proof on a GPU
examples/prd493-terrain-splatis sixteen layers × (albedo, normal, ORM) = 48 uncompressed JPEGs,written by the shipped
export_terrain_layersrecipe. On the web recipe and on the native desktophost it renders with
layers=16 samplers=4 stacked=3; the same package ondevelopnever reaches aframe:
Captures for a judge — before/after, same pose — are in
/home/joao/projects/threenative/threenative-engine/.afk/scratch/prd493-captures/:phase-1-2/(package on develop vs this branch) and
phase-3/(browser reference vs the desktop-native frame).Not claimed
The Android row, and Machinefall's own walk. Neither is ticked, and the PR stays a draft until AC-1
has a judge.
Row A7, attempt 2 (2026-10-04): proved, and it blocks the launch — do not merge this arm
An empty prewarmed batch now submits one instance of the zero matrix its own fresh buffer holds,
because
RenderObject.getDrawParametersreturns null atcount === 0and three therefore neverbuilt the node the prewarm was waiting for.
world-cells-shadow-prewarmhad been counting thatdraw without three's count gate; with the gate in, it reads
castersUnbuilt=8before this changeand
castersUnbuilt=0after, with every prewarmed caster still first drawing behind the gate.On the walk it is fatal, twice, alternating arms in one lane (
map-walk,tnFrameSpans=1, hostload 24–32, runs in
.afk/scratch/walk-a7b-pair-{1,2,3,4}):core-dev2(1a6298cd), pair-1compileSettledcore-dev2, pair-3core-a7b, pair-2 and pair-4TN_STARTUP_STALLEDat 0.0 % with nothing outstanding, the page's main thread never yields (describeexceeded 915 s), the 900 s timeout takes the run, the walk never startsSo there is no walk to compare, nothing to capture and no blind bundle: one arm never leaves the
loading screen. The mechanism is right at fixture scale (24 keys × 2) and fatal at Machinefall's
(385 × 2), and the open question is whether the gate is paying the node builds or its own
per-update shadow invalidation, which now has every prewarmed caster to submit. A7, its boxes and
AC-3 stay open; the next step is one instrumented run that separates the two. The engine change is
in this PR so the measurement is reproducible, and it must not be merged as it stands.