Skip to content

feat(render): authored exposure and volumetric fog (PRD-339 / VQ-07) - #397

Merged
jonit-dev merged 117 commits into
developfrom
feat/prd-339-auto-exposure
Oct 4, 2026
Merged

jonit-dev merged 117 commits into
developfrom
feat/prd-339-auto-exposure

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Exposure and volumetric fog now share one ownership-safe output graph and one coherent CI board. Fog composes scene-linear colour before automatic exposure; rebuild, resize, output replacement and disposal preserve each feature's lifecycle contract. This survivor consolidates #399 while preserving both original source commits and archived PRDs.

Earlier consolidation qualification: all seven actual NVIDIA WebGPU browser controls/lifecycle arms pass from source b0560e3c623ffb5eba607ce30f14c646dd84de0b; fresh Linux native coverage executed 45 targets with unchanged floors. Focused rendering 126 tests, CI process 211 tests, native runner 84 tests, joint freshness/control 28 tests, root TypeScript, workspace builds, docs and budgets pass. Original full native/browser scenarios remain required by exact-head CI.

CI follow-up candidate a59821c6a1001d1c6b64db21d1df92c7e0969288: Sailing now waits for its first finite height through the existing startup hold and unchanged 45-second boundary; exit/restart cleanup is covered. The original 330-tick, five-assertion scenario passed on SwiftShader with the readiness path observed. Native vehicle conformance now declares its own locked catalog Rapier dependency, with a clean isolated-importer regression. The passive fluid observer retains its last 24 samples through terminal exit and bounded fresh capture/console facts. It preserves the child exit status and all original assertions/timeouts.

Validation: final independent combined review; full affected conformance runner 76/76; observer/Sailing 27/27; normal scaffold drift 320/320; root typecheck, build, lint, docs/instructions and unchanged budgets. The real vehicle-only bundle row validates both sides; its registry-wide CLI correctly exits 2 for excluded rows, so this is not a full native qualification. The formatted sealed Sailing receipt retains all measured facts; original sealed bytes remain preserved. Exact first landing timestamp, hardware/mobile/native and performance qualification are not claimed.

Remaining CI work: the underlying fluid consumer timeout is unresolved; diagnostic retention alone is not its fix. Earlier c16786fe3ab506bd63379b6e082fafb659c2ad2a CI ended with 53 successful checks, failed web-reference/vehicle import and fluid consumers, plus the failed aggregate gate. Fresh exact-head hosted CI is required for this candidate. No merge to develop or green CI claim is made.

Actual passing GPU evidence and settings · Preserved initial lifecycle failure

Off control Fog and automatic exposure
Actual off control Actual fog and exposure

Both frames are actual unchanged 640×400 captures from the same measured source and authored room. They qualify functional browser rendering; they make no GPU frame-time, Android, macOS, Windows, or combined native-runtime claim.

Preserved original heads: #397 f54efbdc9cadac5bf8719adf3435fca98d872bb4; #399 edd5e1d6eef2ef306234a615d6f392861b2ee0f8. Both are merge ancestors of this survivor. The #399 native repair is preserved from the immutable frozen patch, with a separately reviewed lint-only split of the lazy compiler import; its timeout, caching and dependency semantics are unchanged.

Original #397 checkpoint and checklist (preserved verbatim)

Opt-in generated auto exposure uses the existing world pass, applies RGB exposure before bloom/output, and preserves authored settings with the default cost off in all thirteen templates. Deferred native readback mapping and lifetime fixes preserve actual paired GPU state across resize, output rebuild and reset.

PRD339 is 100% complete: six phase boxes and five acceptance criteria. Owner-approved AC2 keeps ten-boot 10% repeatability and independently requires the first actual GPU update after the unchanged eleven-stop cut to reach 0.25 stops at delta1/60. Gain1 error is 0.000000476837 stops; gain0 fails the same gate at 10.553099394 stops. The original ineffective spread mutation remains preserved honestly.

Current-base qualification ran six actual hardware arms at ae44ab8: three 360-tick consumer controls, 720-tick rebuild/resize/reset, native 180-tick positive and injected-validation control. Browser NVIDIA Turing WebGPU and native Vulkan RTX2080 observations verify the expected positive and negative gates. Fixture scale1 explicitly preserves its controlled input after the renderer default changed; actual canvas, buffer, renderer size and DPR markers are retained. Core/playtest/native source trees remain byte-identical to capture at the publication head; the fixture differs only in import ordering. Native instrumented coverage remains 19,217/24,373 (78.85%) under its unchanged native source closure.

User-approved historical cleanup removes exactly39 files present in this branch and updates five historical prose links. All39 removed originals are hash/blob verified at the already published immutable4ce93bcd anchor. All92 immutable protected evidence files and26 preservation hashes remain unchanged. The separate #416 debug log is outside this branch. Complete current paired traces and12 exact PNG payloads are retained losslessly in the324,716-byte XZ bundle; no external gist was published. Verification evidence is69,940,795bytes under the unchanged72MiB cap.

Validation: required typecheck and full lint pass;176 exposure tests,37 actual Git-selector/pin tests,66 scaffold tests, official evidence budget and unchanged normal ci:fast/hooks pass. Fresh independent source, actual control/proof, cleanup/reference and final public-recovery reviews pass. Integration routing uses the current impact selector, retains fluid/world-capture gates, and covers the five exposure modules and actual shared runtime dependencies.

Acceptance is complete; merge readiness remains pending green checks at f54efbdc9cadac5bf8719adf3435fca98d872bb4: CI, integration. Auto-merge is not enabled yet.

The coherent CI repair preserves collected failure logs and primary errors, separates native/browser fluid dependency routing, and covers the actual exposure policy import. Shared-selector delivery honestly selects all integration lanes; the diagnostic runner edit also requires browser coverage. Rain now respects its existing software default while preserving explicit HIGH through supported UI input. The actual v2 receipt retains eight original passing assertions, six image hashes and HIGH/PERFORMANCE captures. This owner fixture qualification is not a final-head capture or proof of the CI device-loss cause. Original exposure/native captures and identities remain unchanged.

520 scoped tests, full typecheck/lint, independent source/evidence reviews, official evidence budget and all four normal pre-push checks pass. Actual paired no-install scaffolds changed only four intended Rain files; the other twelve trees are identical. No quality budget, threshold, adapter-null guard, pipeline security setting or evidence cap was waived.

Previous49f runs remain failures: exposure and cold-boot recovered once at unchanged source, while fluid's single recovery failed before assertions on screenshot stability. The original fluid device-loss and rain adapter-null artifacts remain preserved. Earlier4ce integration and its unchanged retry also remain failed, with no unproven GPU-loss/deadlock cause assigned. Normal auto-merge will be enabled only after the new exact-head checks qualify.

Original #399 checkpoint and checklist (preserved verbatim)

Bounded, depth-correct volumetric fog now ships as generated starter source in src/render/, with opt-in density, colour, overlapping bounds, height falloff, resolution and step controls. The default stays off. Directional shadows and an explicitly unshadowed finite-range point-light subset contribute independently of extinction. Scene depth occludes the medium; inactive, unsupported and zero-density paths allocate no fog targets. Owned targets/materials and scene-fog suppression are released through the existing graph lifecycle.

PRD-VQ-07 is complete and archived: six phase boxes and two acceptance criteria verified; label prd:100% — ready.

Validation at e87faa6f9669e85742db659a08a15ec5a8cd8ea5:

  • Full CI 37151307501 succeeded, including ci-required and native platform checks. Rain's initial adapter/device-loss failure passed on its exact-job retry.
  • Browser fog qualification: 29 arms, 392 in-arm and 13 cross-arm assertions; zero failures or console/network/runtime diagnostics. Artifact 11283674657, SHA256 dc6c332a…; Chromium WebGPU/SwiftShader, 640×400 screenshots.
  • Linux ARM64 native fog: 29 arms, 419 in-arm and 13 cross-arm assertions, 29 nonblank gates; zero failures. Both no-fog resize controls passed with zero fog targets. The lane rebuilt the host and executed compiled acquisition-status and actual GPU destination-pixel tests. Artifact 11285180012, SHA256 5a9d03ff…; llvmpipe, runtime 5a164cc2…, bundle b76f3417….
  • Zero density restores identical off pixels; resize, camera cuts, streamed occluders and repeated scene transitions match fresh references and restore the texture baseline. Overlap clears unchanged thresholds at 0.09386 changed channels / maximum delta43 against 0.05 /4. All artifact summaries and attempts identify this exact source.

Nonblocking integration failure: fluid consumers failed three executions with screenshot stability timeout, advance timeout, then SwiftShader device loss/blank capture and a failed maxSpeed observation (latest artifact 11285747559). No more retries or threshold changes were made. Independent review confirmed this PR changes no fluid simulation or capture-harness code; its output-graph APIs are unused by that browser fixture. The integration run therefore ends failed although both fog jobs passed. This is outside VQ-07's scope; branch rules require the passing ci-required check. No protection or required check is bypassed.

Qualification is software correctness only. GPU means retain sparse query coverage (browser4/30 answered, native4/30,4/30,3/30). No phone/hardware performance, default-tier admission, OS-window lifecycle or wider local-shadow claim is made.

Protected merge queue status

The PR remains unmerged. Merge-group CI 37156742265, attempt 2 passed RTS on its bounded retry, but required rain still failed: rain-simulation-advances observed zero frames because the WebGPU adapter request resolved null (TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). Its preceding scenarios passed assertions with software-device-loss warnings. ci-required correctly failed. No source, assertions, provenance requirements, timeouts, or branch protections were changed. Further blind retries stopped after the repeated adapter failure; queue runner WebGPU availability remains unresolved.

Rain software-quality correction at edd5e1d6eef2ef306234a615d6f392861b2ee0f8

Rain previously chose the software-safe built-in post chain but its UI still selected the expensive high study on wide windows. Scene loading now chooses the existing performance study before the first software-rendered frame; URL and player choices remain explicit overrides, and hardware retains its existing 700px window policy. The four-tier scenario explicitly chooses high through its existing quality menu, with every original assertion, transition and screenshot retained.

Validation: 87 focused tests, scoped scaffolder TypeScript, all 66 scaffold contracts and the normal pre-push lint/docs/agent/drift checks passed. Independent review found no blockers. A generated byte comparison reproduces both Rain scaffold hashes: exactly the four reviewed template files changed, with 108 files unchanged; every other template pin and the hash guard remain intact.

Matched local simulation runs on real SwiftShader passed the original 120-tick assertions on both baseline and candidate, with empty diagnostics. The candidate retained performance/32 cloud steps rather than baseline high/64. The corrected four-tier gate passed all eight original assertion results and retained six screenshots, with empty diagnostics; the labeled requested-high state observed high/64 steps before the original high checkpoint, followed by balanced/48, performance/32 and ultra/88. The initial unsupported resource-setup experiment failed before assertions because the engine state provider is read-only; its failure evidence was retained and that setup removed.

These runs establish the bounded quality-policy fix, but the healthy baseline does not establish the cause or resolution of the queue's adapter-null/device-loss failures. Fresh CI 37169021789 and integration 37169021686 are pending on this exact head. No blind retry, workflow/routing/runner change, merge or protection bypass was performed.

The fog implementation, qualification fixtures and completed VQ-07 PRD are byte-identical to e87faa6f in this correction. The fog acceptance evidence above remains tied to that original head; it is not presented as a fresh run on the new head. New-head fog and other selected integration checks remain subject to current CI.

Exposure CI repair and completed merge

The shared playtest wait now allows eight half-step slices (83.3 ms total for a one-tick request), covering its documented 15 fps bound instead of falsely rejecting the measured 55–59 ms CI cadence. It still rejects a stopped host and never synthesizes ticks. The phase-aligned 60 ms regression failed before the change and passed afterward. Only the shared wait and its test changed in repair commit a9b29e9f941027bd7a9ab1cfa55dc210b0b2b58f; head 6db1562a5ed80e7799b173516a0b9adcf7c6cfed also incorporated the CI-only develop update.

Validation: local focused tests, TypeScript, lint, workspace build and all 18 hardware exposure cases passed. Full local tests reported six native test files without built executables; exact-head hosted CI subsequently qualified the candidate, including native and Android parity. PR CI passed. All selected rendering integration checks passed; the documented out-of-scope fluid software-device-loss advisory remained the sole nonblocking integration failure. No fluid watchdog, assertion or timeout change is included.

Merge-group CI passed its required ci-required gate at 09:43:35 UTC. GitHub squash-merged this PR into develop at 09:44:08 UTC on 2026-10-04 as 62e2dab6422e27508716bfcc3591d578ea9d1643. The landed tree exactly matches reviewed head 6db1562a; develop contained that commit at verification. The trailing run-summary job was cancelled at 09:44:18 UTC after merge, so the run-level conclusion reads cancelled; no required gate was bypassed.

The original local-edit stash 20bab637021fd1b986d1f7274b7f759ca04dd393 is preserved. The 2.3 GB task checkout was removed after explicit cleanup approval; its worktree registration is gone and the original local-edit stash remains preserved.

@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Oct 2, 2026 — with ChatGPT Codex Connector
@jonit-dev jonit-dev removed the prd:0% PRD implementation has no verified phase yet label Oct 2, 2026
@jonit-dev jonit-dev added the prd:25% PRD phase 1 verified label Oct 2, 2026 — with ChatGPT Codex Connector

Copy link
Copy Markdown
Contributor Author

Runtime screenshot progress: diagnostic, incomplete

This is the actual dark-room fixture rendered by the GPU exposure graph at source bb34aff158d07973cba5eaef53b3464caa7917bf, from run 36986637037 / artifact 11217399345. Adapter: Google SwiftShader, WebGPU, headed Chromium under private Xvfb.

Actual auto-exposure dark-room runtime capture

The frame is readable and nonblank. GPU measurement: luminance 0.001918947; applied 6.4936 stops versus target 6.5515, settled=true. The overall run FAILED a browser console 404; the failing URL is still under investigation. This is progress evidence, not full acceptance, native parity, performance, or transition proof. Paired/sequence captures will follow.

Exact provenance and original PNG SHA-256. PNG bytes preserved: 3090d234d78130ce9a4e1e9068017fc88e4bbad4827274bef2ebb3ec4f1f2820.

Copy link
Copy Markdown
Contributor Author

Runtime progress: sunlight regression exposed, correction awaiting rerun

The dark-room case passed without diagnostics after the fixture favicon repair, but the actual sunlight frame below failed the blank-image guard despite settled exposure telemetry. Source eaa583b1c29b913a382ddf0fcdae35b046b2854b, run 36988237465, Google SwiftShader WebGPU. This failure is preserved rather than waived.

FAILED actual sunlight runtime capture before alpha correction

The shader used exp2(stops), but the composition multiplied all four RGBA channels. At exposure below1, that also reduced coverage alpha. New head c7091c7edca15f6e0c415c3cdbfb2a0be696d9cc applies exposure to RGB only and preserves alpha. The graph regression,128 focused tests, root TypeScript, instruction budgets and scaffold contracts pass locally; actual bright/dark rerun is still required. No defaults changed and no full-acceptance/native/performance claim.

Original failed-image provenance and SHA-256.

Copy link
Copy Markdown
Contributor Author

Runtime progress: corrected dark/sunlight endpoints pass; transition remains open

Actual unedited captures from source c7091c7edca15f6e0c415c3cdbfb2a0be696d9cc, run 36995411940, artifact 11221785080; Google SwiftShader WebGPU,640×360. Both endpoint reports pass with no console/device-loss diagnostics, and both exposure measurements are settled.

Dark room: PASS

Actual settled dark-room frame
Measured luminance0.001918947; applied6.5429 stops, target6.5515.

Sunlight: PASS

Actual settled sunlight frame after RGB-only alpha correction
Measured luminance3.896104; applied−4.4356 stops, target−4.4360. The RGB-only correction preserves coverage alpha and repairs the previously black sunlight output.

Eleven-stop cut: FAIL, retained

Actual unsettled transition frame
This frame is washed out. Applied−0.1848 stops versus target−4.4360; settled=false after the scripted wait. The full integration run therefore FAILED. The one-stop and disabled cases were not reached. Next diagnosis must measure actual rendered updates/integrated delta time, rather than weakening the settle requirement.

Exact source/run/adapter provenance and original PNG SHA-256 values. These endpoint pixels are software-browser correctness evidence only; no native, performance, transition, cold-boot-repeatability or full-acceptance claim.

Copy link
Copy Markdown
Contributor Author

The cut failure is now traced to the test clock. Timing diagnostic run 36997457561, source 1f6c58af0ea8123d28b575b204c0cbeb1d828e38, observed only 11 actual exposure GPU updates / 0.7265 consumed seconds after the step named waitFrames: 180. The fixed-step runner batches those as gameplay ticks. Its final GPU readback correctly remained unsettled; there were no console or device-loss diagnostics.

Head 5c3b1762477d4a0f076b3779e22212c737ed5583 selects the existing public wall-clock playtest mode for this fixture, preserving the authored rates, snap policy and 180-frame limit. The proof now counts actual GPU updates through the first settled readback at the new target and rejects a stale pre-cut sample. The three new regression tests went red then green; 40 focused exposure tests pass. Runtime rerun 36998395106 is pending; dynamics are still unqualified.

The current develop merge is included as a real second parent, with combined scaffold fingerprints remeasured and 128 focused/scaffold/mirror tests passing. The earlier inspected endpoint screenshots and failing cut remain the visual evidence until the corrected run is inspected.

Retains exact PR397 f54efbd and PR399 edd5e1d through ordinary merge ancestry. Reconcile automatic exposure with owned output receipts and idempotent fog graph cleanup; preserve both archived PRDs and all source evidence.

Frozen owner fixes: passive fluid observer SHA256 4c1c9609dadb5b6cd955bde1dff1e2106dc1f59a9819c8a808a2b6858daabd0f; unit cleanup b81d320b7a82b73d5ad0fbdc9f30d051914e5b28b2eeae4adf0abeaf389b8b78; native bundle service 07c8a6f83c6e8c7289c8e72d5e78f478bc141b5203206c32e3d948c8cef2173d. Local candidate checkpoint only: combined native coverage and hosted CI remain unverified; publication requires parent review/authority.
@jonit-dev jonit-dev changed the title feat(render): authored auto exposure and eye adaptation (PRD-339) feat(render): authored exposure and volumetric fog (PRD-339 / VQ-07) Oct 4, 2026
jonit-dev and others added 6 commits October 3, 2026 21:56
…s to fluid sources

- sailing AGENTS.md/CLAUDE.md joined two wrapped line pairs (101 -> 99 lines
  by the template.spec split count); repinned the sailing scaffold digest.
- fluid-particles / fluid-native path filters matched every file under
  packages/core, so an unrelated render-chain edit ran the fluid proofs.
  They now match core's fluid-*, gpu-readback and particles sources only;
  physics and playtest still select them. Specs updated, with a negative
  control for renderer.ts and render/chain.ts.
@jonit-dev
jonit-dev added this pull request to the merge queue Oct 4, 2026
Merged via the queue into develop with commit 62e2dab Oct 4, 2026
72 of 73 checks passed
jonit-dev added a commit that referenced this pull request Oct 5, 2026
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.
jonit-dev added a commit that referenced this pull request Oct 8, 2026
Three PRDs claimed 339. PRD-339-the-frame-sets-its-own-exposure keeps it
(PR #397 landed it), so the compile walk and loading-screen PRDs moved to the
next free numbers: max across docs/PRDs was 494.

- performance/critical/PRD-339-the-compile-walk-leaves-the-main-thread.md -> PRD-495
- performance/PRD-339-the-loading-screen-is-bytes-not-files.md -> PRD-496

Every link, table row and prose mention that meant those two now names the new
number; the auto-exposure mentions in rendering/, open-world/ and the runbook
still say PRD-339.

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
jonit-dev added a commit that referenced this pull request Oct 8, 2026
Auto exposure and volumetric fog are in review (#397, #399), tone gates
ship (PRD-341), GPU-driven culling and projected-size culling are
partial (PRD-386, PRD-390). Cells edited in the sheet XML so the
workbook keeps its conditional formatting.
jonit-dev added a commit that referenced this pull request Oct 9, 2026
…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…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:100% — ready PRD complete, ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant