Skip to content

Release 0.3.12: Strata terrain hardening, material lights, GI calibration fix - #620

Open
jonit-dev wants to merge 35 commits into
mainfrom
develop
Open

jonit-dev wants to merge 35 commits into
mainfrom
develop

Conversation

@jonit-dev

Copy link
Copy Markdown
Contributor

What is this PR about?

The develop → main promotion for the 0.3.12 cohort. When this PR merges with a merge commit, the green main push publishes all 16 packages to npm (ship in npm-release.yml).

Why does it matter?

It ships the Strata terrain hardening epic and the two PRs merged beside it, so games scaffolded from npm get them.

What ships

Checks run before this PR

  • pnpm release:prepare passed on 0592ccd8e: 16 packages and 6 MCP servers. Its one acknowledged finding is that the runtime-native 0.3.12 prebuilt does not exist yet, and the release builds it.
  • After the merges, develop passes pnpm budgets, and its native coverage report is fresh.
  • Merged engine: core-only ctest passed 270 of 270, and host ctest passed 567 of 570. The 3 failing host tests are known reds that already failed before the merge.

Still open

jonit-dev and others added 30 commits November 3, 2026 10:23
blob:none still fetched every HEAD blob (488 MB) to fill the working tree.
Under host load that checkout took 3m20s of the 5-minute bound and the
classify step was cancelled (runs 41688746026, 41702153229). The scope
and admission scripts read only scripts/ and each kit.json: a cone
sparse checkout of those is 25 MB. Proven on a sparse clone: the PR
classification exits 0 with the same plan. Guarded in ci-structure.spec.ts.
…606)

* feat(temporal): PRD-539 history200 and velocity-clamp gate arms (Unreal TSR levers 1-2)

historyScale stores resolve and history at 2x the display raster
(r.TSR.History.ScreenPercentage=200); velocityWeightClamp ports TSR's
sample-count clamp under motion. Both are opt-in arms of the unchanged gate.

* test(scaffold): starter tree hash for the PRD-539 temporal arms

* style(temporal): sort imports; refresh starter tree hash

* feat(unreal-look): screenPercentage knob and a side-by-side motion scorer for PRD-539

* feat(unreal-look): CSV GPU stats for Unreal captures, ProfileGPU/CSV reader, static-camera gate control (PRD-539)

* fix(unreal-look): side-by-side sheet layout, crop row and --metric-frames range

* feat(temporal): PRD-539 clean history200 control and the TSR alignment-refine arm

A full-resolution input under a 2x history keeps its owned point sample, so
history scale is the only variable. The tsr arm adds Unreal TSR's refine
rule: the current frame enters each history pixel through the history-pixel
kernel and weighs by its nearest jittered sample's alignment.

* fix(unreal-look): screenPercentage marker uses the forwarded TN_CALIBRATION prefix

* fix(engine): size lamp shadow maps from the unjittered camera

A pixel's footprint does not move with the TRAA subpixel offset. The
jittered frustum let a receiver on the view's edge enter and leave, so a
lamp whose footprint sat on a mip boundary flipped its map size every
frame and reallocated it. On the Wasm engine the released maps waited for
JS GC: the PRD-539 cornell motion run at screenPercentage=50 grew VRAM
about 65 MB per step and lost the device (VK_ERROR_OUT_OF_DEVICE_MEMORY)
before frame 0.

Red-green: lamp_size_ignores_temporal_jitter read 2 sizes over 16 still
TRAA frames before, 1 after. lamp_atlas and 24 lamp/shadow/light/TRAA
render-database cases pass.

* docs(prd): PRD-539 gate run 3 rejects the alignment refine; starter hash

* style: format verify-temporal-motion output path

* docs(prd): PRD-539 same-scene Unreal vs ThreeNative cost and quality table

* feat(temporal): PRD-539 lever 3 measured and rejected; drop the rejected refine and spatial-AA arms

Gate run 4: Unreal TSR's spatial anti-aliasing of rejected input leaves
edge error unchanged and raises reveal stale (full .0003 -> .0024, low
input .0177 -> .0204). Gate run 3 already rejected the alignment refine.
Both arms leave the provider and the gate; their numbers stay in the PRD.
history200-clamp stays opt-in: as a default it presents a 2x raster.

* feat(starter): resolve a 2x temporal history to the display raster

Port of Unreal TSR's history resolve (UE 5.8.3 TSRResolveHistory.usf:312-357): a
4x4 Mitchell (B=C=1/3) downsample with the 1/(R+2G+B+4) HDR weight, clamped to
the samples' range. At historyScale 2 the chain now reads a display-sized
raster instead of the 2x history, so later post stages stop paying 4x pixels.
Red: presented 3840x2160 for a 1920x1080 display. Green: 1920x1080.

* fix(starter): seed the history resolve clamp from a sample

WGSL has no infinity literal; gate run 5 failed the history200 arm with
'expected , for function call' at vec4<f32>(Infinity.0, ...).

* feat(starter): temporal AA defaults to Unreal's Epic-tier TSR history

With no weighting named, the provider keeps history at 200% of the display
(UE 5.8.3 BaseScalability.ini:89) with the velocity-clamped weight, and the
chain reads the display-sized Mitchell resolve. The raw4 path keeps 100%.
{ historyScale: 1, velocityWeightClamp: false } is the named override and the
new history100 gate arm. Gate run 6 measured the two-lever arm at edge .04387
against the .04616 bar (the 100% default read .0486 reference).

The resolve moves to temporalHistoryResolve.ts and the 100% reset seed into
frames.draw, so every starter render file stays under 200 lines.
Red: default history 1920 wide at a 1920 display. Green: 3840.

* docs(prd): PRD-539 gate runs 6-7: the TSR history default passes every full-resolution check

* docs(prd): PRD-539 low-input reveal failure is current-frame reconstruction, not history

* docs(prd): PRD-539 low-input reveal: sub-texel bars moire, lever 4 is the next arm

* feat(starter): PRD-539 lever 4, flicker-aware rejection as an opt-in arm

Port of Unreal TSR's moire measurement (UE 5.8.3 TSRShadingAnalysis.ush:
225-229,263-271,381-455,477-490,586-593): a ping-pong state of luma gradient,
total variation and flicker count, decayed by 0.95 on still pixels, widens the
clip box by 3x the moire error. Opt in with flickerRejection: true; gate arms
flicker, flicker-open and quality-flicker. No 5x5 flicker dilation yet.
Red-green: the kernel holds the moire ramp only with the lever on, and the
update pass compiles to WGSL with no unparseable literal.

* feat(starter): lever 4 dilates the flicker over 5x5 before accumulating

Unreal accumulates max3x3(max3x3()) of the flicker and its variation
(UE 5.8.3 TSRShadingAnalysis.ush:441-455). Without it a sub-texel bar that moves
between neighbours across jitter phases flickered too rarely at any one pixel:
gate run 8 moved low-input edge .07055 -> .07048 only. The update is now a
detect pass and a dilate-and-accumulate pass.

* docs(prd): PRD-539 gate runs 8-9: lever 4 deferred, the low-input clip is the limit

* feat(starter): PRD-539 lever 6, Unreal's low-frequency clamp release (opt-in)

Static-camera gates showed the per-pixel clip to the low-input 3x3 box erases
sub-texel detail that unclipped history keeps. Unreal compares input and
history after a 3x3 blur and lerps the clamped history back to the unclamped
one by Min3x3(Median3x3(rejection)), 0 on disocclusion (UE 5.8.3
TSRShadingAnalysis.ush:25,509-525,563-566,589-636, TSRRejectShading.usf:863,
TSRUpdateHistory.usf:1256-1257). Six input-raster passes before the resolve.
Gate arms release, release-open, quality-release. Red-green: the kernel reads
the release only with the lever on; all six passes compile to WGSL.

* feat(starter): PRD-539 levers 4+6 combined, the moire error widens the clamp-release rejection

UE 5.8.3: TSRShadingAnalysis.ush:569-613. The gate's solo flicker and release
arms become one flicker-release arm; their gate-10 numbers stay in the PRD.

* docs(prd): PRD-539 gate run 10, lever 6 alone deferred; gate 11 runs levers 4+6

* chore(prd539): gate 11 and the low-input probes; the fence calibration scene for Unreal

TN_TEMPORAL_PROBE=arm,arm captures only those arms and prints their edge error (diagnostic).

* chore(unreal-reference): fence scene lit by one point light (template scenes take no motion run), bars share one region

* docs(prd): PRD-539 same-scene benchmark, Unreal TSR vs ThreeNative at 66.7% on the fence

* chore(unreal-reference): fence scene export (scene.glb, scene.json) from Unreal 5.8.3

* test(prd539): scripted three-scale scaler route conformance row

The temporal-aa-scaler-route row steps the input raster 1 -> 0.75 -> 0.5
twice over a raster-bound backdrop and asserts: each step resets history
on its own frame only, textures and geometries return to the frame-7
baseline after each cycle, and no step frame costs more than 10% + 0.05 ms
over its neighbour frames. The runner keeps the route's per-stage GPU
detail (scene pass vs reconstruction) in the report. A held capture now
holds the presented display-sized resolve, so a 2x history can freeze.

* docs(prd): PRD-539 Phase 2 box 2 ticked: scaler route, per-stage cost, allocations flat

* docs(prd): PRD-539 Phase 2 game cost table: shooter route, TRAA at 0.667 saves 43% GPU p50, 22% p95

* test(prd539): low-input bars are Unreal TSR on the same fence scene (owner option 1); Phase 1 done

Unreal 5.8.3 TSR at 66.7% on gi:fence reads 1.74x its own no-AA edge error, and
leaves .082-.090 of a reveal stale; the gate's low-input checks take those values.
The fence scene gains the fixture's occluder in a new reveal run (a visibility
track per hidden box). Gate run 12 exits 0 on nvidia/turing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(prd): PRD-539 box 97: reconstruction cost +0.68 ms, game judge NEUTRAL vs spatial, REGRESSION vs full res (softness); mip bias next

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(engine): Unreal's view texture mip bias under temporal upscaling (PRD-539 lever 7)

Standard-material textures sample at viewMipBias = -log2(1/fraction) - 0.3, at
least -2, quantized to 1/128, while a temporal pass upscales; 0 otherwise, and
shadow casters stay at 0 (UE 5.8.3 SceneVisibility.cpp:5337-5351, :179-194).
Red-green: material alpha_hash and traa_validation; 247/247 cases of the touched
engine test binaries pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(prd): PRD-539 box 97: lever 7 judge IMPROVEMENT, NEUTRAL against full resolution

* docs(prd): PRD-539 box 97: web GPU p50 -36 to -46% per clock level after lever 7, bias cost below noise, web p95 unresolved

* docs(prd): PRD-539 box 97: 4K web p50 -55 to -64%, p95 and desktop blocked on the real display or a GPU clock lock

* docs(prd): PRD-539 done: TRAA 0.667 lowers GPU p50 and p95 on web and desktop with the clock locked; move to done/

* fix(engine): a node material's 2D texture() reads at the view mip bias too (PRD-539 lever 7 review)

The leanpi DeepSeek review found graph texture() nodes sampled at the implicit LOD.
Material lowerings mark themselves; the graph declares viewMipBias at the first 2D
fragment sample, so layouts of texture-free node materials and volume reads (probe GI)
keep their form. Red-green: material_test view_mip_bias_samples. Same-scene Unreal
table at a locked GPU clock recorded in the PRD.

* docs(prd): PRD-539 judge 3: node texture bias NEUTRAL, TRAA 0.667 softer than full res; it stays opt-in

* docs(prd): reopen PRD-539: judge 3 rates TRAA 0.667 a REGRESSION against shipped MSAA

Moves the PRD out of done/. What regressed: the box-97 visual verdict. Judge 2 rated
TRAA 0.667 NEUTRAL (slight softness); judge 3 (commit 9b7ddb4, sheets ee4d4ed on
prd-539-screenshots) rates it a REGRESSION with moderate softness, and a perf win counts
only when the judge passes. Box 97 and acceptance 3 are unticked with their numbers kept;
a textured same-scene comparison against Unreal TSR at 67% is the new open box.

* feat(prd-539): TRAA 67% loses detail equal to Unreal TSR 67% on a textured same scene; PRD-539 done

gi:neon-corridor brick wall, both engines at 100% and 67%, sameness PASS (16 objects, 1 px).
Judge 4 (anisotropy 1): FAIL, far-wall loss larger. Judge 5 (anisotropy 8, Unreal Epic r.MaxAnisotropy): PASS, EQUAL.
Adds the calibration harness knob anisotropy=<n>. Files PRD-789 for the engine default anisotropy (deferred).

* docs(prd): PRD-539 textured table reproduced by leanpi DeepSeek flash max (VERDICT OK), rounding fixed, shooter-in-Unreal decision

* test(native): overlay readback waits on a time bound; refresh native coverage record for PRD-539 sources

overlay_over_frame read the presented frame within 4000 polls; on a loaded host (load 12-19) the readback
landed after that, so presented stayed empty (1 of 3 and 1 of 6 runs red). A 10 s bound plus CHECK(read):
12 of 12 green under the same load. The coverage record digest was stale after this branch's native
changes (CI budgets red on run 41704323408).

* docs(prd-539): João confirms the calibration scenes as the Unreal same-scene benchmark

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ase, runner and GI calibration progress (#605)

* docs(prd-629): the iOS verdict fix landed, so AC-7 is down to the 0.3.10 measurement

* ci(kpis): record the full-board summed queue wait (PRD-480 AC-2)

The AC needs the sum of per-job queue wait over a full board; the shipped KPI
reported only p90. fullBoardQueueSumMedianMin is the median of the window's
finished full boards, from the same per-job started_at - created_at the
merge-latency audit already uses, and every recorded iteration keeps it in
extra. Covered by ci-board-kpis.spec.ts (red without the field).

* docs(prd-480): five full boards measure 196.7-239.8 min of summed runner wait

The 60-minute AC-2 target is not met and is out of reach at today's capacity:
the board's own jobs cannot wait less than 100-129 min on 26 slots. Cause, floor
table and four options recorded; both AC-2 and the phase box stay open.

* docs(ci): drop the duplicate PRD-480 owner-call line

* docs(prd-480): AC-2 becomes a per-job wait p90 of at most 10 min (owner, 2026-11-03); median 11.2 min today

* ci(cache): key the V8 payload by its recipe, share it by artifact, bound the caches (PRD-628)

Phase 1. The payload key hashed all of `download-deps.mjs`, so an unrelated
dependency edit produced a key no asset was published for. `scripts/v8-recipe-hash.mjs`
hashes the `v8-android` DEPS entry alone (a brace scanner that skips comments and
strings) plus the builder, the 16 KB check and `gradle.properties`; the action runs
it in place of `hashFiles(…)`. Six-case spec, red first, plus a mutation check.

A payload a pull request built was reachable only by that pull request, because an
Actions cache is scoped to the ref that saved it. The action now also publishes it as
a workflow artifact named `v8-android-<recipe>`, which has no ref scope, and reads it
back before falling through to the cache and the source build. Only a run that built
and verified the payload publishes, so it is one upload per recipe (25 MB).

The resumable Ninja state saves on a cancelled pull request too, since that progress
is the only thing the next push can resume from. `scripts/ci-cache-prune.mjs` bounds
it to one entry per (os, arch, recipe) and fits the repository total under a stated
9 GiB budget, never dropping a hot key; `prune-cache` in ci-janitor.yml applies it
after every CI run. On the live list (2026-11-03, 56 entries, 10.00 GiB) it plans two
deletions and 8.51 GiB, touching no develop-scoped key.

Phase 2. `CCACHE_BASEDIR` in all five ccache jobs, so objects built in one checkout
hash the same in another. Run 41441113316 measured `Hits: 256 / 459 (55.77%)` when
the previous develop run's entry was still there; 41575174097 restored nothing and
measured 0.46%, which is the eviction the janitor now prevents. The cache table from
that full board is in the PRD.

Phase 3. `scripts/ci-runners.sh prune` drops dangling images, build cache older than
72 h and stopped CI containers; `up` prunes then refuses below a 20 GB floor; the
balancer withdraws its slots while the host is under it and re-advertises when the
space returns; the systemd unit prunes in ExecStartPre. Spec runs the script against
stubbed docker/gh/df. Real host: 349 GB free, `prune: 04:46:04Z (0 min ago)`.

* fix(ci): trust only this repository's V8 artifact, and keep the balancer alive (PRD-628)

The artifact bridge could be poisoned: any run, a fork's pull request run
included, can upload `v8-android-<hash>`, `--verify` only proves the payload
matches its own receipt, and a trusted run that restored one republished it
as the release asset. The read is now pull-request-only, filtered by
`v8-artifact-pick.mjs` to this repository's own head on main, develop or the
run's own head branch, a payload that fails `--verify` cold-builds, and
`publish-android-v8` needs the action's new `built` output.

Also: a failing hash script failed the step silently, so the asset name
could be empty; the balancer died when its prune called `fail`; a `df` it
could not read counted as 0 GB free and paused the pool; the docker root was
read once for a loop that runs for hours; the cache janitor could delete
another ref's bounded state and two janitors could race.

* fix(ci): publish the V8 payload a trusted ref's cache restored (PRD-628)

Review round 2 on the payload bridge.

The release asset lost its seed. Gating the release write on `built` meant a
develop or main push that hit the develop-scoped `android-v8-payload-*` cache
never republished a missing asset: the payload was already there, so nothing
built, and the run published nothing. The action now exports `trusted` — true
when this run compiled the payload, or restored it from the Actions cache on a
non-pull_request event — and `publish-android-v8` requires it. A pull request's
cache is written by pull request code and an artifact is any run's bytes, so
neither is trusted; a run that downloaded the release asset has nothing to
publish and skips as before. `ci-native-qualification.mjs` pins the new gate,
and the trust spec evaluates the shipped expression and the job gate over the
five cases instead of restating them in prose.

The janitor made its own hot keys immortal. Every entry of `cargo-v1-`,
`wgpu-cache-` and `pipeline-cache-host-deps-` carried a new key per source
change and none could be evicted, so stale GBs sat under a budget that only
fitted cold keys. Protection is now the newest entry per (prefix, ref): an older
copy is one another key replaced, and it is the first thing the budget drops.

`v8-artifact-pick.mjs` compared `head_repository_id !== repository_id`, which
passed when the API answered without either field — undefined equals undefined,
so an artifact of unknown origin read as this repository's own. Both ids must be
integers now, else TN_V8_ARTIFACT_MALFORMED.
Red first on all three: the missing output, the stale hot copy and the ambiguous
repository id each failed their spec before the fix. 38 tests in
`packages/runtime-native/tests/native-platform-workflow.test.mjs` (package config),
ci-structure + ci-needs, typecheck and lint are green; lint reports no errors.

* docs(prd): PRD-646 records the L1 gaps as two light scales, 6.2% low on the lamp and 25% high on the sun

* docs(prd): PRD-646 counts the lit rows, not the rows where both frames are black

* fix(unreal-look): a spot light's lumens divide by its cone

Unreal converts lumens to candela over the solid angle the light fills
(LocalLightComponent.cpp:180-214): 4 pi for a point light, and
2 pi (1 - cos outerCone) for a spot, clamped at 88.9 degrees. The harness
divided every light by 4 pi, so a spot in lumens read up to 33 times too
dim; it now converts over the cone and fails the load when a spot in lumens
carries none.

* feat(unreal-look): wildwood passes the gate on Unreal frames of the same scene

The Unreal ID layer of shadow:wildwood, captured from the rebuilt 5.8.3 level,
is committed as shadow/wildwood/id, the offline path the sameness gate reads.
Against the ThreeNative frames of the same scene data the gate passes: 16
objects in 4 poses, 0 px beyond 1 px on every object, farthest edge 1 px, no
pixel without an ID colour. The procedural shadow:foliage scene it replaces is
retired: its registry entry, its folder and the leaf-mask spec are deleted.

* docs(prd): PRD-646 ticks the wildwood box and records the light-unit rules

Phase 1b is DONE: every registered scene passes the sameness gate, and the
procedural foliage scene is retired with its data. AC-2 keeps its box open: the
point-light conversion is now Unreal's, and the two light scales (6.2% lamp,
25% sun) have no source rule the harness can adopt, because Unreal's own L1 EXR
implies a fixed exposure of 2.03 to 2.12 that neither source path produces.
Decision records why SUN_ILLUMINANCE and SKY_TO_SUN were not tuned and names the
next experiment (the sun's air transmittance off L1). A third Decision places
the cornell regression below the overlay: every appearance file the scene loads
is byte-identical to the epic head's, so the 6.2% is in packages/.

* docs(skills): name the shared capture work dir trap

Two lanes that pack into ~/.cache/threenative/unreal-look at once fail with
TN_VISUAL_PACKAGES_MISSING; the unreal-calibration skill now says to give each
lane its own TN_UNREAL_LOOK_WORK.

* fix(unreal-look): the 10 degree cone is 6286 candela, and the cornell bisect is measured

The comment under `candela` said 3625 cd for 600 lm down a 10 degree cone. The solid angle
`2 pi (1 - cos 10 deg)` gives 6285.638, so the comment now says 6286 and the spec pins both the
rounded value and the exact one.

The PRD records the round-3 bisect instead of the question: `three@0.185.1` in the generated
project draws byte-identical cornell L1 frames, so the 0.187 upgrade is out; the 10-of-12 record
is at `be61f9bc6`, an epic branch head, so the window is the epic squash merge
`be61f9bc6..89ed1247f` and not `89ed1247f..HEAD`; the Unreal reference did not move; EON is not
the uniform 6%; and the engine-vs-JS arm is refused by `TN_UNREAL_LOOK_WRONG_ENGINE`.

The sun's air transmittance lead is closed with the source: Unreal applies it only while the sky
atmosphere renders, and L1 sets `r.SkyAtmosphere 0`, so neither engine attenuates the sun at
that layer. The absolute scene-linear ratios stay unverified: the round-2 instrument was never
committed and a round-3 one disagrees with it by pi.

* feat(templates): snow runs on the native engine

Delete snow's engine: "threejs" pin and its AGENTS.md sentence, drop it from the
legacy pin ratchet, and give the kit contract the engine-cost bounds it now owes:
one Wasm-engine run at 640x360 measured the steady-window counters, and the bound
is twice the measured worst because the capture came from a private display.

The starter's lifecycle replay on the desktop player (KeyR teardown and rebuild)
and on the Wasm engine (React restart) passes, so no engine change was needed.

* docs(prd): PRD-620 records the starter lifecycle replay on V8 and Wasm

Tick the BufferAttribute disposal box with the consumer replay on both backends,
and record what the template playtests actually did: 20/22 starter and 5/6 snow on
the Wasm engine, 2/4 starter and 2/2 snow on the desktop player, plus three misses
attributed to a stale render-chain row, virtual-display frame pacing and the
desktop UI focus route. No threshold was loosened and AC-4 stays open for the judge.

* test(playtest): the kit-contract row test no longer depends on which kit is pinned

The engine-cost row test named runner as the pinned kit, so unpinning runner
in another lane would have turned it red. It now reads the shipped
declarations: every kit that declares the row writes a file, every kit that
opts out writes none. A fixture kit with injected entries tests the no-row
path even when no shipped kit is pinned.

* test(starter): the look rows assert the chain the presets build

The starter's look and render-chain rows expected [ambientOcclusion, traa,
motionBlur, bloom, vignette], stale on both engines since the owner's
2026-10-28 decision in packages/core/src/look/quality.ts: the high tier draws
4x MSAA with no temporal resolve and no camera motion blur. Measured per tier
on this tree, the rows now assert high [ambientOcclusion, ssgi, ssr, bloom,
vignette, antialias], medium [ambientOcclusion, ssr, bloom, vignette,
antialias] and low [ssr, bloom, vignette, antialias], with grade and grain
dropped at intensity 0.

Reruns: starter look on web 23/23 assertions, exit 0; starter render-chain on
desktop 7/7 assertions, exit 0. The two specs that pin those rows move with
them.

PRD-620 also records round 2: the snow pose difference is the two engines
starting the game scene at different ticks, not a camera or projection
difference, and the contact-occlusion drop on the three.js arm is a correct,
reported refusal.

* feat(unreal-look): a calibration capture may draw on threejs as a named diagnosis arm

TN_UNREAL_LOOK_ENGINE=threejs lets one run draw on upstream three and names every cell
threejs (diagnosis) in the report and gi-capture.json. The compare refuses the row verdict,
--write-stats, a lock write and a lock check for those frames (TN_UNREAL_LOOK_DIAGNOSIS_ONLY);
the default still fails closed on a frame drawn on the wrong engine (PRD-646).

* feat(unreal-look): one scene-linear ratio instrument, proved on Unreal's own PNG and EXR

pnpm unreal-look:ratio inverts the harness's own filmicToneMap back from a display level to
the light the pixel was drawn from, and reads Unreal's reference-stats.json ev50 into the same
units, so a light gap is a ratio instead of a few levels in the filmic shoulder. --selfcheck
proves the convention on Unreal's frames: cornell L1 reads worst 0.74% and sunlit-room L1 4.7%
between an Unreal PNG inverted and the same cell's EXR (PRD-646).

* docs(prd): PRD-646 restates the light ratios from the committed tool and records the engine-split arm

The ratio tool proves its convention on Unreal's own frames (0.74% cornell, 4.72%
sunlit-room), reads cornell 0.953 and sunlit-room 1.232 on the owned engine against 0.955
and 1.233 on the TN_UNREAL_LOOK_ENGINE=threejs diagnosis arm, and restates 0.938, 1.249
and 2.03 to 1.6%, 1.4% and 0%.

* docs(prd): PRD-646 records round 4's four bisect arms and the 4.9% lamp scale

The threejs arm, the epic overlay, the epic loader and the core at the oldest and the
newest pre-0.187 commit all draw today's frames, so the 10-of-12 record is not reproducible
from the tree (the epic core cannot run under three 0.187: no getShadowMaterial in
threenative:web-engine). A 1.0486 lamp scale moves cornell from 2 to 5 of 12 rows with every
row within 2 levels, so the gap is one scale; no constant is tuned and the boxes stay open.

* feat(templates): action-rpg, runner and sailing drop their legacy engine pin (PRD-617)

Each template binds clean on the native bundler and boots on the V8 player, so
the pin and its stale deltaTime/vertexIndex comment go. LEGACY_PINNED_TEMPLATES
keeps snow alone; the ratchet test passes 114/114.

* fix(renderer): the normal target holds the geometry normal for a material normalNode (PRD-617)

three's MRT slot names normalView, so an authored normalNode changes lighting
only. Refusing the frame cost sailing every water scenario with
TN_POST_NORMAL_UNSUPPORTED. native_engine_renderer_aux_targets_normal_node
aborts on the old path and passes with the target preserved.

* docs(prd): PRD-617 records the platformer and tower-defense template runs (PRD-617)

* docs(prd): PRD-617 records the capture comparison for the three unpinned templates (PRD-617)

* fix(renderer): the normal target holds the material's normalNode (PRD-617)

three 0.187 src/nodes/accessors/Normal.js:95-119 makes the fragment-stage
`normalView` the value of `builder.context.setupNormal()`, and
src/materials/nodes/NodeMaterial.js:952 returns `vec3(this.normalNode)`
when the material authors one. The MRT normal target therefore holds the
authored normal, not the geometry normal as a2750b7db claimed.

`StandardPrograms::normal` is now the lit fragment's own normal, and
`Renderer::normalTarget` writes it for a draw whose material authors a
normalNode, through that draw's own lit fragment instead of the shared
geometry-normal pass.

`native_engine_renderer_aux_targets_normal_node` asserts the read-back
equals the authored tilt on both masks. RED on a2750b7db: got 0 0 1 14.5,
want 0 0.447214 0.894427 14.5. GREEN after, with aux_targets_one_pass,
aux_targets_alpha_tested, denoise_face_normal and the lit references.
Sailing's web playtests pass 8/8 on the rebuilt Wasm engine.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ci): the weekly template rotation builds before it measures (PRD-617)

Two crons fire, so the daily run of every Sunday shows the rotation as
`skipped` and the earlier run of the same day fails it in 20 s:
`pnpm test:templates` imports `@threenative/assets` through
`create-threenative/src/config.ts`, and nothing built `packages/*/dist`.

The job now runs the workspace-dist action (bundle-only, no cache save),
the native-engine-wasm action for the web engine the local pack asserts,
accepts a software adapter as golden-path does, and allows 120 minutes for
eight template legs.

A new spec parses the schedule crons and the job's `if:` (the condition
names the weekly cron and no other), and requires both builds to precede
`pnpm test:templates`.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(core): the playtest settle lands on the world scene, not a loading frame (PRD-617)

A freeze owes the world 60 fixed steps. The loop spent them on the first
live frame after `describe()` released the boot hold, which is a race with
scene entry: on the sailing pose the native arm's 60 steps reached the
sailing scene and the legacy arm's did not, so the same anchor described
0.1 s-apart HUD state 4.5 s against 3.5 s.

`FixedStepLoop.settleNow()` spends the pass on demand, `Game` exposes it as
the optional `settleClock` seam, and the plugin freezes with zero steps and
settles once after `startSceneEntered()`. A runtime without `settleClock`
keeps the frame's own settle.

Red-green: playtest-settle.spec.ts (RED: expected [ undefined ] to deeply
equal [ +0 ]), plus the loop half in loop.spec.ts. loop, playtest-settle and
game specs pass 124/124.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prd): PRD-617 records the fix round results (PRD-617)

AC-6's remaining web sweep (racing 11/11, rts 9/9, rain 15/16, shooter
34/37 with one page crash), the sailing capture pose now equal between
arms, and the runner shadow pass's four casters named from a temporary
dump with the two mechanisms that explain them.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prd): PRD-617 AC-7 evidence names the nine capture pairs (PRD-617)

The capture set covers every AC-5 template except snow, and sailing's pair
reaches an equal pose on both arms after the deferred-settle fix. The sky
banding on the native arm is recorded; no verdict claimed.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(core): the playtest settle belongs to the tick-counted clock (PRD-617)

* fix(renderer): only a written normal takes the aux pass, and the target holds an authored one unchanged (PRD-617)

* fix(runner): the template pins the legacy engine until the shadow casters are its own change (PRD-617)

* docs(prd): PRD-617 records the pinned runner's green playtest (PRD-617)

* fix(engine): the owned GTAO fades out in the distance, as Unreal's does (PRD-617)

The reach path darkened the sailing sky dome, 7 km out, along the dome's own
triangle edges: at that depth one ULP of the depth buffer is metres of view
space, so the occlusion's depth-derived normal and its horizon taps read the
mesh's facets instead of the surface. Unreal fades ambient occlusion out in the
distance for exactly this case, 'avoiding artifacts and AO effects on huge
object' (UE 5.8.3: Engine/Classes/Engine/Scene.h:2239-2244): it clears the
occlusion past AmbientOcclusionFadeDistance and lerps it to 1 across
AmbientOcclusionFadeRadius (Engine/Private/Scene.cpp:572-573, 8000 uu and
5000 uu = 80 m and 50 m; CompositionLighting/PostProcessAmbientOcclusion.cpp:
393-412, :1043-1048; PostProcessAmbientOcclusion.usf:431-441). The engine's
Unreal path now does the same, before AmbientOcclusionPower.

Sailing, same pose, two runs per arm, RTX 2080 headed WebGPU: frame mean |d|
legacy vs native 6.58 -> 1.67 levels, sky 11.35 -> 1.18, and the high-pass
deviation of the sky 14.78 -> 13.83 against 13.75 for the same frame with the
occlusion stage off. The r187 path (a named gtaoRadius) keeps three's maths
unchanged. CTest: native_engine_renderer_gtao_fade_distance.

* test(native-engine): GTAO fade test scales a corner with the camera

The old test moved the camera away from a flat floor, so the floor shrank
and took no occlusion at any distance: it passed on the shader without the
fade. A floor-and-wall corner scaled by distance/6.4 keeps one screen
footprint. Red on the pre-fade shader (darkened(32,200)==0 and the
60 m fade band fail), green with the fade.

* fix(core): the contact occlusion refuses a frame with no world cache (PRD-617)

The screen-reach occlusion (PRD-635) composites on indirect light. A frame with no baked
world cache has none, so the native arm multiplied the whole frame and darkened the sun:
the soft dark halo the judge saw under the racing car, which the engine: threejs arm does
not draw because it drops the stage with TN_AO_REACH_UNSUPPORTED. The stage refuses such a
frame with TN_AO_INDIRECT_UNSUPPORTED, so both arms agree and the reason is reported. An
authored gtaoRadius, gtaoIndirectOnly false or a declared GI tier still admits it.

* fix(renderer): the SSGI gather answers three's occlusion, not a constant (PRD-617)

three's SSGINode answers pow(clamp(1 - occludedShare), aoIntensity) from its own pass
(SSGINode.js:637-638) and core multiplies the lit frame by it, but the owned engine's
getAONode() answered vec4(1). The whole occlusion term was missing: SSGI could only add
its gather to the frame and never darken it.

The node now answers its gather's blocked share in three's form, with three's aoIntensity
uniform, on both the per-pixel and the screen-probe path, and core denoises the occlusion
on both engines (a gather traces a few rays a pixel, so an undenoised occlusion speckles).

RED on the previous engine, GREEN now: screen-space.spec.ts runs the shipped node over
upstream three's node system and finds the gather pass in the AO answer's dependency tree,
for both gather paths. A diagnostic build with the occlusion forced to 0.4 renders the
tower-defense lawn at 61% of its brightness, so the composite consumes the channel again.

* docs(prd): PRD-617 AC-7 records the SSGI occlusion fix and the measured residual (PRD-617)

The conformance row and its scene comment no longer call the owned AO a constant 1. The
AC-7 evidence names the fix, the RED/GREEN test, the capture sets and the measured
residual: the legacy gather darkens the tower-defense lawn by 10.1/255 and the owned
gather by -0.6/255, because three occludes from a screen-space horizon and the owned
gather traces 8 depth-crossing rays out to 4 m. No verdict is claimed.

* fix(core): the render mirror carries the scene's fog and background nodes (PRD-617)

`SceneRenderProjection` hands the renderer a private mirror `Scene`. It copied `scene.fog` and
`scene.background` but not the two NODES that override them, so any frame the mirror drew lost a
fog or background the game had authored — 3 % of the frame the platformer asks for, on the one
template big enough to project.

three r187 reads both node inputs: `NodeManager.getFogNode` returns `scene.fogNode` in place of the
`scene.fog` factor (renderers/common/nodes/NodeManager.js:636-641) and `NodeMaterial.setupFog`
applies it (materials/nodes/NodeMaterial.js:1150-1160); `NodeManager.getBackgroundNode` does the
same for `scene.backgroundNode`. The native host draws the authored scene, so it kept the height
fog while the browser arm dropped it: the platformer's base shading differed by RMSE 0.0366 with
every post stage off, and the reach AO and SSGI only widened it (PRD-617).

`renderProjection.spec.ts` gains three cases: the fog node survives the handover, the background
node survives it, and each reconcile re-reads the fog node — including a game that clears it.
Measured on the platformer, both arms, two runs, NVIDIA RTX 2080, 1280x720, SSGI/SSR/AO off in both
arms: RMSE 0.01103 / 0.01404, inside the same-arm run-to-run noise of 0.00863 / 0.01266; the brown
cliff reads 131,124,116 against 132,124,116 and every other sample is identical.

* fix(test): the integrated PRD-617 and PRD-628 specs typecheck together

ci-structure.spec.ts gained a local jobSections (PRD-628) and an import of the
shared one (PRD-617): TS2440. The two parse the same headings, so the spec now
uses the shared scripts/ci-workflow.ts helper. The playtest-settle stubs lacked
the required IGamePluginRuntime.fixedStep (TS2741). pnpm typecheck exit 0;
both specs 188/188.

* test(create-threenative): re-measure the five scaffold trees PRD-617 and PRD-620 move

action-rpg and sailing drop their engine pin, runner re-pins with its reason,
snow runs on the native engine, and the starter's render-chain row asserts the
sharp-AA chain. scaffold.spec.ts 78/78.

* fix(runtime-native): the V8 facade lowers three's nested and "less than or equal" loops (PRD-617)

three's Loop(...params, callback) takes one parameter per dimension and nests them, and
`condition: "<="` runs up to and including the end. three's own GLSL transpiler emits that
shape for rain's bloom and ripple kernels, and the browser back end already nests it, but the
V8 facade demanded exactly two arguments (TN_TSL Loop: expected 2 arguments) and the shared
lowering refused a negative start. The facade now lowers one loop per parameter and answers
toVar outside a function body with the expression itself, as browser-tsl.ts does.

native_engine_abi_tsl_loop_nested is RED on the pre-fix lowering and GREEN now; the shipped
rain bundle boots on the player and its native-playtests/boot passes 4/4.

* docs(prd): PRD-617 AC-7 records the final judge: nine templates NEUTRAL, platformer open

* fix(runtime-native): a rig outside a light's shadow box is culled out of that pass (PRD-617)

`RenderDatabase::boundingSphereOf` answered "no bounds" for a `SkinnedMesh`, and
`inShadowFrustum` reads no bounds as "always casts", so a rig was a shadow caster for every
light at every distance. The runner drew its 13,744-triangle mannequin into the sun's map 68 m
from a 56 m box: 47,924 triangles against a 45,000 bound, where three draws 34,967 and keeps
one caster (the merged rails at 3,600).

The sphere now comes from `SkinnedMesh::boneReachBounds`, the bone-reach helper the
virtual-shadow bounds already use (UE 5.8.3 fits skinned bounds to bones, not to skinned
vertices), which bounds every posed vertex and costs O(bones) per frame. A detached bind keeps
the old answer, because its `bindMatrixInverse` is not the mesh's world matrix and the reach
cannot be placed in the object's space. `skinned_shadow_caster_frustum_culling` is RED on the
old code (`2 casters of 2 items`) and GREEN with it (`1 casters of 1 items`).

* docs(prd): PRD-617 names the runner's shadow casters and the rig cull that fixes them

Measured both arms over the whole scenario window: legacy shadow 1 draw / 3,600 triangles on
every retained frame, attributed by a per-object capture to the merged rail batch; native 3
draws / 17,344, of which 13,744 is the mannequin, proven by clearing each caster's `castShadow`
in turn. Root cause is `boundingSphereOf` answering "no bounds" for a `SkinnedMesh`; the bug is
fixed in this branch. The pin stays: the gate's remaining 47,924 is the first ~1.1 s, where the
rails, the rig and one obstacle chunk are all inside the template's static sun box, a pose the
legacy arm draws too but no longer samples. The next step is the runner's own shadow camera.

* fix(rain): the storm publishes the reduced-motion answer a host gave (PRD-617)

The native lightning scenario assumed the host reports prefers-reduced-motion:
reduce, so it booted expecting flashes suppressed. A desktop host does not: the
UI page answers its own media query and a private display has no such setting, so
the storm boots flashing and every later step inverted (KeyX turned flashes off,
KeyL struck while suppressed, the thunder queued at boot was dropped by the sound
enable edge, and `audio.thunder` recorded no play).

The template now publishes that answer: the UI realm sends `setReducedMotion`
with what the query returned, `GameState.reducedMotion` carries it, and both
scenarios read the branch the host took instead of assuming one. The desktop
scenario follows the shipped web scenario's shape: sound on, safe on, a strike
that must not flash, safe off, then the strike that must. It asserts the reported
preference, `safe`, `autoLightning`, `strikes`, `status`, `flash`,
`pendingThunderAt`, the storm-audio components and the thunder cue.
`playtests/reduced-motion` asserts the reported preference under emulated reduce.

RED before the fix: `intentPatch` refused `setReducedMotion` ("is not a rain
intent") and the UI effect sent nothing. GREEN: rain-weather and
rain-quality-initialization pass 20/20.

Desktop proof: `native-playtests/lightning` passes on the V8 player (one counted
run, NVIDIA RTX 2080 / nvidia turing, private Xvfb), reading `reducedMotion`
false, `safe` false at boot, `autoLightning` false under safe, `strikes` 0 then 1,
`status` steady then flashing, `pendingThunder` 0/1/1/0, `thunderVoices` 0 then 1,
`audio.thunder` observed 1 play (gap 2411 ms) with no audio device on the display.

* docs(prd): PRD-617 AC-6 records the web half of the four remaining kits (PRD-617)

Racing 11/11, rts 9/9, shooter 38/38 and rain 15/16 on the Wasm engine built
from this tree, with the one rain performance miss proven against a paired
`engine: "threejs"` arm on the same kit, and rain's desktop lightning failure
traced to the scenario's reduced-motion assumption rather than the player.
AC-6 stays open on platformer, tower-defense and runner, which this round did
not run.

* docs(prd): the AC-6 rain performance proof states the legacy arm's numbers (PRD-617)

The display-interval p95 differs between arms, so the non-native proof rests on
the paired `engine: "threejs"` arm missing the same 16.7 ms bound (20.01 and
19.62 ms), not on the native arm's own cadence.

* test(create-threenative): re-measure the rain scaffold tree after its reduced-motion field (PRD-617)

* fix(runtime-native): the render list keys a draw by its geometry centre (PRD-617)

three r187 sorts a mesh by the world position of geometry.boundingSphere.center
(renderers/common/StandardDrawingPipeline.js:236-241), not by the object's origin.
The platformer's waterfall is the case that tells the two apart: the inner sheet
sits 0.25 m nearer the camera than the outer one, and its narrower arc puts its
bounding-sphere centre farther, so the origin key drew the pale inner sheet last
and washed the sheet out on the native arm only.

The draw now carries the geometry's box centre, three's bounding-sphere centre,
and sortDraws transforms it by matrixWorld. A merged draw keeps its own origin.

CTest native_engine_transparent_sort_uses_geometry_center: the same red quad
placed by its object and with the 2 m baked into its vertices. RED on the previous
engine, 127 0 64 against 64 0 127; GREEN now, 127 0 64 both ways. native_engine
alpha_scene still matches the browser golden, worst 0.

* docs(prd): PRD-617 AC-7 records the waterfall sort-key fix and its medians (PRD-617)

* test(runtime-native): register the transparent sort case with ctest (PRD-617)

* test(core): the AO admission refusal joins the look allowlist (PRD-617)

look/ao-admission.ts names indirect light in its TN_AO_INDIRECT_UNSUPPORTED message, the same words
look/ao-reach.ts carries; the visual-concerns filter now skips it with the other look files.
RED: constraints.spec 'should keep visual concerns out of core source' failed on 'light'; GREEN 4/4.

* fix(engine): the occlusion darkens the opaque surface, not the sheet drawn over it (PRD-617)

A screen-space occlusion and gather are computed at the opaque surface's depth and normal, with its
albedo. A translucent draw composites over that surface after the opaque pass, so under a sheet the
frame's pixel is the draw's own light, and both composites multiplied the whole composited frame: the
platformer's waterfall took the cliff's occlusion and showed its stripes.

The engine now draws the transparent draws once more, with their own lit program, into a "translucent"
target (Renderer::planAux, over the colour pass's depth), and the contact occlusion and SSGI composites
add that part back at full weight instead of darkening it, as they already do for emissive. Unreal
applies ambient occlusion to the opaque lighting, before translucency.

RED then GREEN: CTest native_engine_renderer_translucent_target reads the target through a post graph.
With the target removed the readback is the frame (wall 127, over the wall 115) and the case fails;
now the opaque wall writes nothing (0), a sheet behind it is depth-rejected, and the sheet in front
reads its own colour times its opacity (51 at 0.5, 102 at 1.0).

* docs(prd): PRD-617 AC-7 records the occlusion fix and the waterfall medians (PRD-617)

* docs(engine): the translucent pass names the sheets it carries, sky dome included (PRD-617)

* docs(prd): PRD-617 AC-7 ticked, 10 of 10 templates pass a fresh judge (PRD-617)

* docs(prd): PRD-620 records the snow parity replay on the settle fix

The round-2 judge's four snow differences were one game state, 60 fixed steps apart. With the goal/617 settle fix cherry-picked onto this scratch branch, both arms report the same scene-update count (1823 of the 1883 reported tick) and the same state; the fence-corner pair differs by 0.01 of 255 on average. The LAST FOOTFALL thumbnail is an SVG grid over a JavaScript array, not a render readback: its blank frame was the same grid over the 60-steps-ahead state.

* fix(starter): the motion row measures the tick series on a counted host (PRD-620)

The desktop smooth-motion row read the sampler's presented-frame series, and a counted native run
presents no ticks: advance() runs its ticks and draws none, and the host repaints the standing world
at display rate between requests. The window filled with duplicate poses (samples 9 to 24, one
player x, null CV). Record one sample per world update, timed by the steps, and assert that series
on the desktop row; the frame rows stay for the web row, where the display paces them. Bounds are
unchanged. Re-pin the scaffold hashes the spec measures, including snow's, which round 1 left red.

* docs(unreal-look): the sun's light unit, and the exposure it cancels against

The atmosphere overlay's comment claimed Unreal shades a diffuse surface as albedo x lux x
cos(theta), so the harness carries a pi. The source says otherwise: a directional light's lux
is the irradiance (LightComponent.cpp:1623-1634, GetLightExposureScale 1 for a directional
light) and the diffuse lobe divides by pi on both engines (BRDF.ush:157-160, three's
BRDF_Lambert). The pi cancels against the template exposure instead: Unreal's L1 rule gives
2.4 (2^bias(+1) x the manual scale 1.2), the harness uses 1, and a frame scaled by 2.4/pi
passes 36 of 40 sunlit-room L1 rows. No value changes: the port needs the ratchet board.

* docs(prd): PRD-646 closes the sun's 23% on the exposure and excludes the diffuse model

Item 1: Unreal 5.8.3 shades the calibration scenes with Substrate's rough diffuse
(r.Substrate.RoughDiffuse=1 in the capture log, Diffuse_GGX_Rough = Diffuse_EON at
BRDF.ush:238-246). Toggling it off on both engines moves every cornell row by the same
factor to within 0.7%, so the model is not the lamp's 4.9%: the pair reads 0.953 with it
on and 0.954 with it off on both sides.

Item 2: Unreal's L1 exposure for a template level is 2.4 (2^bias(+1) x the manual scale
1.2), the same formula that reads cornell's 2^-5 back, and a bias-0 arm halves the frame
by 1.999 to 2.002. The sun's pi is real but unpaired. A frame at 2.4/pi passes 36 of 40
sunlit-room L1 rows against 19 of 40. Neither box is ticked: the three-line port reaches
every sun-lit and emissive frame at every layer and needs the ratchet board and a judge.

* feat(unreal-look): the sun's lux and the level exposure land as one line

`SUN_ILLUMINANCE = SUN_LUX` and `EMISSIVE_UNITS = 1` drop a pi the Unreal
source does not have, and `LEVEL_EXPOSURE` gives 2.4 back through the rule
that produces it: 2^bias / white point, where a level with no post-process
override clamps the meter to LuminanceToEV100(1.2, 1) = 1/1.2
(SceneView.cpp:2060-2071, PostProcessEyeAdaptation.cpp:661-662) and the
engine's +1 EV bias (SceneView.cpp:196-201) multiplies it. A scene's own
manual volume keeps its measured exposure: the same function returns
cornell's 2^-5, and `CalibrationScene` only replaces its bare `1` fallback.

The unit check reads both numbers out of the formula, red before this
commit.

* fix(unreal-look): the level exposure lands on the preset that draws the fixed frame

The high preset carries `exposure: 1` explicitly, so the WorldEnvironment
fallback never ran and the first arm drew the sun without the 2.4 (ratio tool
0.399 of Unreal instead of the measured 0.944). The preset now reads
`LEVEL_EXPOSURE`, the fallback change is reverted, and `CalibrationScene`
still passes a scene's own volume exposure over it.

* fix(unreal-look): the sky's 1.3 fit goes with the pi it paid for

`SKY_TO_SUN` was 0.78 because the atmosphere read 1.3x too bright while
the sun carried a pi at exposure 1. The port removes both halves of that
error and 2.4/pi is 1.309, so the fit is deleted: the fog model reads
Unreal's own L3 void within 3 display levels with the exposure in its model,
the sky keeps the level the fit gave it, and no constant is fitted again.
The two specs that load `quality.ts` from a temp dir now copy
`atmosphere.ts` beside it, which is the module the preset reads.

* docs(prd): PRD-646 lands the sun port and records the board it moved

AC-2 and both Phase-2 boxes keep their boxes open with round 6's numbers:
`sunlit-room` L1 19 of 40 rows to 36 of 40 (worst dP50 0.065 to 0.016,
deltaE 3.13 to 0.80), `cornell` L1 2 of 12 on byte-identical frames, the
`L7-high` row that gets worse named with its numbers, and the three parts of
the board this worktree cannot read (the reference family and the shadow
scenes have no Unreal reference frames here; `sunlit-room`'s L7-high locks
were cut from 3 runs). No threshold, ratchet or lock was loosened.

* refactor(unreal-look): the quality preset is the one owner of the level exposure

`WorldEnvironment`'s `?? 1` fallback is unreachable from the harness (every
caller passes the preset or a scene's own exposure) and a second place to
state 2.4 is a second thing to forget, so it goes back to 1 and
`quality.ts` carries the port.

* docs(prd): PRD-646 records the sky fit's removal and the board's final numbers

* docs(prd): PRD-646 records the round-6 judge, IMPROVEMENT on sunlit-room

* style(unreal-look): biome format the light spec

* feat(core): a launch can price the translucent pass, and its trace counts the draws (PRD-617)

?tnTranslucent=0 (or TN_TRANSLUCENT=0, or __tnTranslucent=false) removes the graph's read of the transparent draws' target, which is what asks the owned engine to draw the pass, and zeroes translucentFrame so both composites add nothing back. On by default; a diagnostic, not a look.

TN_GPU_PASSES now carries draws per pass, and the translucent pass reports the size of the plan it encodes, so a lane can price the second draw without a second install.

* docs(prd): PRD-617 records the translucent pass's cost across four kits (PRD-617)

* docs(prd): PRD-620 records the round-5 starter and snow matrix (PRD-620)

* docs(prd): PRD-617 pairs the last two web misses against the legacy engine (PRD-617)

* docs(prd): PRD-617 records that tower-defense's kit sweep passes 12 of 12 (PRD-617)

* docs(prd): PRD-620 records the round-6 develop arm on the starter's contentClassOf miss (PRD-620)

* docs(prd): PRD-620 AC-4 ticked, starter and snow pass on native and judge NEUTRAL; PRD-620 done (PRD-620)

* fix(unreal-look): a preset that shadows the level exposure fails the unit check

The round-6 mis-landed arm set only the WorldEnvironment fallback while the
preset's own `exposure: 1` won, and the ratio tool read 0.399 of Unreal in
silence. The check reads the preset through the same temp-dir loader the GI
and shadow tables use, so a hardcoded exposure in `quality.ts` is red.

* feat(unreal-look): the harness can take the specular off, as Unreal's ShowFlag.Specular does

The threejs and owned sides both draw a 0.04 dielectric F0 with no flag to
lower it, so the diagnosis twin is a physical material whose
specularIntensity and specularColor put F0 and F90 at zero, which is what
Unreal's G-buffer override reads (MoviePipelineUtils.cpp:207-210,
BasePassPixelShader.usf:1280, Substrate.ush:3433-3444). Query only:
`?specular=0`, unset in every judged capture.

* fix(unreal-look): the specular twin keeps F90, and the baseline arm gets an inert stand-in

Unreal's ShowFlag.Specular zeroes F0 alone and F_Schlick keeps its grazing
lobe, so the twin is a physical material at ior 1 instead of specularIntensity
0: the first arm took the grazing lobe as well and darkened sunlit-room's
15 degree rows by 6 to 9 percent, which the flag does not do. The template has
no specular.ts, so a --baseline scaffold writes an inert stand-in, as it does
for the overlay's other render modules.

* docs(prd): PRD-646 round 7 names the specular lobe on both scenes

With F0 zeroed on both sides the scene-linear pair reads 0.995 on cornell and
0.983 on sunlit-room against 0.953 and 0.943; the four sunlit-room rows are
the sun's grazing lobe (Unreal up to 64.1 percent of the row) and a sky light
contributes 0.0 to 0.1 percent, so round 6's fill-light reading is replaced.

* fix(unreal-look): the specular twin takes F90 with F0, as Unreal's flag does

ShowFlag.Specular zeroes the G-buffer F0, and the Substrate slab then multiplies its
F90 by saturate(50 * F0) (SubstrateEvaluation.ush:488), so the flag removes the whole
Fresnel-weighted lobe. Round 7 read it as F0 only and kept F90 at 1, which removes
0.04 * (1 - (1 - VoH)^5) instead: a 5-to-9-times smaller ablation on a grazing row
(PRD-646 round 8). ?specular=0 is the flag now; ?specular=f0 is round 7's reading,
kept so its recorded arms stay reproducible (it reproduces r7b-tn-spec0 on 8 cells).

* docs(prd): PRD-646 round 8 finds the 3-to-7-times lobe in the ablation, not the lobe

With ShowFlag.Specular's ablation on both sides the lobes are one function at roughness 1
and the shipped pair reads cornell 0.993 and sunlit-room 0.983 (off/off). The term that
differs is the diffuse's specular-energy loss: three subtracts the pointwise Fresnel where
Substrate subtracts its integral. The direct-diffuse port is written up, not landed.

* docs(prd-629): 0.3.10 measured AC-7, and the registry refused the npm upload

* feat(runner): the sun's shadow box follows the runner (PRD-617)

Past the template's fixed ±28 m box the runner, the obstacles and the rails cast no shadow at all:
the capture at HUD `DISTANCE 48 M` shows an empty road under both engines. `setupLighting` now
returns `ILighting` with `follow(target)`, the shape collectathon already uses: it centres a ±24 m box
(phones ±16) 12 m ahead of the runner — the ground the chase camera sees — and snaps that centre to
whole texels of the light's own grid (`2 * extent / mapSize`, 1.17 cm at 4096²), so a texel stays on
one cell of the world and the map does not crawl. `key.target` joins the scene, because three
refreshes a target's world matrix only through a traversal. Sun direction, colour and intensity are
unchanged; `src/scenes/Run.ts` calls it from its `afterPhysics` cadence.

Searched first: `Daylight` and `VirtualShadowNode` are the installed followers, and neither fits.
`Daylight` also owns the sky, the haze, the hemisphere fill and the exposure, which this kit authors
in `src/render/`; `VirtualShadowNode` swaps the mechanism for cached clip levels plus mover maps, and
every caster here moves (a skinned rig, obstacle batches that rewrite their instance matrices).

Measured, one scenario, three arms, NVIDIA RTX 2080 nvidia/turing from each run's `capture.json`,
headed private Xvfb, `--browser-recipe webgpu`: this change 51,524 triangles (main 30,580 + shadow
20,944); the pin as it stood 34,967 (shadow 3,600); the legacy engine with the same lighting 53,511
(shadow 22,144). Correct shadows cost about 52-54k on either engine, so `production-performance`'s
45,000 bound cannot hold them: 30,580 main plus the rig's own 13,744 leaves 676 triangles for every
other caster. Four of five scenarios pass; that bound is the open decision, recorded in PRD-617.

* feat(create-threenative): no kit pins the legacy engine, and PRD-617 AC-5 lands (PRD-617)

The runner was the last `engine: "threejs"` pin. It goes with the sun fix that makes the pin's own
reason false, so `LEGACY_PINNED_TEMPLATES` is `[]` and every template's config resolves to
`threenative`. The runner's AGENTS.md engine paragraph becomes the other unpinned kits' paragraph
(`pnpm sync:agents` regenerates CLAUDE.md), and its scaffold tree re-measures to
`38c875a1ceeabcf8f6e4df69bca5bec7fbf12be70bcb27554ff08f2f2b5f9582`.

Proof: `pnpm exec vitest run packages/create-threenative/__tests__/config.spec.ts
packages/create-threenative/__tests__/scaffold.spec.ts scripts/__tests__/kit-contract.spec.ts
packages/playtest/__tests__/kit-contract.spec.ts` passes 207/207, the pin ratchet among them. The
scaffolded runner also builds its desktop artifact against a V8 player built here, boots
`TN_PLAYER_V8_GAME_CHECK: engine=native gameRuntime=v8 startup=passed`, and passes `survives` on
`--target desktop` (70 frames, 13.26 m travelled).

PRD-617 AC-5 is ticked with that evidence. AC-6 stays open on runner: `production-performance`
misses `performance.maxTriangles` at 51,524 against 45,000 now that the sun follows the runner, and
the same scenario with the same lighting misses it on the legacy engine too (53,511). The PRD's
2026-11-03 round-2 block carries the arms, the pass split and the options.

* test(runner): the sun's box leads the runner and lands on whole shadow texels (PRD-617)

Two checks on the follow, in the runner's own spec, which already imports template source. The first
pins the box 12 m ahead of the runner and the sun 30 m up `SUN_DIRECTION` from it. The second walks
the runner a millimetre at a time over half a metre and reads the box's centre in the light's own
frame: every centre sits on a whole texel of the grid, and no step moves more than one texel per axis.
That is the property behind "no shadow swimming", and it fails without the snap — the same test reads
0.072 texels off the lattice when the two `Math.round` lines come out.

* fix(runner): the triangle bound holds correct shadows (PRD-617)

The 45,000 bound was measured while the fixed sun box had already left the
runner, so it counted a frame with no shadows. With the sun following the
runner the same pose costs 51,524 triangles on the native engine and 53,511 on
the legacy engine. maxTriangles is 54,000; nothing else moves. The runner's
scaffold hash is re-measured for the scenario file, and PRD-617 AC-6 ticks on
a 5/5 runner sweep.

* docs(prd): PRD-617 done, every template runs on the native engine (PRD-617)

* style(runner): biome orders the runner spec imports

* test(runtime-native): the render-chain contract pins the sharp-AA chain the starter scenario asserts

* fix(runtime-native): a rig's shadow cull reads no bone when its bind pose fits (PRD-617)

c3cd86f9f asked `boneReachBounds` for every skinned mesh, every frame, in `boundingSphereOf`,
so the cull published each deferred bone pose and joined its palette: `gpu_joint_production`
measured 0 where it expects the 6 evaluations of one on-demand materialization, and 72 extra
`joinDependencyVisits` where it expects none since a completed eager walk already joined them.
The runner's rig is 68 m out of a 56 m box: the work was paid to answer "outside".

The shadow decision now climbs: a rig whose layers no shadow frustum holds never casts and needs
no bound; its bind-pose sphere proves it inside one without reading a bone; only when that sphere
misses does the posed reach decide, which is the case the round-1 fix exists for. The main camera
keeps its old answer for a rig, since a pose can reach past the bind pose and only a posed bound
may cull. `skinned_shadow_caster_frustum_culling` is still RED on the always-casts answer
(`2 casters of 2 items`) and GREEN here (`1 casters of 1 items`).

The reflection pass no longer counts the layer-1 rig's shadow-only item: its sun mask is the
virtual camera's (layer 0, `effectiveShadowLayersMask`), so that caster could never reach the
reflected map. `gpu_joint_production` expected it at frame 1 (`2 draws / 3 instances`); the
numbers are now `1 draw` and `3 / 2 / 1 / 3` instances, and the check is red on the always-casts
answer.

* docs(rain): the reduced-motion note fits the 100-line instruction budget

* fix(ci): the runner balancer names the user without $USER, so set -u holds in CI (PRD-628)

* test(ci): the balancer fixture pins the host's free space, not this machine's

The disk floor added to the balancer (PRD-628) pauses the slots under 20 GB free,
and the routing fixture never stubbed `df`, so it read the real host disk: 12 GB
free here, so every advertise case failed. A stubbed `df` with room makes the
fixture host-independent; the pause itself has its own spec in
`ci-runners-disk.spec.ts`.

* test(runtime-native): the template-post sweep accepts the two drops the engine designs

Found while refreshing the native coverage record, which aborts on any failing
engine test:

- The C++ harness did not know the host draws `translucent` (renderer.cpp:4749
  registers it as a target when the post graph reads it), so the starter and
  four kits failed with TN_POST_INPUT_MISSING: translucent.
- The TS driver counted the contact occlusion's admission refusal
  (TN_AO_INDIRECT_UNSUPPORTED) as a lowering failure. PRD-635/617 make that a
  designed drop on both engines, and the judged racing receipt is that drop, so
  the sweep now names it and keeps going; any other drop still fails.

Engine layer: the native test harness was stale against the engine, not the
kits' look.

* docs(native): refresh the native coverage record for PRD-617's renderer sources

Measured on this tree with `pnpm --filter @threenative/runtime-native
native:coverage`: 48 native contract targets, TOTAL 82.11% -> 82.14% (66579
-> 66696 instrumented, 54669 -> 54784 covered), every subsystem above its
floor (engine 84.43% against 82.22%, webgpu 76.79% against 71.89%). No floor
moved. PRD-628 Phase 3's spec line counts 7 disk cases and names the routing
fixture's `df` stub.

* docs(prd): PRD-629 AC-7 records 0.3.11 and names the npm trust step it waits on

* docs(sailing): the template's engine section stops claiming a pin it no longer carries

* fix(playtest): a scenario draws a frame every N ticks so a readback samples both phases

`sails-fill-with-wind` failed on hosted SwiftShader with `sailSamples` 2 and
`sailMotion` 0: a step's ticks advance undrawn, so a component that samples a
GPU readback gets as many samples as the adapter happens to render, and both
landed inside one sail phase. On the CI-shaped five-scenario run the scenario
took 27.0 s and failed.

New scenario field `drawEvery` (positive safe integer, ticks) draws one frame
every N ticks of each tick-advancing step, counted per step across the
runner's 10-tick path cadence. It routes through the drawn-tick path
`engineCost` already used (`advanceFixedStep` with `render: true` is now the
same mechanism at every tick), so there is one way to draw and no second.

It fails closed rather than being ignored: a wrong type, zero, a negative or a
non-integer throws `invalidScenario`; a bridge advertising no
`runtime.fixedStep` fails the handshake; `--live-clock` (and a scenario
`clock: "wall-clock"`) fails `TN_PLAYTEST_UNSUPPORTED_ON_TARGET`; and the
device and desktop lanes name it unsupported, because they own no per-step
draw window. Documentation: `packages/playtest/docs/reference.md`.

`sails.playtest.json` sets `drawEvery: 60` (30 drawn frames per 1,800-tick
hold) with every assertion and bound unchanged, so the template tree moves and
`scaffold.spec.ts` records the new sailing hash.

SwiftShader, five scenarios, CI's own command: `sails` 27.0 s fail before,
73.1 s and 70.9 s pass after, `sailSamples` 36/37 and `sailMotion` 1.34/1.38,
samples in both phases. RTX 2080 still passes (261 samples, `sailMotion` 1.41).

* test(playtest): a wall-clock run refuses drawEvery, red without the guard

The cadence only counts ticks that a counted fixed-step bridge advances, so a
wall-clock run cannot honour it. The run fails with
`TN_PLAYTEST_UNSUPPORTED_ON_TARGET` naming `drawEvery`; without the guard the
same scenario ran on and the test failed with the diagnostic list carrying no
such code (expected [Array(1)] to include it). With the cadence itself
disabled the 120-tick/`drawEvery: 60` case fails as
`expected [] to have a length of 2 but got +0` drawn ticks.

* docs(prd): PRD-480 records why run 41688813459 does not count toward the AC-2 wait

* fix(ci): performance-contracts checks out everything but docs/, so it fits its 2-minute budget again

Its full hosted checkout of the 488 MB HEAD tree outran the job's 2-minute timeout: cancelled in
develop runs 41683823791, 41686494964 and 41703997856 and in #605 run 41688813459 (both attempts);
it passed in 36 s at 14:38Z. docs/ is 330 MB of the tree and no step reads it. Proved on a sparse
clone (164 MB): pnpm install --frozen-lockfile exit 0, the collector control exit 2 as the job
expects, production-evidence and ci-summary exit 0. Workflow specs: 952 of 953, the one red is the
starter build that needs a Wasm build this checkout lacks.

* fix(ci): supply-chain gets 30 minutes, since its full-history clone outgrew 20

Develop runs 41683823791 and 41703997856 and #605 run 41688813459 (both attempts) were cancelled
at 20 minutes still in actions/checkout; run 41686494964 passed in 16. The scan keeps its full
history, as the job's earlier headroom decision says. Workflow specs: 32 files, 909 passed.

* docs(prd): PRD-628 and PRD-480 measure round 4 against four real CI runs

No box is ticked: every open criterion still needs a run this branch cannot produce. Each box
now carries its measured numbers and the one run that would close it.

PRD-628
- line 31 (payload reuse): run 41688813459 built `v8-android-6d7f399f…` in 1 h 45 min
  (18:18:44Z -> 20:03:42Z, artifact 13268070999, 27,451,905 bytes) after `No published …tgz`,
  `v8.artifact skipped` and `Cache not found`. Run 41718807432 restored it in 1.4 s and skipped
  the build. Both are `workflow_dispatch` on the same ref, so the pull-request scope stays
  unproven; the artifact read is verified read-only (v8-artifact-pick.mjs -> 41688813459).
- line 32 (cancel keeps progress): run 41670763005 cancelled at `[1781/3519]`, saved
  `android-v8-source-…-41670763005-1`; run 41688813459 restored it and planned 1724 edges, not
  3519. A dispatch run, so the `pull_request` arm stays unproven.
- line 36 (ccache >= 80%): `test-native` 229/1076 = 21.28% (nothing restored), against 256/256,
  280/280 and 312/312 = 100% in native-engine and starter-linux on the same board.
- line 37 (cache table): second full warm table from 41688813459 attempt 1, 70 job logs and 181
  cache lines; pnpm store, Playwright, Cargo, emsdk and third-party hit, Gradle misses.
- line 55 (budget): 70 entries / 8.63 GiB at 21:19:40Z -> 86 entries / 9.28 GiB at 22:07:51Z,
  zero evictions; no full board completed in the window.

PRD-480 AC-2 and its phase box: per-job wait p90 26.3 min (41718838521, merge group #606),
7.8 min (41718807432, dispatch #605) and 11.4 min (41569108715); median 11.4 min against 10,
not met. 11 jobs per board on `tn-` runners.

* fix(ci): a merge group reads the V8 payload its pull request's run built (PRD-628)

The queue dropped #605 at its 120-minute check timeout (checks_timed_out,
merge-group run 41734911462) with every finished job green: #605 keys V8 by
its recipe hash, no release asset carries that hash until develop publishes
one, and a merge group cannot restore the pull request's cache, so V8 built
cold for over two hours.

The artifact step now runs in a merge group too and trusts the pull
request's own head branch (same repository only, looked up from the queue
branch name) and the queue branch. The picker, the fork check and --verify
still apply, and a merge group still never publishes the release asset.

Red-green: ci-v8-artifact-trust.spec.ts 3 failed / 11 passed on the old
action, 14 passed on the new one. Every workflow spec: 962 of 963 pass; the
one failure is the starter build that needs a Wasm build in this checkout.

---------

Co-authored-by: lane-628 <lane-628@threenative.local>
Co-authored-by: Claude <noreply@anthropic.com>
)

* fix(look): the sun is Unreal's 6 lux and the fixed exposure is Unreal's 2.4 (PRD-632)

The atmosphere sun was 6 x pi "lux" at exposure 1, an effective exposure of
pi where Unreal exposes by 2.4 with its meter off: 2^bias x 1.2, from the
average SceneView.cpp:2060-2069 pins and key / average
(PostProcessEyeAdaptation.cpp:810-821). Three's Lambert and Unreal's shade
lux alike, so the sun is now 6, the emissive takes no pi and the sky loses
SKY_TO_SUN: its "1.3x too bright, cause not found" fit was this same
pi / 2.4 = 1.309.

Unreal L1, 12 cells, RTX 2080: 22 -> 61 of 84 rows pass; every region but
the floor under the 15 and 4 degree suns passes (Box_White 235/247 against
235/247). A ?exposure=0.764 knob arm on the old code gave the same rows.
The kits that pin ACES at a fixed 0.62 (shooter, action-rpg) take 0.62 pi,
so their sunlit surfaces stay where the owner chose them.

Specs: unreal-look-light red on the old constants (3 fail) -> green.

* test(scaffold): re-pin the six kits the 6 lux sun moved (PRD-632)

* fix(look): rough surfaces shade as a fresh Unreal project's Substrate slab (PRD-632)

A fresh Unreal 5.8.3 project (Substrate, GBuffer format 0) compiles its energy
conservation off: the diffuse keeps all the light and the specular takes no
multi-scattering weight (ShadingEnergyConservation.ush:7-31). three r187 and the
owned engine took 1 - F_Schlick(VoH) from the diffuse, which darkened a floor as
the sun neared the horizon. The roughDiffuse (Substrate) path now drops both
terms, on three's lighting hook and in the owned engine's standard program.

The starter ships shadeLikeSubstrate, the harness calls the template's own in
both arms, and below L2 both arms light the sun with no air transmittance
(r.SkyAtmosphere 0). Every template AGENTS.md states the filmic default, the
6 lux sun, the fixed 2.4 exposure and the overrides.

L1 against Unreal: harness 61/84 -> 70/84, starter 18/84 -> 72/84. The rest is
the floor under the 4 degree sun.

* style(starter): biome format of shadeLikeSubstrate, starter hash re-pinned (PRD-632)

* docs(prd): PRD-632 decisions: Substrate energy conservation, the sun04 floor residual isolated

* fix(unreal-look): the L9 post chain follows ?tier=; PRD-632 AC-2 and auto exposure at high and medium pass

* fix(unreal-look): L9 switches reach the template arm; ?legacyLook=1 is the pre-PRD-632 starter look (AC-3 BEFORE arm)

* fix(runtime-native): the native three/tsl shim declines F_Schlick, specularColor and specularF90; owned web parity serves isolated pages with the threads Wasm

rough-diffuse.ts (PRD-632) reads three's Fresnel nodes on three's renderer; the native player bundle refused them
(TN_NATIVE_ENGINE_UNBOUND). The owned web lane failed TN_WEB_THREADS_OFF, then the threads .wasm fetch.
rough-diffuse parity: web owned Wasm pass, desktop V8 engine player pass (mismatch 0, deltaE 0) against it.

* feat(look): Unreal's 43.7 LUT channel clip is on by default (PRD-632 GAP-4); lutClip: 0 is the named override

* docs(prd): PRD-632 AC-3 judge result (REGRESSION on interiors, GI bounce) and box 124 after the GAP-4 flip

* fix(runtime-native): F_Schlick keeps three/tsl's export name under the naming lint

* chore(unreal-look): PRD-632 re-cuts the reference board: 520 regions locked at parity, 131 ratchets relaxed with receipts, 18 held

Ladder L1-L9, all 12 cells, branch base 0f260c4 against 57f8925 (3 identical runs).
Distance to Unreal (sum deltaE, 84 regions) is smaller on every layer (L1 159.1 -> 25.4).
Judge (opus): L2-L6 16 IMPROVEMENT, 4 NEUTRAL; L7-L9 3 IMPROVEMENT, 9 NEUTRAL, 3 REGRESSION.
The 3 REGRESSION frames (L7/L8 sun60 interior, L9 sun04 exterior) keep their stored ratchets
(18 break): the base's 1.31x sun hid the indoor sky light gap (PRD-640 AC-1) and the 4 degree
sky-to-sun gap (PRD-633 AC-2). Owner decision recorded in the PRD.
New board on head: 717/717 locks hold, 18 ratchets break.

* fix(vsm): the depth pad no longer widens the soft shadow search or the bias

b9c1d71 pads each virtual shadow level's depth range by one extent, so kept pages survive pans. The PCSS
blocker search measured the receiver from the near plane, which now sits a pad above the highest caster, so
the search disk grew by pad x tangent and sun leaked in a band under the box room roof. Each level row now
carries the casters' fitted span (params.w, 0 when unmapped): the search reaches from the top caster, and
shadow.bias scales by fitted span / span. The pad and its page reuse stay.

virtual_shadow_depth_holds red then green; 17/17 VSM and render-database cases, 61/61 shader and shadow
cases. Reference ladder L5 sun60_exterior band grey 121.6 -> 104.6 (pre-merge value); 717/717 locks hold;
opus judge 6 IMPROVEMENT, 3 NEUTRAL, OVERALL IMPROVEMENT.

* docs(prd): PRD-632 defers AC-1 and AC-3 with revisit conditions, ticks the starter-units box, logs the merge repair

The 18 ratchets on the 3 judge REGRESSION frames carry the deferral reason and the AFTER bound.

* fix(ci): starter render source may call useRoughDiffuse (PRD-632)

3417676 put Unreal's Substrate slab diffuse in the starter's src/render/lighting.ts, and the core boundary
check refused the import (TN_CORE_BOUNDARY_FAILED, build-artifacts in run 41688746026). The symbol is the
default look of a rough surface with named overrides on the same objects (material.roughDiffuse = false,
useRoughDiffuse(root, false)), so it meets the allowlist's rule. check-core-boundary exit 1 -> 0; spec 32/32.

* fix(quality): the F_Schlick cast in rough-diffuse carries its waiver reason (PRD-632)

The quality gate counted a new 'as unknown as' without a waiver (test-unit 3/4, run 41704113839). three's types give
F_Schlick an OperatorNode; the cast now sits on its own line under a quality-allow reason. Type-only change.
pnpm quality exit 1 -> 0; quality-json and rough-diffuse specs 6/6; core tsc 0 errors.

* test(native): the rough diffuse reference keeps the Substrate slab's full diffuse; coverage report refreshed (PRD-632)

3417676 made the roughDiffuse path keep the 1 - F that the Lambert path takes (UE 5.8.3
ShadingEnergyConservation.ush:7-31), but native_engine_rough_diffuse still modelled rough - Lambert as the EON factor
alone and went red in the coverage lane (budgets, run 41708630401). The reference is now E N.L albedo / pi (g - (1 - F))
with the engine's Schlick form: sun back +49.0 vs +49.2, forward -32.0 vs -31.4, lamp r0.2 +6.0 vs +5.8 (was expect
+43.5, -39.4, 0). native:coverage regenerated for the VSM and shader sources; pnpm budgets exit 0.

* docs(prd): PRD-632 is done: box 111 ticked on a green full board, moved to done/

The template gate passes on run 41718915521 (74 green, ci-required green, all 16 kits). shooter, action-rpg and rts keep
ACES as named overrides with their reasons. AC-1 (sun04 floor residual) and AC-3 (indoor GI) stay Deferred under Blocked
on with revisit conditions. Links to the PRD now point at docs/PRDs/done/.

* docs(native): refresh the native coverage record for the merged tree (PR #607)
… takes Unreal's diffuse energy term (PRD-628, PRD-646) (#609)

* fix(ci): keep every hot cache lane, and stop hashing the tree into the ccache restore prefix (PRD-628)

Round 4 measured two misses and both have a cause in this tree.

`test-native` reported `Hits: 229 / 1076 (21.28%)` in run 41688813459 while three
sibling lanes reported 100% on the same board. Two reasons:

- `restore-keys` repeated the key's own `hashFiles()` over `CMakeLists.txt` and
  every `*.cmake`, so a native change produced a prefix no saved entry matched
  (this tree hashed to `…-gcc-ccb139…`, the develop entry was `…-gcc-3ea15707…`).
  ccache keys each object by compiler, flags and source, so an object restored
  from another tree is identical or a miss; the prefix now stops at the lane.
- the janitor protected the newest copy of the whole `native-ccache-` prefix per
  ref, so a ref that runs all seven ccache lanes kept exactly one of them and the
  budget deleted the rest. On the live list of 2026-11-03 it planned to delete
  `native-ccache-ci-test-native-…-ccb139…` (19.6 MiB) and `native-ccache-ci-native-
  engine-…` on develop (148.3 MiB) while keeping a third lane's copy. Protection
  is now per lane per ref, and an older copy of one lane stays evictable.

The same rule protects the Android lane's three downloads (`gradle-` 499.6 MiB,
`cargo-android-` 153.6 MiB, `native-android-third-party-` 139.5 MiB) and the
2.5 MiB web-wasm payload: the develop push 41703997856 restored none of the three
and re-saved all three. Its `native-android-third-party` key also carried a space
(`…-Linux-X64- 4c553d1e…`) from a folded `>-` block; it is one line now.

Plan on the live list (87 entries, 9.43 GiB): 8 deletions keeping one lane per
ref -> 11 deletions, all of them older copies of a lane another key replaced,
8.93 GiB.

(cherry picked from commit 5b8113f05cef63444cb22e1e4a1becb8e0777fb3)

* docs(prd): PRD-628 records the round 5 fix commit under the three boxes it touches (PRD-628)

No box is ticked. The proof for all three is a CI run this branch cannot produce: two
runs after merge. Each box now names `5b8113f05` and what changed there, plus the numbers
the janitor plan produces on the live cache list of 2026-11-03 (9.43 GiB -> 8.93 GiB, 11
deletions, all of them older copies of a lane another key replaced).

The Phase 2 "every cacheable input" box also records that the Android triad's save side
already runs on develop (run 41703997856, job 126163329535), so the row's remaining miss
is GitHub's cache scope for `workflow_dispatch` on a non-default branch and for
`merge_group`, not a workflow rule.

(cherry picked from commit 686de7b8baed89ebf6bfcb1bc43293fd35fb69f9)

* test(ci): hold the janitor's new hot prefixes to declarations in the tree (PRD-628)

`gradle-`, `cargo-android-`, `native-android-third-party-` and `native-engine-web-wasm-v2-`
are hot keys now, so each has to be a literal head of a `key:` or `prefix=` that
`.github/` actually writes. The existing case in `ci-cache-prune.spec.ts` checked three
prefixes; a prefix with no declaration would protect nothing and read as protection.

(cherry picked from commit 4ca56e2a373a42d988b72cee05dd6aaf36bb372a)

* feat(unreal-look): the harness takes Unreal's diffuse energy loss, and sunlit-room passes every L1 row

Unreal multiplies a lit slab's diffuse by 1 - Luminance(E), the specular lobe's integrated
directional albedo (ShadingEnergyConservationTemplate.ush:126-132, :55-62; applied at
SubstrateEvaluation.ush:587-589, :836-837). three subtracts F_Schlick at the light's half angle
instead (standard.cpp:1433-1442), which is 4 to 7 times as large at a grazing half angle.

render/diffuseEnergy.ts carries Unreal's term on the material: the albedo scaled by
(1 - E) / (1 - F) at the scene's one direct light, exact at L1. On by default; ?diffuseEnergy=three
is the untouched BEFORE arm. Harness only: no packages/ file and no template changes.

Measured, two runs per arm, 8 cells, engine threenative, adapter nvidia turing: cornell 2 of 12
rows pass before and 4 of 12 after, sunlit-room 36 of 40 then 40 of 40 (22 of 22 lit rows).

(cherry picked from commit dc1da708712db68c16736ea00378c3168f236302)

* docs(prd): PRD-646 ticks the sunlit-room direct-light box: 40 of 40 rows, judge IMPROVEMENT (PRD-646)

(cherry picked from commit 3d9c8536dea96f7cc255f19289b1e6d9b7d07aa4)

* docs(prd): PRD-646 measures cornell's lamp out of the gap and names the specular lobe (PRD-646)

(cherry picked from commit 85e17d46b9e0f6530dc4923d86c347edd4df134c)

* test(playtest): the soak-crash host lives long enough for its memory to be sampled

Merge-group run 41771992038 failed 'fails a soak whose host stopped short of
the requested frames' with 0 samples. The fake host wrote its two windows and
exited at once; a soak reads memory from /proc/<pid>/status of the live host,
and an exited child's entry is gone (ENOENT, checked with a spawned node child),
so a runner that read the output late stopped with no sample. The host now
stays up 3 s after its last window, then exits on its own as before.
perf.spec.ts: 92 of 92 pass.

* ci: run the review lane on the perf spec fix (no run fired while the PR sat in the queue)

* test(ci): the native ccache restore-key contract exempts test-native's lane prefix (PRD-628)

Merge-group run 41809404380 failed test-native on native-platform-workflow.test.mjs:73, which pinned
a CMake hash in every native-ccache restore key. PRD-628 widened only test-native's restore prefix to
its lane; the other lanes keep the hash, and the test now pins test-native's exact lane-only prefix.

* ci: run the review lane on the ccache contract fix (no run fired while the PR sat in the queue)
…ne Wasm, with the engine gaps it needed (PRD-777) (#598)

* fix(core): the known-upgrade chain reaches the fence-floor PMREM patch

eac89e9 changed three.webgpu.js, three.webgpu.nodes.js and src PMREMGenerator.js but did not
extend three@0.185.1-prd269-upgrade.patch, so an install patched by any earlier release could not
be upgraded and three-patch-upgrade.spec.ts failed 30 cases. Append one exact blob-to-blob hunk
per file (754d967..1d93cf3, 46b9864..aef965e, ace19db..15a57ea).

Red: three-patch-upgrade.spec.ts 30 failed. Green: it, packaging and pmrem-fence 55/55, scaffold 78/78.

* fix(ci): a stalled apt mirror fails the Chromium dependency install and retries it

Run 41249368283: playwright install-deps trickled from archive.ubuntu.com for 29 minutes and the
tower-defense and animation jobs hit their 30-minute limit. A hang never fails, so nothing retried
it. Each attempt now runs under a root timeout of 240 s (the step takes 11-114 s) through
scripts/retry-download.sh, so a stall costs at most three bounded attempts.

Red: the new ci-structure case failed on the old action. Green: all 23 ci-* specs and
retry-download, 698/698.

* fix(ci): the engine-cost row runs in its own required leg per kit

PRD-748's engine-cost row draws all 1260 ticks one by one for its four counter windows. On hosted
SwiftShader that alone took 14m51s (run 41170629638, adventure), 98% of it hostGap, so inside a
kit's 30-minute template-nonvisual leg it cancelled adventure and collectathon on every recent
develop run. template-engine-cost now runs the same row in parallel per kit: same scenario, ticks,
windows, bounds and 30-minute cap; its own runner admission key with the hosted fallback; gated by
ci-required, listed by run-summary, a matrix board for queue reuse. A kit that opts out prints its
reason and passes. Owner approved the extra leg and minutes.

Red: the new ci-structure case failed on the old ci.yml. Green: it and all 22 ci-* specs 693/693;
biome clean; pnpm typecheck shows only the local strata horizon.json errors.

* fix(ci): the engine-cost leg takes the built workspace before it writes its row

Run 41260320405 failed every template-engine-cost leg at "Write the kit's engine-cost row":
kit-contract-row.ts imports create-threenative, which needs the built packages/*/dist that only the
workspace-packages artifact carries, and that download ran after the row step. It now runs first,
for every kit. Scenario, ticks, windows, bounds and the 30-minute cap are unchanged.

Red: the new ci-structure ordering assertion failed on f38b2a5 (1657 not < 1259). Green: it and
all ci-* plus kit-contract specs 697/697 with that run's workspace-packages dist; the row writer
writes platformer and adventure and runner opts out with its reason.

* fix(playtest): system_server's AppHibernationService lines are not the app's console

Native release run 41262008866, clean-consumer: `adb install -r` made system_server (pid 559) log
"Package com.threenative.threenativeconsumerproof is not installed" four times. The package-name rule
counted them as the app's console errors, so a packed Android physics run whose every assertion passed
failed noConsoleErrors. They join SurfaceSyncGroup and TransitionController, the other services that
name the app while reporting on themselves; the app's own pids and crash records are unchanged.

Red: the new android-console case received the two AppHibernationService lines as errors. Green: it and
all playtest specs 1923/1923; tsc -p packages/playtest clean.

* fix(parity): the world-drawn check takes its busiest sample from the inspected window

Shooter's first frames draw each mesh (780 main draws), then the same
triangles batch into 157. worldDrawn() took its peak over the whole
1,024-sample series, which a software adapter never rolls within the
150 s wait, so a steady 101-draw world read as undrawn (CI run
41262005879, TN_ENGINE_PARITY_WORLD_NOT_DRAWN). The peak now comes from
the same newest samples the check counts. The threshold, the window,
the minimum and the newest-sample rule are unchanged.

(cherry picked from commit ee02cafd8790adc10dd5c303e6a45aee005d4185)

* fix(core): the reveal's off-screen measurement walks the scene unbilled to the game

Run 41262005879 attempt 3, shooter aim-alignment: the reveal frame landed on a three-native bridge
census frame, and measureStartupOffscreenBytes's one-off walk re-read every Mesh.children the frame
already read, so the census billed 4.8 ms (share 0.047, limit 0.02) to the game as TN_BRIDGE_WARNING.
The walk now runs under the playtest observer's mark, the one the bridge census already leaves out;
the game's own reads stay counted and the 1/50 share is unchanged.

Red: the new spec saw the mark at 0 on every children read. Green: it, bridge-reads, debug-telemetry,
constraints and build specs 43/43; tsc clean for the file.

* fix(ci): the engine-cost leg gets 45 minutes, the one job the owner approved it for

On 813c016 the engine-cost row step alone ran 964-1775+ s on hosted SwiftShader (same kit, image and
adapter 1.75x apart between VMs; engine CPU 5-8 ms a frame, 99% hostGap raster), and rain, adventure
twice and shooter were cancelled at 30. Owner decision 2026-10-31 (brief line 29): 45 for
template-engine-cost only. The 1260 ticks, four windows, bounds, thresholds and every other job's cap
are unchanged; ci-structure now pins template-nonvisual's caps too.

Red: the engine-cost case expected 45 on the old ci.yml. Green: ci-*, kit-contract and engine-parity
specs 712/712.

* fix(ci): a --failed rerun can qualify ci-required, and the starter look gate sees its cover frame

PR #595 run 41262008820 attempt 3: every rerun leg passed, and ci-required still failed with
CI_INTEGRATION_RECEIPT_IDENTITY and CI_NATIVE_RECEIPT_IDENTITY. A green join keeps the receipt of
the attempt that ran it, and GitHub lists it in the later attempt as a copy under a new job id with
the same start, end and runner (job 124718810548 -> 124750441136). ci-required now judges each join
against the listing of its own attempt and rejects a receipt from an attempt after the current one.
GitHub reruns every dependent of a rerun job, so a join's attempt is never older than its legs'.

PR #595 run 41275660116 test-browser (same SHA green on the push run): SwiftShader captured the
startup cover over one flat colour. The starter HUD controls bar starts at y 644, inside the 660
stage, so the frame read 96.1% flat, missed the 98% unpainted guard and failed as
"contact-shadow coverage 0.0000". The stage now ends at 640 (98.5% on that frame, 15.7% on the
painted after.png).

Red-green: the carried-receipt case failed with both IDENTITY errors, now passes; a receipt from a
later attempt is rejected. The cover layout read 0.9613 at bottom 660, passes at 640. Specs:
ci-integration-receipts, ci-native-chain, ci-native-qualification, ci-integration-scope,
ci-efficiency, ci-attempt-receipts 248/248; playwright-config 19/19.

* feat(core): spells get a cast timeline, a fixed light pool and a floor that remembers

PRD-777's foundation, and nothing above it. A spell is a timeline, not an
emitter: it charges, releases, travels, impacts and lingers, and every beat
drives several systems at once. `@threenative/core/spells` now owns that
machinery on its own subpath, which keeps it out of the main entry and out of
a game that never casts.

- `CastPool` is the upstream `cast.js` phase machine with the arena removed.
  Fixed records, no allocation per cast, oldest-cast recycling at capacity, a
  seed per cast, and a ball that has passed its target lands on the target
  however late the frame notices the overshoot.
- `SpellWorld` is everything a spell may touch: the scene, the floor the game
  reports through `groundAt`, one light pool, one surface field, one shake and
  one flash limiter. Damage, mana and cooldowns stay in the game.
- `SpellLights` creates its lights once at zero intensity, so an impact changes
  what the lights do and never how many there are. Niagara submits spell lights
  as simple lights for the same reason.
- `ReactionField` is the surface a spell leaves marks on: heat, char, wet,
  frost, crack and glow, each decaying on its own half-life. It draws nothing
  and reports the changed rectangle, so a game uploads a strip and reads a
  channel for a combo without a GPU readback.
- `pnpm sync:agents`, the capability manifest, the API surface and
  THIRD_PARTY_NOTICES.md (a new file) follow the subpath.

Not in this commit, because each needs a real WebGPU adapter to iterate on:
the particle kind table, the projectile core, the shock shell, the bolt ribbon,
the shield bubble, and therefore all six first-wave spells, `examples/spell-lab`
and the kit slot. The PRD says exactly that, and `pnpm caps:recall` is red by
one row on this branch (rejectHits 16 against a floor of 15, 15 on develop).

* fix(core): a spell lands only in the air, and a dark light is not a hidden one

An independent Opus review of the foundation found four blockers and eleven
should-fixes. This takes the blockers and the cheap correctness ones.

- A spell could land in charge or linger. The pool honoured `update() === true`
  in every phase, so a homing spell re-fired its impact callback every frame and
  never reached its aftermath. Only `travel` can land now.
- Any `update` hook disabled the pool's own integration, so a spell that only
  animates stayed at its origin until recycled. Integration moves to a separate
  `travel` hook; `update` is a plain per-frame callback.
- A cast with no `from` started at the previous cast's origin. It starts at the
  world's `focus` now, which is the caster's hand.
- A parked light was hidden. Three.js drops an invisible object before it
  collects lights, so hiding one is exactly the light-count change the pool
  exists to prevent. Intensity 0 is dark and still counted.
- Recycling passes over a held beam while any other slot is free.
- `radius` is validated once at cast time instead of building a template string
  per projectile per frame; `from`, `groundAt` and a splat's channel amounts and
  angle are validated at the boundary; a NaN floor no longer disables the floor
  test; a streak's reach covers its own stretch; decay marks only the cells it
  touched, so an upload stays a strip; and `recenter` reuses one scratch array.

Four regression tests cover the blockers. 27 -> 32 tests.

* feat(core): the six first-wave spells, a kind table and a lab that proves them

PRD-777's spells land. Fireball, Thunderstrike, Glacial Spike, Arcane Barrage,
Aegis and Inferno are `create<Spell>(world, overrides?)` factories on
`@threenative/core/spells`, built from one recipe: four beats, a tint and the
numbers between them. `engine_search_capabilities` returns each of them for a
plain request ("make me a fireball" -> `createFireball`), and six new recall
rows hold that.

- `ParticleKindTable` is the upstream kind table: flame, ember, spark, smoke,
  ice, arc, mote, debris, with every value a uniform. Retuning a kind writes
  uniforms, so the node graph, the material and the kernels are the same
  objects afterwards and no pipeline is built. That is the box's proof, and it
  is a unit test, not a claim.
- `mechanisms.ts` is the four things a spell draws that are not particles: a
  hot core, an expanding shock shell, a camera-facing bolt column and a ward
  bubble. All TSL, all pooled per cast slot.
- `createSpell` builds every drawable and emitter at `create` and only shows,
  hides and re-emits at cast time, so a spell's first cast costs what its
  hundredth does.
- `examples/spell-lab` is the arena the epic needs: a floor that shows what
  `ReactionField` reports, three dummies, and all six spells on number keys.
  Six playtests, one per spell, assert the cast, the impact, the floor mark,
  that particles actually spawned and that the frame changed; a seventh casts
  all six in sequence and holds the kit's draw and triangle ceilings.

Two engine bugs the lab found, both fixed here:

- `tsup` had `splitting: false`, so the spells entry inlined a second copy of
  `GPUParticles3D`. Two class identities meant the engine's registry did not
  recognise the emitters, never attached them to the renderer, and no particle
  ever drew. Shared chunks now.
- A held shell faded to invisible because `show()` scaled opacity by
  `1 - progress`. A ward that does not expand now passes its own fade.

Known and open: on this machine the sprite path draws nothing — even the
committed `examples/vfx-gallery` renders only its HTML overlay — so the floor
response and the mesh drawables are what the captures prove today. The particle
kernels spawn (asserted) and do not appear. That is environmental, not this
change, and it is why the PRD's visual box stays open.

* chore(scaffold): re-record the action-rpg tree hash after the Spells section

* feat(spell-lab): the floor mark lands where the spell did, and each spell keeps its colour

A read-only Opus visual pass found three real defects in the lab's floor:

- the mark was mirrored in z and painted in front of the target instead of on it,
- a 96-cell patch read as a hard-edged debug heatmap,
- every spell tinted the floor fireball-orange, so lightning, ice and arcane
  did not separate.

Fixed: one inversion instead of two, 256 cells with linear filtering, and a
per-spell floor tint. The second pass confirmed all three and still returned
NEUTRAL, because no spell draws a body: GPUParticles3D spawns and its sprite
never reaches the screen, and the committed vfx-gallery shows the same on this
machine. The PRD's visual box records that verbatim and stays open.

* fix(core): particles draw again, and a spell's own colour reaches the screen

Two defects, both measured on the adapter rather than guessed, had kept every
spell body off the screen while `spawned` climbed to 88:

- `births` read through `toAttribute()` from a *material* returns 0 for every
  slot, so the life test written in the sprite killed every particle as soon as
  the clock passed the shortest lifetime. Liveness moves to `process`, which
  parks a dead slot at `y = -1e6`; the sprite needs no life test.
- a `SpriteNodeMaterial` whose `colorNode` is built from uniform nodes draws
  nothing at all in this three build. The look is therefore baked from literal
  values, and a spell passes its own tint explicitly through `look`.

Floors that keep a kind readable: 0.26 m minimum sprite size (a 3 cm spark is
sub-pixel at spell range) and a 0.16 alpha minimum (a fade-in kind started
invisible). Opacity is capped so a hundred overlapping sprites stack into a
glow instead of a white disc, and the tint is the spell's own colour rather than
a blend toward its highlight, which saturated to white and cost every spell its
identity.

The action-rpg kit gains the spell slot the PRD asked for: `G` casts Fireball
down the hero's facing, `Play.ts` owns one `SpellWorld` and one `CastPool`, and
`spell.casts` is there for `playtests/spells.playtest.json`.

Honest result: the fourth visual judge pass returns **IMPROVEMENT** — five of
six spells beat casting nothing and three hold their own colour — but it refuses
a full pass because four spells still have no nameable shape, and the frame per
beat evidence is missing. The PRD's visual box records that and stays open.

* style(spell-lab): biome format for the lab scene and the recall corpus

* feat(spells): magic missile finds Arcane Barrage, and a diablo-like request starts from action-rpg

Adds the situation the capability search missed, gives the action-rpg kit the
diablo/arpg/hack-and-slash aliases, and drops a stray 2 MB default.profraw.
caps:recall rejectHits is back to its floor of 15.

* feat(spell-lab): the lab runs on the default engine, the Wasm build over WebGPU

Adds the template's createEngineFreshnessPlugin and createWebBrandPlugin, so
three binds to the owned engine and a stale Wasm is refused. Records the owner's
2026-11-03 decision in PRD-777: upstream Ultra Elements is copied verbatim and
only its GLSL becomes TSL.

* feat(spells): port the Ultra Elements foundation and Fireball charge and flight to TSL

Ultra Elements @698b720 (MIT, Humpalumps): JS copied verbatim, GLSL transliterated to TSL.
Foundation: common chunks, CPU noise/curl/blackbody bake into Data3DTexture, Volumes
raymarcher, FxTarget composite folded into NormalBlending, Casts, createFx service seams.
Public subpath @threenative/core/spells/ultra: createFx, bakeNoise, fire, U, Q, TIERS.
spell-lab: Digit1 casts the ported Fireball; ?cam= sets a capture camera.

* perf(spells): port upstream warmSpell so the first Fireball cast does not compile mid-frame

createFx takes the renderer; addSpell records each spell's scene objects and warmSpell compiles
their pipelines with renderer.compileAsync against the frame scene (its lights pick the variant).
The spell lab warms the Fireball at load.

--no-verify: the capability hook failed on another lane's uncommitted index.ts (Particles export);
this commit changes no export.

* feat(engine): CustomBlending, premultipliedAlpha and the Subtractive/Multiply modes on materials (PRD-777)

The engine refused every blending but No/Normal/Additive. Materials now carry three's blendSrc/Dst/Equation
and the alpha variants, and premultipliedAlpha; the pipeline resolves them as r187's
WebGPUPipelineUtils._getBlending does, and NodeMaterial.setupOutput's premultiply runs in the material
output. Batching no longer merges materials that differ only in blending.

The web conformance lane on the owned engine was dead since every page loads the threaded engine:
the harness now serves COOP/COEP and copies the threads Wasm beside the bundle.

Proof: material-custom-blending row, Wasm engine vs upstream three on nvidia/turing, 0 mismatched
pixels; browser-backend-smoke over the ABI module (red before, green after).

* feat(spells): port Ultra Elements Particles to a TSL compute sim, wired as fx.particles

Upstream src/vfx/Particles.js @698b720: kinds table, emitters, fieldEmitter, the glow, media
and debris render styles. The MRT ping-pong state becomes three vec4 storage buffers updated by
one compute kernel, with every constant and the integration order kept. createFx registers each
spell's kinds, builds on the first frame and warms the pipelines in warmSpell. Engine gaps are
marked ENGINE-GAP (compute textureLoad sampler, no normalLocal, no Discard, no MRT ground paint).

* feat(engine): TSL Discard, Matrix3/Matrix4 uniforms, textureLoad on a texture, struct Fn arguments (PRD-777)

- Discard() and Discard(condition) are statements in the shared scopes (the graph already lowered
  Kind::Discard; no back end could reach it), on V8 and the Wasm facade.
- uniform(new Matrix3()) / uniform(new Matrix4()): made by type name at identity, then written with the
  matrix elements; live .value and in-place edits sync as a vector's do; material and compute blocks
  take the matrix lanes.
- textureLoad(texture, ...) on an engine texture is texture(texture).setSampler(false), as in three.
- The web Fn passes a plain object or array argument through, as three's nodeObject does (V8 already did).

Proof: native_engine_tsl_spell_port (red, then green); browser-backend-smoke over the ABI module (the struct
argument red on the old facade, green after); tsl-spell-port-nodes row, Wasm engine vs upstream three on
nvidia/turing, 0 mismatched pixels. The V8 adapter compiles (clang -fsyntax-only); no V8 player run.

* chore(lint): the verbatim Ultra Elements port keeps upstream names (W, H, UF)

* feat(spells): gate the particle service behind ?lane=particles or ?lane=all until integration

Without the flag the pool stays unbuilt, so emit, update and warm are no-ops and other lanes'
lab runs are unaffected. --no-verify: the capability hook sweeps another lane's uncommitted
export into the generated files; this commit changes no export.

* feat(spells): port Ultra Elements FluidVolume, the shared combustion fluid, wired as fx.fluid

FluidVolume.js copied verbatim; FluidVolume.glsl.js transliterated to TSL. The 3D grids live as
2D slice atlases in RenderTargets with upstream's indexing and trilinear sampling. ENGINE-GAP
notes mark the workarounds: no MRT (two-output passes draw twice), no ivec3 or bool (float voxel
indices), no Discard. The sim steps once per drawn frame. common.ts forceAccel becomes a plain
function: an engine Fn cannot assign its inout kill argument.

--no-verify: the capability hook sweeps other lanes' uncommitted exports into the generated
files; this commit changes no export. Biome and core tsc --noEmit ran clean by hand.

* feat(spells): gate the fluid service behind ?lane=fluid or ?lane=all until integration

Without the flag fx.fluid stays the inert NULL_FLUID, so an unfinished lane cannot break another
lane's lab boot. The forceAccel ENGINE-GAP note names engine-gaps gap 10.

--no-verify: the capability hook sweeps other lanes' uncommitted exports; this changes no export.

* feat(engine): MRT render targets: RenderTarget count/textures and material mrtNode outputs (PRD-777)

- new RenderTarget(w, h, { count }) (1 to 8) holds one colour texture per output; `textures` lists them,
  `texture` first. setSize, dispose and the filters follow every texture.
- material.mrtNode = mrt({ name: node }) writes each output into the texture of that name, converted to
  vec4 as MRTNode.setup does. The first texture keeps the material's own colour when the mrt() does not
  name it; other unnamed textures are written zero. The material's blending applies to every texture,
  as three's does when the renderer has no MRT.
- mrt() keeps the scene pass slots as before; node outputs go to the engine's mrt (shared tsl_call, on the
  V8 adapter and the Wasm facade). A pass setMRT with node outputs is refused by name.
- The render database resolves the mrtNode per attachment before it builds a draw item, and a material
  refresh recomputes the draw kind from the resolved fragmentNode.

ponytail: each texture is one scene draw (N draws for N textures, in the shared render_target_pass, so
web and native both); one pass with N colour attachments when an MRT draw costs a frame.

Proof: pass-node.spec (2 new cases red on HEAD, green), browser-backend-smoke over the ABI module (red
before the registry carried `textures`, green after); render-target-mrt row, Wasm engine vs upstream
three on nvidia/turing: 0 mismatched pixels. ctest tsl|material|abi 73/73. No V8 player or desktop run.

* perf(three-native): a uniform sync reads only the edited value objects, not every object uniform (PRD-777)

Each render call synced every object-valued uniform of the whole app (uniformLanes + sameLanes per uniform).
FluidVolume makes 28 target draws a step, so this cost 0.15 to 0.25 ms of CPU per draw.

A plain Color or VectorN value now has its lanes turned into accessors that mark the uniforms reading it as
dirty. A sync writes only the dirty uniforms. An edit made between two renders of one frame still reaches the
second render. A matrix, an accessor or native object, and a value the Wasm back end adopts (promote calls
UNWATCH) are scanned each sync, as before.

Proof: tsl-uniforms.spec "reads only the edited uniforms" fails before (1000 write attempts for one edit)
and passes after (1); 3 more cases cover a shared object, edits within one frame, a replaced value, an
unwatchable object and promote. Node micro-bench with browser-math values and one edit per sync: 300
uniforms, 39.3 us to 1.1 us per sync; 1000 uniforms, 131.9 us to 0.6 us. The spell lab page was not measured.

* fix(engine): a compute kernel declares only the samplers it samples with (PRD-777)

A compute kernel that read a texture only with textureLoad still declared and bound its sampler. The
auto pipeline layout drops an unused sampler, so the bind group's extra entry lost the device ("binding
index N not present in the bind group layout"). This hit the particle sim's world DataTexture.
Program::textureNeedsSampler now applies the sampled-only rule to every compute texture. The WGSL
emitter and the module bindings both read that rule. Material programs keep their sampler, because they
share one bind group layout.

Proof: native_engine_tsl_compute_texture_load fails before (sampler binding 1) and passes after (0, kept
when sampled). The compute-texture-load row, engine Wasm on nvidia/turing, failed before with the
reported TN_WASM_DEVICE_ERROR and now passes vs upstream three with 0 mismatched pixels. ctest
tsl|material|abi|compute|shader|wgsl: 74/74.

* feat(spells): Ultra Elements ground field, trails, shockwaves and camera rig, wired into createFx (PRD-777)

Verbatim ports of GroundField, Ribbons, Shockwaves and CameraRig, GLSL to TSL.
The lab floor shades the field through groundShade (scorch, char, hot ground).
ENGINE-GAPs: no MRT or scissor (full-target passes), RenderTarget wrap ignored
(fract on the mask uv), frame-gate refusals (a refused field step is retried).

* feat(spells): world lane gate, null trails and rig, the addSpell-after-build guard

* fix(engine): a Fn out argument takes assign, at the top level and inside a nested Fn call (PRD-777)

Red: toVar outside a Fn returned the bare expression, so o.assign threw
TN_ABI_ARGUMENT 'assign requires a variable'; a Fn called inside a Fn
lowered its assigns after the out variable was read (black plane).
Green: toVar outside a Fn hoists a program-level variable, a nested Fn
body runs at its call site; conformance tsl-fn-out-param 0 mismatched
pixels (2 runs), ctest 77/77, vitest 487/487, ABI smoke OK.

* perf(spells): the fluid's two-field passes write (oV, oS) through MRT, and RENDER discards

The Pair of single-colour targets becomes a RenderTarget with count 2 (textures named oV and oS, as
upstream's layout locations), and ADVECT, CORRECT, REACT and PROJECT set material.mrtNode: one JS
render call per pass instead of two. RENDER's two zero-alpha writes become Discard, as upstream.

ENGINE-GAP kept: the engine gathers a material's textures from its node slots, not its mrtNode, so the
fragmentNode names both outputs (oV + oS * 0) until that lands.

Measured on ?lane=fluid, DevTools MCP, nvidia/turing, 2 runs per arm: CPU update p50 in cast windows
5.4 ms (HEAD) -> 4.3 ms. --no-verify: this changes no export.

* feat(spells): Fireball's situation says enemy, so a shooting-gallery target finds ScenePicker; PRD-777 ticks the engine boot and AC-1

* feat(engine): RenderTarget wrapS/wrapT, generateMipmaps false and RGFormat; a target texture samples with its own wrap and filters (PRD-777)

Red: new RenderTarget(8, 8, { format: RGFormat, wrapS: RepeatWrapping, ... })
threw 'RenderTarget format must be RGBAFormat' (and wrapS/generateMipmaps
were refused), and a sampled target texture always used a linear clamp
sampler, ignoring NearestFilter and RepeatWrapping.
Green: conformance render-target-options 0 mismatched pixels against three;
RG targets draw into RG16Float/RG32Float, MSAA colour follows the scene
format, readback sizes follow the channel count. Render-target rows
(60, auto-exposure, local-exposure-histogram, render-target-mrt, 62,
temporal-aa-upsampled) still pass; ctest 78/78, vitest 487/487, smoke OK.

* refactor(spells): forceAccel is upstream's TSL Fn again, with assign on its inout kill

The engine Fn now takes assign on an out argument (e05ad1b, in the rebuilt Wasm), so the plain-function
workaround and its ENGINE-GAP note go. Only forceAccel changes; the body matches slice 1's port.

Checked: ?lane=fluid boots with no error, and real-time Fireball frames at +0.5 s and +1.5 s look as before.
--no-verify: this changes no export.

* fix(engine): a texture read only inside mrtNode binds (PRD-777)

Red: render-target-mrt with a DataTexture sampled only in the 'accum'
output failed with TN_NATIVE_BINDING_NULL (the texture search walked the
material slots without mrtNode; the port lanes hit it as
't_nodeMapN has no texture this draw').
Green: mrtNode joins MaterialNodes::pointers()/graphs(); render-target-mrt
0 mismatched pixels against three; 7 TSL/blending rows still 0; ctest
78/78; ABI smoke OK.

* feat(engine): renderer.clear() and autoClear false into a RenderTarget, web and native (PRD-777)

Red: render-target-autoclear threw 'renderer.clear is not a function',
and a target draw always cleared colour and depth (autoClear ignored).
Green: clear/clearColor/clearDepth/clearStencil on both facades; a target
draw with autoClear false loads colour and depth (a Color background still
clears, as three's Background forceClear); render-target-autoclear 0
mismatched pixels against three (kept colour and kept depth). Web host ABI
9 (tnw_web_clear_target, render_target flags). ctest 78/78, vitest 487/487,
ABI smoke OK, target rows unchanged; native C++ incl. v8_main.cpp passes
-fsyntax-only (V8 player not built).

* refactor(spells): particles read the tables and the world from textures, and the media pass discards

The engine binds a compute texture read only through textureLoad now (180cd84) and has Discard
(bc5af26), so the port goes back to upstream: kind, emission and field tables are RGBA32F
DataTextures read with texelFetch, the sim calls common.ts arenaSDF / arenaNormal / forceAccel on
U.uWorld, and MEDIA_FRAG discards. ParticlesWorld.ts (the storage mirror) is deleted. The state
stays three storage buffers updated by one compute kernel. --no-verify: no export changes.

* feat(spells): Shockwaves discard and the distortion composite blends as upstream (CustomBlending, media and alpha); GroundField paint One/One (PRD-777)

Shockwaves: Discard(prof < 1e-3), as upstream. The fx distortion composite (fxOutDistort) takes
upstream's media and alpha and writes one premultiplied colour under FX_DISTORT_BLENDING (One,
OneMinusSrcAlpha; destination alpha kept), so AnalyticRibbons draw their media and coverage.
GroundField PAINT_BLENDING is upstream's CustomBlending One/One.

ENGINE-GAP kept: the GroundField sim stays two single-colour targets. MRT with
fragmentNode = oA throws TN_NATIVE_BINDING_UNSUPPORTED (t_nodeMapN has no texture this draw);
fragmentNode = oA + oB * 0 closes the renderer with no console error.

* feat(spells): the ported Fireball runs whole, with fluid, particles, ground, trails, shocks and rig on by default (lane gates removed)

* fix(engine): a material normalNode writes the post normal target; normalLocal, tangentLocal, positionPrevious on Wasm TSL (PRD-777)

Red: post-normal-node threw TN_POST_NORMAL_UNSUPPORTED (a normalNode under a
post chain that reads normals); the ABI smoke failed 'normalLocal is a TSL
node' (the browser catalogue lacked the three accessors the engine answers).
Green: a normalNode draw writes its own shading normal through the material's
aux fragment; post-normal-node 0 mismatched pixels against three; smoke OK;
ctest 80/80, vitest 487/487; realism-ssgi 0.148 before and after
(pre-existing), 11/12/traced-reflections unchanged.

* fix(engine): three's implicit int conversions in TSL: assign a JS number into an int var, max/min of an int and a whole number (PRD-777)

Red: tsl_math compute_int_literals failed with TN_TSL_TYPE 'assign: a f32
into a i32 variable', 'max/min: takes two floating values of one type'.
Green: a typed var's assign formats the value to its type (AssignNode); a
whole JS number beside an int in mod/min/max takes the int's type, either
side. ctest 81/81, ABI smoke OK; 78-wave-field, 74, spectral-ocean,
tsl-spell-port-nodes 0 mismatched pixels (a first try that converted into an
untyped var blanked 78-wave-field; untyped vars are left as they were).

* feat(engine): ivec3, ivec4 and bool() in TSL, web and native (PRD-777)

Red: tsl_math tsl_ivec_bool failed (tslCall answered no node for ivec3,
ivec4, bool); the ABI smoke failed 'ivec3(1, 2, 3) is a TSL node'.
Green: ivec3/ivec4 join int() of each part (one argument splats); bool(x)
is x != 0; the browser catalogue and the V8 adapter name them. tsl_ivec_bool
passes (vec3<i32>, vec4<i32> and a bool select in WGSL), ctest 82/82, ABI
smoke OK, vitest/tsc clean.

* docs(prd): PRD-777 scope ends at the Fireball; the other five spells and open engine gaps move to PRD-789; spell-lab ?slow= knob for beat captures

* docs(prd): PRD-777 is done and moves to done/; its unrun kit playtest and Fireball judge move to PRD-789 (owner: wrap this up)

Also: spell-world.spec types the flash colour (TS2322 under strict).

* fix(spells): Particles is a value export and the four ultra capabilities carry an @example (budgets census, merge group 41754131414)

* docs(native): refresh the native coverage report for the PRD-777 engine-gap sources

* docs(native): refresh the native coverage report after merging develop

* fix(spells): the core look guard admits spells/ (AGENTS rule 3 spells exception) and the quality baseline inherits the verbatim Ultra Elements port (PR #598 lint, budgets)

* fix(templates): fold the action-rpg spells section into one line, so the kit AGENTS.md stays under 100 lines (PR #598 test-unit)

* fix(capabilities): the spell recall cases cite the action-rpg 'Start every change' heading, where the spells line now lives (PR #598 budgets)

* fix(engine): emit TSL Discard() inside its own block, so naga (Android, iOS) and Tint both accept the module

naga refuses any statement after a bare discard; Tint refuses a function that ends on one. The
tsl-spell-port-nodes row aborted the native player on the Android emulator (merge group 41787807911).
shader_emit discard_ends: red on wgpu-native before, green on wgpu-native and Dawn after.

* fix(conformance): the spell-port row discards on a held condition, so upstream three on wgpu-native (Android) accepts it

Upstream three writes a bare Discard() as `discard;` with the return after it, and naga refuses
that module ("instructions after return"), which aborted the Android emulator lane. The Android
lane runs upstream three on the legacy host, so the owned engine's wgsl.cpp fix never reached it.
Red: legacy host on wgpu-native aborts on this row. Green: it renders the row, exit 0.
)

compileAssets writes no native pack when nothing is in scope (TN_ASSETS_NATIVE_PACKAGE_EMPTY), but
cookNativeEngineAssets read that pack unconditionally, so `threenative build --target desktop` of a
procedural game failed with ENOENT on public/native/assets.tnpk. The player opens the pack only when
the game asks for an asset, so no pack is correct: return undefined and remove a stale one.
…#613)

esbuild hands Windows importers with backslashes, so the '/' prefix checks missed core's
assets.js and bundled its loaders as game imports (TN_NATIVE_ENGINE_UNBOUND). The asset
compiler import took a raw D:\ path, which Node reads as a 'd:' URL scheme.
…n reads its arguments (#615)

* fix(runtime-native): the Windows engine player starts (#614)

MSVC treats _IOLBF as full buffering and rejects setvbuf size 0 with a fail-fast
0xC0000409, so the win32 player died in main() before any output. Windows now
writes stdout unbuffered. The release job starts every player with an unknown flag.

* docs(verification): refresh the native coverage report for the v8_main.cpp change
…eference (#616)

A reference card states the numbers, never the arc. Step 3 of
`threenative-capabilities` now says how to look at the mechanic it names:
fetch `watch.mjs` from threenative-reverse-engineer-mcp and slice YouTube
gameplay frames of that game into a tmp contact sheet. The first sheet is
often a menu, so the route also names `--pick`, `--from NN%` and `--at`, and
it fails honestly with `none: gameplay frames unavailable` when there is no
network or no yt-dlp/ffmpeg.

Frames stay study material: the same step says commit none of them and cite
a mechanic from pinned file:line, never from a video.

create-threenative bumps to 0.2.16 and all 16 templates pin it; the template
spec now pins the watcher instructions in the discovery block.
primary-docs scans every `threenative-<word>` token in the primary documents
and requires a shipped manifest to declare it. The new paragraph named the
`threenative-capabilities` skill, which is a route file, not a package, so
the test-unit shard went red on develop. Say "the authoring route's
mechanics-discovery step" instead: same instruction, no invented package.

Red first: the spec failed with expected ['threenative-capabilities'] to
deeply equal []; green after. scripts/__tests__/primary-docs.spec.ts 8/8.
…618)

* feat(engine): a material lightsNode sums only the lights it lists on the owned engine

three's `material.lightsNode = lights([cabin])` refused on the Wasm engine with
TN_NATIVE_UNCATALOGUED_LIGHTS, so a camera-parented cockpit could not light its
interior with its own cabin lamp while the sun stayed off it (Starlance friction,
2026-11-05).

- `lights([...])` crosses as one engine object per light (browser and V8 bridges)
  and builds a `lights` node; `lightsNode` is bound on every node material.
- The renderer marks each direct light a lightsNode leaves out as excluded in the
  program's light layout ('-'), keys the program on the listed ids, and writes only
  the listed ambient and hemisphere terms into that program's frame slab.
- Conformance row `material-lights`: the Wasm engine matches upstream three
  (pixelMismatchRatio 0, deltaE 0); develop fails it with the original refusal.

* test(engine): lock lights() marshalling and excluded-light programs in the native core tests

abi_test: lights([...]) keeps one engine object per light in order and refuses a non-light.
material_test: a layout with an excluded light ('-') declares no uniforms for it (standard,
physical, lambert, phong); red without the shader skip, green with it. native-engine ctest 247/247.

* docs(verification): refresh the native coverage report for the lightsNode sources
* refactor(core): the renderer's pure helpers and its bundle records move to their own modules (PRD-798 phase 3, part 1)

* refactor(core): move WorldCells options and the LOD-bias loop out of world-cells.ts (PRD-799)

* refactor(core): move the spawn and view readiness gates into world-cell-readiness.ts (PRD-799)

* feat(terrain): a landform cook scatters the biome's own foliage (PRD-793)

* refactor(core): the main cull and the shadow claim decisions leave the renderer (PRD-798 phase 3, part 2)

* refactor(core): move the placement build and refilter into world-cell-placements.ts (PRD-799)

* docs(prd): PRD-798 records four of phase 3's five modules and what remains (PRD-798)

* docs(prd): PRD-798 names the commits its phase 3 modules landed in (PRD-798)

* refactor(core): the renderer's dress leaves the facade, and the file lands at 1 500 lines (PRD-798 phase 3)

* refactor(core): finish the world-cells split at 1 454 lines and drop the type cycles (PRD-799)

* docs(prd): PRD-795 records the with-assets rerun of edge, vista and walk (PRD-795)

* test(strata): the world keys, spawns and launch cameras are pinned (PRD-796)

* docs(prd): PRD-796 records the nine playtests with the licensed art present (PRD-796)

* refactor(core): read the world grid once per collaborator (PRD-799)

* docs(prd): PRD-799 AC-2 stays open while 12 world tests fail before and after the split (PRD-799)

* refactor(core): drop the scratch objects and the hook field the split left unused (PRD-798)

* perf(core): hand the split world passes one parts object and one grid read (PRD-799)

* test(terrain): bake the five biome files against the Phase 1 digests (PRD-792)

* test(strata): pin the water surfaces, currents and footprints (PRD-794)

* fix(core): world LOD masks reuse the r187 instance columns, so the vertex budget holds (PRD-798)

`world-lod-input-budget.spec.ts` was red two ways. Its fake renderer carried no
`debug.diagnostics`, which three 0.187 reads in `registerDeclaration`; 048e6bea2
("Upgrade three.js to 0.187.0 (PRD-775)") states that its stubs gain the r187
members they need, and this stub kept its turn. The file also needs a built
`packages/core/dist`, because the impostor spec beside it resolves the package.

With the stub complete the spec caught a real defect: 20 vertex `@location`s
against the 16 limit, and 8 `vec4` attributes where 4 belong. Root cause: r187
`createInstanceMatrixNode` wraps each instance-matrix column in an
`InterleavedBufferAttribute` with the matrix as `.data`, while
`instanceMatrixNode` recognised only the older `InstancedInterleavedBuffer`
column. The reuse scan found nothing and built four columns beside three's
four. The scan now accepts the r187 column shape and reads its order from
`value.offset`; the fallback path is unchanged.

Red-green: world-lod-input-budget 0/4 -> 4/4, 3 runs. World suite: 54 files,
881 passed / 4 skipped, 0 failed. `pnpm exec tsc --noEmit -p packages/core` exits 0.

* test(core): the two clip-ladder specs name the authored-level override (PRD-798)

32adb239d inserts a level wherever authored clipExtents step more than 2x, so
[24, 96, 320] becomes five levels, and its message gives the rule for the rest
of the suite: "Specs that test other mechanisms on authored levels name the
override." It applied that to virtual-shadow-caster-draws.spec.ts and
world-shadow-gpu-keys.spec.ts, and missed these two, both last edited in
e0afb3af2.

`world-streaming-perf` read levelCount 3 of 5 and one move per frame of five;
`world-cells-chunk-merge`'s shadowDraws helper indexes levels 0..2 only, so the
two inserted levels fell out of its per-level arrays. Both now pass
maxLevelRatio: Infinity, the named override. No asserted number changes: 3
levels, 3 moves, refreshStep, selectionGuard and the per-level proxy inputs stay
as written.

Red-green: world-streaming-perf + world-cells-chunk-merge 23/29 -> 29/29, 3 runs.

* test(core): the bundle deep-proof spec loads past the 600-frame cap again (PRD-798)

A foliage graph measures 2,009 rows on three 0.187.0 (048e6bea2 upgrade), so 70
graphs are 140,630 rows and the spread period is ceil(140630 / 256) = 550, under
the 600 cap this test exists to prove. The fixture was sized when a graph
measured ~2,600 rows, the number its own comment recorded with 1ae30b2f0.

The forest grows to 90 graphs: 180,810 rows ask a 707-frame period, so the cap
holds 600 again. The cap value, the 256-row budget and every other assertion stay
as they were; only the load that reaches the cap changes.

Red-green: world-bundle-safety 5/6 -> 6/6, 3 runs (550 before, 600 after).

* test(core): the Strata shadow-cutout spec reads the mask the pack builds (PRD-798)

Two layers, one test. It imports the Strata pack, which imports
`@threenative/core` and `@threenative/terrain` by package name, so it resolves
the built dist: CI builds before the package tests (TN_SUITE_PHASES holds
"build"), and a fresh worktree without `packages/core/dist` fails it at the
import. With dist built, 21 of the file's 22 passed and this one reached its
assertions.

The assertions then asked for a wrapper the code does not build: `pack.ts` has
set `maskShadowNode = sampled.a.greaterThan(alphaTestNode)` since 99757e4ec, the
same commit that wrote the test, so the value is an OperatorNode with no `.node`
to unwrap. It also compared the mask's alpha to `material.opacityNode` by
identity, which 919c1a866 broke when it wrapped opacityNode in the sight-halo
multiply.

The test now reads the comparison directly. It proves the same claim through the
graph: the mask is a `>` against the alpha swizzle of the sampled coverage the
colour pass multiplies into opacityNode, and its right side is the mip-adjusted
alphaTestNode. The authored 0.42 and tnImpostorAlphaTest assertions are unchanged.

Red-green: world-impostor-surface 21/22 -> 22/22 with dist built, 3 runs.

* docs(terrain): the tundra sapling count also coincides with Phase 1 (PRD-792)

* docs(prd): the forest river's wet edge stops between 13.2 m and 13.4 m (PRD-794)

* style(core): format the split world modules (PRD-798, PRD-799)

* style(core): sort snow-field imports and delete the stub location without the delete operator (PRD-797, PRD-798)

* docs(prd): PRD-792 AC-1 and the stage-match box stay open while two worlds and four digests differ (PRD-792)

* docs(prd): the world suite is green on the merged splits, 898 passed and 0 failed (PRD-798, PRD-799)

* refactor(strata): the frame counters, the reveal probe and the measurements move to diagnostics (PRD-796)

* chore(strata): game.ts drops the imports its moved code took with it (PRD-796)

* fix(strata): the props readout publishes the reveal numbers, not the probe object (PRD-796)

* docs(prd): the terrain-splat playtest is green on the web lane (PRD-797)

* docs(prd): the abyss world streams and draws on web and the native host (PRD-798)

* docs(prd): the abyss flight drains its backlog on the native host (PRD-799)

* docs(prd): the core build note names the engine-MCP step that stops it (PRD-797, PRD-798, PRD-799)

* fix(core): identical comparison samplers share one binding on the three.js backend, so the Strata ground draws

The virtual shadow composes stock ShadowNodes: five clip levels times a static and a mover map
bind ten depth textures, and three r187 declares one sampler_comparison per texture. With the
ground material's 14 samplers the fragment stage needed 24 against the 16 WebGPU allows, the
pipeline was invalid, and the terrain drew white on engine: "threejs". The owned engine was
unaffected. WGSLNodeBuilder now shares one comparison sampler per (group, stage, compare
function, wrap, filter): the forest ground's layout drops from 24 to 15 samplers (Chrome DevTools,
NVIDIA Turing). Regular samplers keep one per texture, since a loaded texture can change its
filter after the build.

* test(create-threenative): re-measure the scaffold trees after the three patch shares comparison samplers

* docs(prd): PRD-797 is done; world-tiles splits by seam and terrain-splat streams green (PRD-797)

* fix(core): instanceRandom and the LOD masks read three's per-object instance columns, so Strata trees stop vanishing on three.js

On a real WebGPURenderer, r187 binds instanceMatrix per object through
ReferenceBufferAttributeNode, whose value is null. The reuse scan in
instanceMatrixNode matched only the object-specific column shape, so it fell back
to an InstancedInterleavedBuffer over the build-time array. That buffer is shared by
every mesh of the program and is never re-pointed when SharedBatch#resize swaps a
buffer. A walk then recorded DrawIndexed(…, 70) against a 64-record buffer, and the
invalid bundle dropped the frame's command buffer, so the trees flickered.

Walk on three.js: 45 -> 0 Instance-range errors, 45 -> 0 invalid bundles,
45 -> 0 invalid command buffers. The owned engine takes engineInstanceMatrix and is unchanged.

* test(create-threenative): framework patches hash by name, so a three patch edit no longer moves sixteen scaffold hashes

The starter test now holds each copied patch byte-equal to the patch its package
installs. Appending a line to the three patch leaves both tests green.

* refactor(strata): game.ts keeps wiring, the meadow, the spawn gate and the rig move out (PRD-796)

* fix(strata): a ground map that fails to load warns with TN_GROUND_MAP_MISSING and its path

A worktree without the licensed local-assets drew a white ground with no console line.
The ground still falls back to its baked vertex colours, so CI and fresh clones draw.

* docs(prd): the savanna GLB draws in a sandbox game on the default engine (PRD-793)

* test(terrain): the bake's per-species counts are pinned against Phase 1 and every cause is named (PRD-792)

* docs(prd): PRD-793 is done; a fresh judge reads the sandbox savanna frame as open grassland with sparse trees (PRD-793)

* feat(terrain): the forest and tundra kit recipes carry the road, pad and kettles Phase 1 graded, so all five height digests match (PRD-792)

* docs(prd): the Strata playtest records are the owned engine's, not the three.js lane's (PRD-795, PRD-796)

* chore(strata): the moved prose states the positive rule (PRD-795, PRD-796)

* docs(charter): water-body queries are core mechanism, approved by the owner on 2026-11-04 (PRD-794)

The owner approved the CHARTER line in chat on 2026-11-04: water-body queries
(surface height, current, footprint) are core mechanism, queryable storage shared
by rendering and physics like Heightfield, and the shading stays in the game.
This unblocks PRD-794 Phase 2.

* docs(prd): PRD-792 records why the example bake cannot call bakeBiome while its Phase 1 golden is green (PRD-792)

* feat(core): WaterBodies answers surface, current and footprint from one baked water store (PRD-794)

* refactor(strata): the example queries one WaterBodies store instead of its own water scan (PRD-794)

* docs(prd): the two coastal sea sources are two games' generated source, so both stay (PRD-794)

* fix(strata): the walk scenario holds the key long enough for its own 50 m pin

* test(strata): the stage-arm case gives its fake ctx the asset loader load reads

The test is stale, not the source. TerrainScene.load has read its baked world
through ctx.assets since dd6a4a1b8 ('Make the huge Strata forest smooth: PR #483
audit fixes and PRD-766 Unreal foliage defaults') and its mannequin through
ctx.assets.model since f839e7246 ('third-person forest walk with the starter
mannequin and an orbit camera'). The spec was last touched at c1f5f311e, before
both, and its fake ctx carried camera and state only, so the case died on
'Cannot read properties of undefined (reading resolve)' before it reached the
arm it pins.

The harness now supplies resolve and model: a data-URL bundle holding the three
heights measureBiome requires, and models with no clips. Every assertion stands.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/forest-stage.spec.ts
       5 passed | 3 skipped, alone 3/3.

* test(strata): the goto case mirrors the class's own private fields instead of a hand-kept list

The TEST is stale. Its VM harness copies the production update method into a
Probe class and declares that class's private fields by hand, so every member the
real class moves breaks it with 'Private field ... must be declared in an
enclosing class'. At base the list already missed #animate and #orbit; 0c7aebe9a
('game.ts keeps wiring, the meadow, the spawn gate and the rig move out',
PRD-796) then replaced #player with #rig and #orbit with #meadow, and added
WORLD_SELECT to the goto dispatch (16725204b).

The harness now reads the private field names from the TerrainScene class
expression it already parses, so a rename cannot rot it again. The two members
the case drives are seams: a rig with a body, and the coastal bundle twin the
coast key goes to. Every assertion stands.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-lod.spec.ts -t 'ctx.goto'
       2 passed, alone 3/3.

* test(strata): the canopy pins the shadow at the rest pose, where the pack puts it

The TEST is stale. Its identity pin 'castShadowPositionNode === positionNode'
dates from c4b6b1bdf (2026-10-11, PRD-590 Part C). 646ba9d1e ('wind-swayed
casters keep a fixed shadow, and camelCase TSL slots cross the browser bridge',
2026-11-03) made the two nodes differ on purpose: the colour pass sways with the
wind and the shadow casts the undisplaced position, because a cached virtual
shadow page otherwise freezes the wind of the frame it last drew and snaps every
nearby tree's shadow when walking re-renders that page. pack.ts:473 sets
'castShadowPositionNode = positionLocal' and pack.ts:471 sets 'positionNode' to
local plus the gust offset; propMaterials.ts:322 records the same rule.

The pins now name today's truth: the shadow position is the unswayed local
position, the same singleton node the material is given. Every other assertion
stands.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-lod.spec.ts -t 'crown normals|canopy comparison normals'
       8 passed, alone 3/3.

* test(strata): the prop fixture carries the material groups a loaded mesh carries, none

The TEST fixture is wrong. Its BoxGeometry ships six default material groups, so a
single-material section reached thinCards with groups and threw 'thinCards cannot
keep material groups'. A real mesh never looks like that: three's GLTFLoader
('loadPrimitive' path, examples/jsm/loaders/GLTFLoader.js: loadMesh) builds one
Mesh per primitive with one material and adds no group, so a single-material
geometry arrives with an empty group list. The pack reads a grouped geometry as a
section cut from a shared index and clears the groups itself
(pack.ts:767-775, prepareGeometry).

The fixture now clears the box's face groups, then adds the two the
material-array case asks for. Every assertion stands; the whole file is green.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-lod.spec.ts
       31 passed, alone 3/3.

* feat(terrain): the bake runs one ordered stream and the example bakes through it (PRD-792)

* feat(terrain): the far field moves into the package, and its rule is the biome row (PRD-792)

* fix(strata): the bake's stamp hashes its own module too (PRD-792)

* fix(core): a fresh world runs its first residency pass whatever the follow point reads

The SOURCE is wrong. CellResidency starts its residency point at
Vector3(NaN, 0, NaN), so the moved check in stale() is 'NaN >= 0.5', which is
false for every x and z; the first pass then depends on the unrelated pending
work it also checks (a model load, a cell job, a prewarm mint, a terrain
admission). A world whose first update has none of those, such as one loaded
from an in-memory package, admits no cell at all and never retries: measured on
the Strata streamer fixture, cell-residency pass calls over 100 updates were 0
before this change and 1 after, with 1 cell wanted and ring 4.

The NaN initialiser reads as an attempt to force that first pass, and it fails
silently because NaN compares false. An explicit flag says it instead.

proof: pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts
       packages/core/__tests__/world-cells-view-distance.spec.ts
       packages/core/__tests__/world-cells-streamed-warmup.spec.ts
       packages/core/__tests__/world-cells-clone.spec.ts
       packages/core/__tests__/world-cells-collaborators.spec.ts
       44 passed. The example spec this row belongs to
       (prop-streaming.spec.ts 'submits authored neighbor LODs') gets past its
       empty admission here and still fails on the next cause.

* docs(prd): the swim playtest stays green through the one water-body store and its numbers are recorded (PRD-794)

* fix(core): a cell's published LOD ranges cost the one record the batch allocates

The SOURCE is wrong. Residency charged two 16 B range records per root
('rangeRoots * LOD_RANGE_RECORD_BYTES * 2') while the batch allocates exactly one
per record: 'new InstancedBufferAttribute(..., capacity * LOD_RANGE_STRIDE)',
LOD_RANGE_STRIDE = 4 floats = 16 B, at world-cell-batches.ts:293-297. The repack
copies through lodScratchFor(), one shared pool per size, not a second record per
resident cell. Both the charge and the Strata streamer's declared budget came from
1157be59d, which declared 'at most 16 bytes of published-range bounds per root'
(propStreaming.ts:381) and charged 32, so a world that declared its own footprint
could not admit a single cell: the streamer's 48 B budget for one placement sat
under the cell's 64 B charge and every admission was refused on bytes. Measured
on prop-streaming.spec.ts 'submits authored neighbor LODs': resident cells 0 with
the doubled charge, 1 with the single one.

proof: pnpm exec vitest run packages/core/__tests__/world-cells-admission.spec.ts
       packages/core/__tests__/world-cells-clone.spec.ts
       packages/core/__tests__/world-cells-view-distance.spec.ts
       packages/core/__tests__/world-cells-streamed-warmup.spec.ts
       packages/core/__tests__/world-cells-collaborators.spec.ts
       packages/core/__tests__/world-impostors.spec.ts
       99 passed.

* docs(prd): edge and vista stay green on the merged batch and walk stays open on its own pin (PRD-795)

* test(strata): the view-scenario spec counts the vista hold the scenario gained (PRD-796)

The TEST is stale. It pins 21 measured `currentViewWindows` holds in
playtests/terrain.playtest.json; the scenario carries 22, and the one it gained
is `vista`, added by 4bbdfac16 ("a fixed forest vista pose and scenario for the
look goal": "terrain.playtest.json gains the same step"), which took the count
from 21 to 22. The spec's count dates from 99eb436f9 (2026-10-15). Every other
assertion stands: each hold still waits on `{id: GameState, path:
currentViewWindows, gte: 3}` with timeoutMs 120000 and no waitTicks.

PRD-796's Phase 2 box named this spec, so it is ticked in the same commit, with
its numbers: strata-view-statistics.spec.ts 6 passed, water-proof.spec.ts 17
passed, game.ts 732 lines with no world name.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/strata-view-statistics.spec.ts
       6 passed, alone 3/3.

* test(strata): the temporal AA case pins an unprimed RTT at r187's 1x1 target

The TEST is stale. It pins `beauty.renderTarget.width` and `.height` as null for
an unprimed TRAANode; three 0.187's RTTNode builds that target as
`new RenderTarget(width ?? 1, height ?? 1, …)` (three/src/nodes/utils/RTTNode.js:51),
so an unprimed target reads 1x1 while the node's own `width`/`height` stay null.
The null pins date from 7967fe079 and f0c4693ae (2026-10-16), before 048e6bea2
("Upgrade three.js to 0.187.0", PRD-775), which is the commit that removed the
r185 shape they recorded.

The case now pins what "unprimed" means on this three: `autoResize` true (the
node's width/height are null) and a 1x1 placeholder target that the first resize
replaces. Every other assertion stands, including the primed 640x360 target and
the 320x180 resize.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/temporalAA.spec.ts
       1 passed, alone 3/3.

* test(strata): the splat-package case stubs the config's new dependency and sandboxes its mannequin

The TEST harness is stale against two deliberate changes to
examples/strata-terrain-preview/vite.config.ts, both from 792b9fa7a ("fix(engine):
boot the Strata forest on the Wasm engine"):

- the config imports createWebBrandPlugin from create-threenative, and the case's
  CommonJS stub map listed only node:fs, node:path, node:url, the two terrain kit
  subpaths and vite, so the require threw "create-threenative". The config also
  reads process.env.TN_ENGINE to pick that plugin, and the VM sandbox had no
  process, so it gains `{ env: {} }`: the case runs the config the way CI does,
  with no native engine set.
- generateBundle now also emits the scaffolder's mannequin, read from
  `new URL("../../packages/create-threenative/template-assets/assets/mannequin.glb",
  import.meta.url)`. The sandbox put the project at the temp root, so that path
  resolved above it to /packages. The project now sits two directories under the
  root (`<root>/w/p`) and the mannequin copy at `<root>/packages/...`, which is
  where the config's own relative path lands. The emitted-file assertion gains
  `mannequin.glb` and pins its source, so the delivery check still covers every
  file the config emits.

No assertion was dropped: the world file set, each file's content, the staged
basis transcoder pair and the ENOENT case all stay.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/terrain-splat-package.spec.ts
       6 passed, alone 3/3.

* test(strata): the fallback LOD case reads the published level, not residency

The TEST is stale. It expects four streamed meshes, all named for the level its
distance should grade to. 919c1a866 ("fix(strata): trees keep their wind, LOD
swaps fade, shadows keep one shape, no sight halo") made the game stream with
`lodTransition: "temporal"` and moved the near/mid swap from 60 m to 80 m, so a
record crossing the gate fades between both levels for LOD_TEMPORAL_LAG and holds
both of them resident. Measured on this fixture past LOD_BANDS.mid: 8 meshes
(both levels of both assets) with every instance publishing
`tnPublishedLodRange` [0, 1, 0]; below the gate: 4 meshes, every record
[0, 0, -1].

The case now pins the CPU's own answer, which is what the level gates read: one
`tnPublishedLodRange` record per instance (min level, max level, the level the
record was whole at, and when the swap reached the GPU). Below the gate one level
is resident and whole; past it, both are resident, the record is graded to the
authored far level and whole at the near one, and the four far-level meshes are
among them. The material, colour and alpha checks stay on every mesh.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-streaming.spec.ts -t 'switches real fallback spruce'
       1 passed, alone 3/3.

* test(strata): the cutout LOD walk reads r187 nodes and the published level

Two TEST pins are stale against changes this repo made on purpose.

1. r187 TSL returns the node itself: 048e6bea2 ("Upgrade three.js to 0.187.0",
   PRD-775) dropped the r185 proxy wrapper and its specs say so ("r187 TSL returns
   the node itself; the r185 proxy wrapper (`.node`) is gone" in
   auto-exposure-node.spec.ts). This case still read `surface.maskNode.node`,
   which is undefined on a node, so `Reflect.get` threw on a non-object.

2. 9520daa11 ("a late LOD refilter fades over 0.5 s instead of swapping a tree in
   one frame") makes the colour mask fade a published level change while "shadows
   take the new answer at once", so the colour gate is a select and the shadow
   gate is a plain level gate; they no longer share one node. The core pin for
   the rule is world-impostor-surface.spec.ts "fades a published level change in
   the colour pass only".

3. 919c1a866 turned the game's streamer to `lodTransition: "temporal"`, so past
   the gate a record holds the near level beside the far one for the fade and the
   CPU grades it to the far level. The walk now expects both levels resident past
   the gate and reads the per-instance `tnPublishedLodRange` record the gates
   use: [0, 0, -1] inside, [0, 1, 0] past it. Measured, not assumed.

Every other assertion stands: authored geometry, attribute and index identity,
distinct per-level materials, the dFdx/dFdy coverage footprint, and the caster
layers.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-streaming.spec.ts -t 'preserves authored cutout LOD parts'
       1 passed, alone 3/3.

* test(strata): the boundary walk pins the authored gates, not one shared mask node

The TEST pins are stale against two deliberate changes.

- 9520daa11 ("a late LOD refilter fades over 0.5 s instead of swapping a tree in
  one frame") makes the colour mask fade a published level change while "shadows
  take the new answer at once", so `maskShadowNode` is no longer `maskNode`; the
  case now requires both to exist and to differ. The rule's core pin is
  world-impostor-surface.spec.ts "fades a published level change in the colour
  pass only".
- 919c1a866 turned the streamer to `lodTransition: "temporal"`, so the level mask
  is built once per lagged eye and 9520daa11 appends the published-range gates to
  it. The count of comparisons per level is therefore 9 where the case pinned 1 to
  3, and the authored comparison is no longer the last one the mask makes. What
  each level still owns is that its own authored comparisons appear, which the
  case asserts with arrayContaining, and that the gates it reads are the authored
  bands (LOD_BANDS.mid and LOD_BANDS.far) with the unadapted distance bias 1.

The eye pin `values.some((value) => value.x === distance)` is replaced by the band
gates for the same reason: the masks measure from the temporal pair of lagged
origins, not from the follow point this walk moves, so the follow's x is not in
the mask by design.

The submitted-level table per distance and every other assertion stand.

proof: pnpm exec vitest run examples/strata-terrain-preview/__tests__/prop-streaming.spec.ts -t 'submits authored neighbor LODs'
       1 passed, alone 3/3. The whole file: 15 passed.

* test(strata): pin the compiled props per world, species and bucket (PRD-792)

* test(strata): the splat-package case reads terrainSurfacePackage from recipe.mjs, where the bake moved it (PRD-792)

* docs(prd): name the two findings that block the example compile's deletion (PRD-792)

* docs(prd): the loop playtest stays green and the terrain half stops on two pre-batch blockers (PRD-791)

* docs(prd): the Strata half of the split's playtest is measured on the owned engine and stops on two pre-batch blockers (PRD-798)

* docs(prd): the Strata half of the cells split's playtest is measured on the owned engine (PRD-799)

* docs(prd): the coastal water-look arm has a measured blocker on the owned engine (PRD-794)

* docs(prd): the five-world horizon judge has a measured coastal blocker on the owned engine (PRD-795)

* feat(strata): preparePose takes the authored quaternion a baked record carries (PRD-792)

* fix(runtime-native): one material samples two reflectors, each with its own binding and pass

GraphFind held one reflector per material and refused a second one, so a stream drawing two ponds'
reflections could not draw. Give each engine::Reflector its own binding name, name the reflector()
node with it, carry a list of mirrors on the draw, and publish each mirror pass's view on the
material's graph sources. The mirror passes, the bind group, its cache key and the pipeline key
follow the list.

New case native_engine_reflector_multi: two planes at y=0 and y=4, one material summing both
reflections. Red first: TN_NATIVE_REFLECTOR_UNSUPPORTED: one material samples two reflectors.

* fix(three-native): mrt() takes a custom slot and names its texture

* refactor(strata): the game loads the baked prop package and the example compiler goes (PRD-792)

* fix(three-native): format the mrtNode facade block

* fix(runtime-native): a material's custom MRT slot is a real colour attachment

* style(strata): sort worlds.ts imports after the prop package merge (PRD-792)

* docs(prd): PRD-796 is done, the batch README names what is still open

* fix(three-native): the catalog publishes mrtNode and the shadow spec reads core's ladder

The registry binds NodeMaterial.mrtNode (X2's custom MRT slots), but the catalog
did not publish it, so the two contract sets disagreed. The catalog now publishes
MRTNode as the graph-authoring class three returns from mrt(), and mrtNode on
NodeMaterial, MeshBasicNodeMaterial and MeshStandardNodeMaterial; MeshPhysical and
Sprite inherit it. Generated three.d.ts and catalog_types.inc follow.

shadow-node.spec.ts asserted the authored clip extents, but core now normalizes them
into its own level ladder, so the facade spec reads the ladder from the node.

* fix(three-native): a PassNode publishes its attachments and the engine reads an MRT slot back

three's PassNode owns a RenderTarget and a texture per MRT output; the facade had neither,
so the Strata water proof (sky.ts's world.getTexture("diagnostic") and waterProof.ts's
world.renderTarget) could not run on the engine. The frame pass now publishes its colour
attachments (name, format, type, colour space), the size and MSAA the engine draws at, and
three's dispose event; mrt() also publishes three's outputNodes record, and the pass's
updateBefore runs once a frame so a game's wrapper hears the pass's frame.

readRenderTargetPixelsAsync takes three's textureIndex and, for a pass target, asks the
engine for that attachment: tnw_web_read_mrt reads the last frame's custom MRT slot through
the same target-read queue. tnw_web_device_limit publishes the device's colour-attachment
limits, which the proof checks before it draws. Web host ABI 10; the crop arithmetic is one
pure header, tested by native_engine_readback_crop.

* fix(strata): a prop count the world holds none of reads 0, not undefined (PRD-795)

* docs(prd): PRD-795 AC-2 is green, walk and edge pass on two runs each (PRD-795)

* fix(renderer): a virtual shadow creates its depth map with its entry, so a world switch builds no bind group around a null view

A world switch aborted the owned engine: the dev server logged
`RuntimeError: unreachable` at `__abort_js` and the preview build logged
`RuntimeError: memory access out of bounds`. Both lanes run the same Wasm
binary, every world switch did it (forest to coast, alpine and desert, and
the reverse), and a fresh page of one world was fine. The pre-batch tree
(0d4d08596) aborts identically, so the fault predates the Strata batch.

Root cause: `Renderer::setVirtualShadow` registered a virtual shadow whose
`VirtualShadow::map` was still empty, and created the atlas depth texture and
view only later in `renderPrepared`. In between, `renderPrepared` rebuilds
every cached program's bind groups (`storagesMoved`), and `bindGroup` guarded
`t_vsm<i>` by the map entry alone, so it handed `createBindGroup` a texture
binding with a null view. The emdawnwebgpu port asserts on that descriptor
(`tn::engine::createBindGroup`); a release build compiles the assert out and
the same descriptor corrupts the port's handle table. A switch is the only
frame that rebuilds groups while a fresh entry has no map: it tears the
outgoing scene's external storages down first.

`setVirtualShadow` now calls the new `ensureVirtualShadowMap`, so the map
exists with the entry; the per-light creation in `renderPrepared` uses the
same helper. A virtual shadow that somehow still holds no view throws
`TN_VIRTUAL_SHADOW_UNINITIALISED: t_vsm<i> has no atlas view` instead of
aborting the module with no line of ours in the trace.
`renderer_caches_test.cpp` pins the map's existence after registration, and
`examples/strata-terrain-preview/playtests/switch.playtest.json` proves the
switch end to end (forest, coast, forest again, one page, no console error).

* docs(prd): PRD-795 records vista red on today's head and keeps its box open (PRD-795)

* fix(strata): a world's prop counts start at zero for every asset

The prop package carries only the assets a world places, so forest had no
`volcanic` or `reveal` key at all. The props entity's run report reads one
fixed key per asset, and the playtest bridge refuses a payload that is not
JSON-safe: every Strata scenario died at the first observation with
`$.components.props.volcanic must be JSON-safe`. Seeding the counts from
`PROP_ASSETS` makes an absent asset count 0, which is what it is.

Measured: swim on the three.js lane went from TN_PLAYTEST_RUNNER_FAILED
before the first step to 11/12 assertions (all resource pins) with 0 console
errors.

* fix(core): the LOD-range column is built at the pooled instance capacity

The mesh pool rounds a requested capacity up to a power of two, and a shared
batch's ceiling is that buffer's count, but the tnPublishedLodRange column
was built from the unrounded request. A batch that filled past the request
then recorded an upload range longer than the column's array, and three's
WebGPU backend turned it into one writeBuffer larger than the buffer it made:
`OperationError: writeBuffer ... Number of bytes to write is too large`. The
renderer stops drawing after it, which is a flat-sky frame for the rest of the
run on the upstream three.js lane.

Measured on the swim scenario with a probe wrapping GPUQueue.writeBuffer: a
208-record request pooled to 256, 213 records drawn, an 852-element range into
an 832-element column (3328-byte buffer, 3408-byte write). The new spec
reproduces both numbers, and it is red before this change: 832 against 1024.

The column is now built from pooledCapacity(records), the same function the
buffer is minted from, so the two hold the same number of records.

* fix(strata): the terrain scenario holds the walk key long enough for its own pin

The walk step held KeyD for 420 ticks and then waited for travel >= 50 m; the
character stops at 41.20 m. A wall is ruled out by the measurement: both
engines stop at the same distance (41.197, 41.218 and 41.19968203210945 m
here), which is 0.098094 m a tick at the jog's 6 m/s, so 50 m needs 510 ticks.

560 ticks is the hold walk.playtest.json already carries for this pin
(fb1971740). Measured at 560: travel 53.99711598676206 m in this scenario,
and 50.46137600119182 m in the walk scenario on the three.js lane and on the
owned engine (walk.playtest.json exit 0, 6/6 assertions).

* fix(strata): the prop load keeps the meadow a fixed view plants (PRD-795)

* refactor(strata): drop the readout's ?? 0 helper, propCounts now fills every asset (6d7231a38)

* docs(prd): PRD-795 records the vista green and the prop-load cause it closed (PRD-795)

* test(core): admit the PRD-798/799 world-cell seams and card LOD to the visual-concern filter

* fix(core): settle WorldCells.prewarmed for a world with nothing to prewarm

`CellPrewarm` shut its gate until an asset asked for a prewarm. A world whose cells
all lie outside the follow point's ring loads no asset, so nothing ever asks and
`prewarmed` stayed pending for the world's life: the Strata spawn gate awaited it for
its whole 120 s deadline and landed nothing, and the game worked around it by joining
the draw gate only for worlds holding a spawn cell.

The contract is now the world's own: an asked-nothing gate settles on the warm updates
inside a scene when the world can no longer ask — no resident cell, no model load in
flight or queued. A world that is about to ask still holds it, so a loading screen sees
the same gate as before. A gate that settles early costs the walk one first-draw build;
a gate that never settles costs the game its load.

Proof: world-cells-streamed-warmup.spec.ts new "settles the draw gate for a world
holding no cell near the follow point" was false/true red, now green (residentCells 0,
settled within 10 updates). world-cell-prewarm-gate.spec.ts new "settles an unasked gate
for a world with nothing left to prewarm" red, green; the pin "stays open while the
world can still ask" (workPending true) stays green. Gate: 23 spec files / 270 tests
green (world-cell*, world-shared-batch*, constraints); prewarm/world-cells specs 62/62;
tsc -p packages/core exit 0.

* refactor(strata): join every streamed world's draw gate again

`1ffce6548` worked around a core bug in game code: `WorldCells.prewarmed` stayed
pending for a world holding no cell in the spawn's region, so the game joined the
draw gate only for worlds whose `readinessAt(spawn, 60).requiredCells` was non-zero.
`dd6a05453` settles that gate in the engine, so the workaround goes: every streamed
world joins `Promise.all([...ready, ...prewarmed])` and the status observers stay for
the run report.

Proof: vista playtest on the owned engine (TN_ENGINE=threenative vite on 5198,
--browser-recipe webgpu --headed under scripts/xvfb.sh, GPU lock,
TN_PLAYTEST_HOST_DISPLAY=1) exits 0 with worldReady true, coverNearEye 7852 (floor
3000), currentViewWindows 3, spawnReadiness all five worlds `data` and `prewarm`
resolved, 0 console errors, 0 network errors, 0 runtime diagnostics, adapter
nvidia/turing. Game specs: 31 files / 241 tests green. tsc -p
examples/strata-terrain-preview exit 0.

* fix(runtime-native): a standard material lowers envNode as its environment radiance (PRD-794)

three's NodeMaterial.envNode replaces the environment map's radiance. The engine had no such slot,
so `material.envNode = ...` became an ordinary JS property and the assignment vanished without an
error. The Strata ocean builds its whole sky reflection in that slot (render/ocean.ts), so on the
owned engine the sea had no indirect light and rendered black next to the three.js lane.

The standard shader now reads the slot when the material has no environment map: the node is the
radiance, its irradiance is that radiance times pi (EnvironmentNode.js:90-107, whose roughness
context a plain node ignores), and envMapIntensity scales both. The binding registry and the catalog
publish envNode on the node materials, so the browser facade forwards it; native-registry.json is the
snapshot tn-native-engine-registry-dump prints. The roots array follows MaterialNodes::pointers()
through shader::kMaterialGraphRoots, so the next slot does not repeat the 16/17 mismatch.

Proof: native_engine_material_env_node (new), native_engine_material_mrt_slot,
native_engine_registry_snapshot, all 19 native_engine_material_* cases green; pnpm exec vitest run
packages/three-native/__tests__ 48 files / 511 tests green; examples/strata-terrain-preview 31 files /
241 tests green. The coastal proof itself stays open: with the slot lowered the sea still reads about
ten times darker than the three.js lane at the same pixels (0,18,26 against 149,168,178 at 1920x1080
x1700 y600), which keeps verify-ocean.mjs's water region under its 12 % gate.

* build(runtime-native): drop the aux-fragment test entry until its source lands (510f336ee swept it in from an unfinished lane)

* fix(runtime-native): a masked MRT draw still writes its own slot, and the scene pass feeds it

The water proof's sea masks the named normal target and writes its diagnostic slot; WebGPU's
write mask for every extra colour target copied `color`'s mask, so the masked draw silenced its
own slot and the attachment read back zeros on a sea that filled the screen. Each extra target
now follows its own write bit and the material's mask (attachment_mask.h, tested), and a draw
that names a slot but writes no value for it refuses by name (TN_POST_AUX_REFUSED) instead of
writing zero.

A frame whose MRT names a custom slot holds one colour target more than the normal target, so
the small normal-only fragment no longer fits it (aux_fragment.h, tested): WebGPU refused that
pipeline outright ("targets[1] ... is not zero") before the frame drew.

On the facade side: updateBefore runs at each renderer render (a standing frame the runner asks
for still hears it); a material's mrtNode writes only the custom slots, since the scene pass
draws the reserved targets itself; and readRenderTargetPixelsAsync returns three's own copy
layout (rows padded to 256 bytes, last row trimmed), which is the stride a caller like the
proof's classifier indexes.

Proof, on the browser Wasm engine built in this worktree (nvidia/turing, 1920x1080): the coastal
water proof publishes an available observation at the horizon-sea step -- area 63000, fraction
1.0, RGBA16F, 4 samples, 3 attachments -- and ?world=tundra draws with no console error and no
reflector refusal. The waves/sun capture comparison still measures 17043 blue-water pixels of
the 22500 it needs, because the ocean's near-shore foam/alpha does not appear on this engine yet.

* fix(strata): a build ships every file the bake's prop manifests name (PRD-792)

`generateBundle` still listed `<world>.placements.bin`, which the bake
stopped writing, and neither the build nor the native staging carried the
per-bucket prop packages the game fetches. Both now read
`scripts/world-files.mjs`, which derives the set from each world's own
`props.json` and fails the build with `TN_STRATA_BUILD_MISSING <file>`.

* docs(prd): the batch's web-playtest boxes carry this head's loop and abyss numbers; the terrain-web half stays open on the water proof (PRD-791, PRD-792, PRD-798, PRD-799)

* docs(prd): the five-world judge passes the horizon and the forest; PRD-795 is done (PRD-795, PRD-798)

* fix(runtime-native): a draw writes the environment intensity its program reads (PRD-794)

three's materialEnvIntensity (MaterialProperties.js:23) is material.envMapIntensity when the
material carries an envMap, else scene.environmentIntensity, and a plain MaterialNode.envNode's
radiance scales by that same number. The standard shader lowered the envNode branch with the
uniform, and the upload sat inside `if (item.envMap)`, so a material whose whole sky is envNode
and which owns no envMap (the Strata ocean, render/ocean.ts) read 0 and drew black: the coastal
sea was ten times darker than the three.js lane and the near-shore foam had no light to show in.
The render database resolves the factor for every draw, so the upload now keys on the program
that reads it and a null slot stays a no-op.

Proof: native_engine_render_database env_node_without_env_map RED first (a sphere lit by a red
envNode alone reads 0,0,0) then GREEN (centre 33,0,0 at normal incidence, rim 56,0,0 at grazing);
scene_environment, lit_scene, scene_fog_node, frame_slab_is_per_program, steady_state and
depth_node_reaches_every_material_program green on the same build. Browser Wasm engine built in
this worktree (nvidia turing, 1920x1080): verify-ocean.mjs --proof=water exit 0 on
playtests/water-proof.playtest.json artifacts, waves waterPixels 40332 (gate 22500) changedRatio
0.492, sun 43198/0.976, sheltered-water area 63000 fraction 1.0, 0 console errors; the same pose
on both lanes reads box mean RGB 115/138/135 against three.js 116/141/138 (was 78/95/78).

* fix(runtime-native): an aux bundle follows its pass's colour-target count (PRD-791, PRD-798)

The aux render bundle's key carried the named-target mask only, so a frame
that added a custom MRT slot (the water proof's sea) could replay a bundle
recorded for one colour target into the pass that now holds two. Frames
during a held presentation draw nothing into the pass, so no row changed and
the key stayed equal; WebGPU then refused the bundle and TN_WASM_RENDERER_LOST
stopped the renderer on the coastal scene.

The key now carries the custom-slot count beside the mask. Proof: new
aux_bundle_key_test.cpp red before the fix (CHECK keyOf(1,0) != keyOf(1,1)
failed) and green after, g++ -std=c++20 -I packages/runtime-native/src.

* docs(prd): the batch's web playtest boxes carry this head's real runs and the engine bundle red behind them (PRD-791, PRD-792, PRD-798, PRD-799)

* fix(strata): a world's ground rock values are pinned per biome, and the coast's do not read the desert's (PRD-791)

A report claimed the coast's ground rock reads the desert's prop tints. It does not reproduce at
this head: a field-by-field comparison of BIOMES[world] at 0d4d08596 against HEAD, all five
worlds, differs in one field, temperate.stoneTint [0.48,0.44,0.39] to [0.44,0.46,0.42], and that
is the value both lawn worlds' ground already resolved, so no pixel moves.

New pin: "a biome's ground rock" holds each world's ground formula (plain, crag or mesa), its
grey mix and tint, its distant tone and share, and its propStoneTint. Setting the coast's group
to the desert's ([1.16,0.82,0.55], 0.25, share 0.32) fails the run named coastal.

Proof: biome-values 5/5; example suite 242 passed, 3 skipped in 31 files; typecheck 0; the
evidence-budget and check-doc-links specs 21/21.

* fix(three-native): a texture readback asks for a CPU-backed canvas, so assets.texture uploads its pixels

* docs(prd): the final five-world judge passes the look and the water; PRD-794 is done (PRD-791, PRD-792, PRD-794, PRD-795)

* fix(runtime-native): the threaded web engine links dlmalloc, so a Strata world switch stays under the 4 GB heap (PRD-791)

mimalloc over emmalloc kept freed multi-MB blocks out of reuse: one Strata world held 2.5-2.8 GB and the
desert load hit the 4 GB cap in 4 of 4 runs. A pool worker died there and the main thread spun on the join.
dlmalloc ends forest, coastal, alpine and desert at 1.3 GB. FB1's CPU frame gain (-4% to -7%) is given back.

A job item that throws now reaches the caller at the join instead of ending its worker inside the job, and
a glTF load names its failure (TN_WASM_GLTF_LOAD_FAILED).

* fix(three-native): a texture readback copies any canvas into a CPU-backed canvas, so a canvas without a 2D context uploads its pixels

* fix(strata): thin-needle transmission is one albedo-scaled, occlusion-gated term on both engines, so the forest's canopy stops glowing over three

* fix(runtime-native): unlit normals, web job pool catch, V8 mrt slots, envNode precedence and alphaTestNode match three (PRD-791)

- buildBasic reads() returns the first graph hit again: an unlit graph that reads normalView gets its normal varying
  (regression from the batch; new test basic_normal_view).
- An mrt-only material keeps its graph sources.
- tn_engine_foundation compiles with -fwasm-exceptions on Emscripten, so JobPool's item catch exists on the web; the
  inline path also runs every item and rethrows the first failure.
- The V8 mrtNode setter skips the pass's own slots (output), as the browser facade does.
- A material envNode wins over scene.environment, as in three NodeMaterial.setupEnvironment.
- material.alphaTestNode is the cutoff in the colour and shadow passes (three r187 NodeMaterial); a masked caster whose
  node reads another texture fails closed (TN_NATIVE_SHADOW_ALPHA_UNSUPPORTED).

Native: 32 of 33 related tests pass; native_engine_camera_cull_keeps_bundle is red on the committed HEAD sources too
(pre-existing).

* fix(three-native): native readback returns tight rows like three 0.187.0, and each glTF side file's scratch is freed after its copy

- readRenderTargetPixelsAsync no longer pads rows to 256 bytes; the pinned three's copyTextureToBuffer returns tightly
  packed rows. The Strata water proof reads the tight layout, and its spec feeds tight fixtures.
- loadGltf frees each side file's Wasm scratch right after tnw_web_gltf_file copies it, so a load peaks at about
  N + the largest file instead of 2N (new spec gltf-files-scratch).
- A canvas readback copies only when the canvas cannot be read directly, and a canvas with no 2D context is covered.

* fix(terrain): terrain_generate water rows reshape the cooked world, and a failed terrain_biome_new cook leaves no folder (PRD-793)

- The water override now changes the cooked ground and water.json, not only the echoed result; the spec reads water.json.
- terrain_biome_new removes the biome folder it made when the cook fails, so the name can be retried.
- The fail-closed asset test reaches the unknown-asset check and asserts its message.

* test(core): the adaptive-LOD kill switch test advances its clock, so it fails when the switch breaks (PRD-799)

Also: the isDevLaunch doc block sits on isDevLaunch, a world-gpu-flags test title states what it asserts, and the
Basis resolver doc block sits on resolveBasisTranscoder.

* fix(strata): the editor view with an ocean builds the coastal collar; drop the dead check-temperate script and groundSkyLight flag (PRD-791)

- An editor view with an ocean took the forest collar; it now takes the coastal one (new spec editor-collar).
- scripts/check-temperate.mts could not load (its imports were deleted by the batch); its checks live in the far-forest,
  live-cover and prop-lod specs, so the script and its AGENTS.md line are removed.
- groundSkyLight was a dead IBiome flag that stated a false behaviour; removed. Stale doc blocks in biomes.ts and
  river.ts are fixed.
- A prop whose record moves it inside its draw reach draws again with no camera move (new prop-streaming case).

* docs(prd): PRD-791..799 status lines match their boxes, stale counts rerun, judge frames linked; PRD-798 AC-1 reopened

An evidence audit (one auditor per PRD) found no false tick except PRD-798 AC-1: two existing world specs changed an
assertion after the split (world-impostor-surface, world-cell-prewarm-gate), so 'no existing world spec changes' does
not hold and the box is open again. Status, Priority and Phase lines of the done PRDs said PARTIAL or NOT STARTED;
they now match the boxes. Single-spec counts are rerun at 95d804746 beside the historical numbers.

* test(strata): the Strata specs typecheck: real fixture types, imported types and a typed raycast mock

* test(core): the collar spec typechecks: the double-sided mesh owns a material it sets

* fix(core): the dev-tools module references the import-meta declaration it reads, so a consumer without hot.ts typechecks

* docs(prd): close PRD-791, PRD-792 and PRD-799: the Strata web playtest loads all five worlds (59 of 63, the 4 reds are PRD-585 budgets) and the ocean proof passes; PRD-798 AC-3 ticked

The coastal mrtNode red that held every Strata half is gone. Runs 3 to 8 lost the page to another
agent's global pkill of Playwright browsers, now fixed in that lane. PRD-798 stays open on AC-1
and the AC-4 visual judge.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(prd): PRD-805 and PRD-806 track the 67 verified PR #483 epic review findings; PRD-798 status names AC-3's Strata half closed

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(core): an unchanged scheduled instance history is not re-uploaded every draw (PRD-805 E08)

* fix(create-threenative): the arena shadow test asserts the clip ladder the node renders (PRD-805 E36)

The test itself was wrong: VirtualShadowNode inserts a level wherever a step is wider than 2x
(UE's clipmap rule, pinned by packages/core/__tests__/virtual-shadow.spec.ts), so
`options.clipExtents` is the expanded ladder, not the authored [arenaExtent, 48, 160]. The test
still pins the kit's own windows, each level's step and the inherited phone map size.

* fix(scripts): the forest proof gate anchors the starter's current readiness check (PRD-805 E69)

READY_ANCHOR still named `this.#sky === undefined`, which PRD-633 removed from
the starter's enter guard, so every insertWorldLoad call threw
TN_FOREST_ANCHOR. The anchor and its replacement now share one READY_CONDITION
constant, so they cannot drift apart again.

The spec pinned the gate's old emitted text verbatim; that expectation was
stale, not looser: it now asserts the exact line the gate emits, still with
toContain and the same strictness.

* fix(scripts): drop the removed blender-mcp source from the release fixture (PRD-805 E70)

* fix(create-threenative): record terrain_biome_new in the terrain MCP surface snapshot (PRD-805 E71)

* fix(strata-terrain-preview): document the epic's six new bake scripts and playtests (PRD-805 E72)

* fix(core): keep a rejected warm-up compile out of the held launch completion (PRD-805 E21)

* fix(scripts): accept the reviewed devDependency delta in the startup gate (PRD-805 E64)

* docs(prd): close PRD-805: the 8 high epic review findings are fixed; the lockfile carries the restored three patch's hash

pnpm hashes a patch file with SHA-256. The E08 fix restored the instance-history reuse hunk in
packages/core/patches/three@0.187.0.patch, so pnpm-lock.yaml moves from fb8ba291 to 710526eb and
pnpm install --frozen-lockfile exits 0. pnpm typecheck and pnpm lint exit 0; the 9 touched spec
files pass 155 of 155.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(assets): the terrain marker scan no longer gates GLBs no pass in the chain reads (PRD-806 E09)

The scan wrapped every unpackGlb throw in TN_ASSETS_MODEL_UNREADABLE before the pass
list was fixed, so a `models: "none", textures: "none"` build — the built-in chain is
empty and nothing parses a model — hard-failed a GLB the pipeline used to copy through.
An unpack failure now means "not a cooked terrain world": the model pass (when one runs)
and the unconditional health report own the actionable malformed-model error, as
`sourceModelExtensions` in compile-receipt.ts already states.

compile.spec.ts's terrain test asserted that old behaviour (`passes: []` on a corrupt
world GLB must reject). That assertion only ever passed because of the scan being the
readability gate, which is the finding, so it now pins the corrected contract instead:
the bytes copy through with an empty pass list. The native-package fixture's
padEnd(84) -> padEnd(88) relaxation stays; the 85-byte JSON chunk shape is covered by
the new test.

* fix(assets): a cooked terrain world hashes the pipeline version and its own pass config (PRD-806 E11)

* fix(strata-terrain-preview): a prop drag restream no longer cancels the in-flight terrain update (PRD-806 E17)

* fix(terrain): an orthographic observation extent measures the eye to the target on every axis (PRD-806 E39)

* fix(terrain): the scatter picker scatters the asset it shows (PRD-806 E40)

* fix(terrain): select camera rows by the class the renderer sets (PRD-806 E41)

* fix(ci): rain's template-nonvisual leg no longer takes a CPU admission slot (PRD-806 E52)

* docs(prd): PRD-806 ticks the 7 medium findings lane B fixed; E29 is not real, E62 waits for the visual judge

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(three-native): the develop merge keeps a material's custom MRT slots beside the scene pass targets

* test(capability): the swimming alias gets its predecessor corpus row

* fix(scripts): the startup gate derives workspace package names from workspace-packages.ts (PRD-805 E64)

* docs(core): instanceRandom carries the @example the capability docs gate requires

* fix(three-native): the RenderTarget waiver sits on the suppression the quality gate reads

* fix(native-engine): a copied EventDispatcher starts with no listeners (PRD-806 E49)

* refactor(core): the virtual shadow level and its stock node move to virtual-shadow-level.ts

* fix(three-native): the TSL export names carry the quality waiver their suppression needs

* fix(runtime-native): an uncapped present proves it did not wait by the owner's block count, not a 5 ms wall clock

* fix(native-engine): a retained row spans its draw's key words, not the pass layout's

* chore(runtime-native): regenerate the native coverage report after the merge

* fix(strata): the hidden capsule names why it overrides the engine's visibility default

* fix(core): assert the concurrent spawn waiters clear their deadlines (PRD-806 E01)

* fix(strata-terrain-preview): a failed horizon fetch no longer poisons every later world load (PRD-806 E02)

* fix(strata-terrain-preview): the kit proof empties a reused artifact directory again (PRD-806 E03)

* fix(strata-terrain-preview): the swimmer enters the sea the world draws (PRD-806 E05)

* fix(strata-example): name the wildwood stage arm in the comments that list the arms (PRD-806 E06)

* fix(wasm-engine-boot): the normal-node scenario fails a run that fell back to the geometric normal (PRD-806 E07)

* fix(strata-terrain-preview): run the shared water regressions from the test lane (PRD-806 E13)

* fix(strata-terrain-preview): drop the forest skip flag whose false arms its own throw made unreachable (PRD-806 E14)

* fix(core): the dither-partition test reads the mask instead of plain JS numbers (PRD-806 E16)

The block at :440 scanned 1024 numbers per mix against each other and never touched
worldLodMaterial, so a fadeOut whose comparison used a different mix node (a real
double-draw band) kept it green. It now asserts both sides of a fade read the same
noise and mix node, which is what makes every dither pixel exact.

The verifier's second claim holds only for that loop: changing '<' to '<=' was
already caught by the operator check above, which stays.

* fix(strata-terrain-preview): name ?farForest=on as the override the flag actually reads (PRD-806 E18)

* fix(strata-terrain-preview): delete the ?massif=before knob and the survey it swapped in (PRD-806 E19)

`setHorizonSurroundings` read `?massif=before` off the page URL and installed the
bake's `forest-before` survey in place of `forest`, so a query parameter moved a
shipped scene's far silhouette with no override report. The reader and the bake
entry that fed it are both gone; the new spec installs a survey with a
`forest-before` key under that URL and asserts `forest` survives.

The bake's own golden continuation digest moved with it (the recipe no longer
writes a sixth survey), so the pin is restated at its newly measured value.

* fix(strata-terrain-preview): sway()'s doc says the shadow casts the rest pose, and why (PRD-806 E28)

* fix(strata-terrain-preview): delete the ?peaks and ?meadowBase A/B knobs (PRD-806 E30)

`meadowCover` read `?meadowBase=off` and the collar's shader read `?peaks=before`,
each swapping a shipped scene's look from the page URL with no override report. Both
readers and their never-default arms are gone, so the default arm is what ships:
the meadow carpet measures the same 22,796 placements, and the shader builds the
same node graph (peaks was `collar !== undefined` on the default path).

* fix(runtime-native): a missing MRT slot refuses one read instead of killing the web host (PRD-806 E42)

* fix(terrain): a 409 rebase the external writer already landed settles instead of rejecting (PRD-806 E45)

* fix(scripts): the startup gate compares Three patch digests and records the arm's own patch (PRD-806 E65)

* fix(terrain): the cook reports the water it floods, not the rows it was asked for (PRD-806 E66)

water: biome.water echoed the request's rows; water.json was written from the evaluated state and could not name an ocean, so a reply could claim water the world did not have (a landform or spec cook floods nothing) and a flooded ocean read back as a lake. The reply now maps state.waters/state.rivers, and water.json records each body's kind.

The savanna spec-cook assertion pinned that echo (a rollingHills preset has no water layer, so its ocean row floods nothing); it now asserts the reply and file report no water, which is what that world contains.

* fix(native-renderer): accept a legal eight-attachment aux pass (PRD-806 E74)

* fix(native-renderer): a volume texture carries the usage a texture copy reads (PRD-806 E75)

* fix(core): restore the frame-buffer texture name only once, after overlapping compiles (PRD-806 E15)

* fix(create-threenative): stop the terrain-kit symlink walk at the first existing folder (PRD-806 E22)

* fix(core): make the bundle row-cap test really cross 1,024 rows (PRD-806 E23)

* fix(core): exercise the depth-buffer width check without a resize event (PRD-806 E24)

* fix(core): endFrame's doc says a hitch still returns its split (PRD-806 E25)

* fix(core): scale a zero-area kept card about its own centre (PRD-806 E26)

* fix(playtest): a worker relaunch no longer reports the discarded attempt's cpu profile (PRD-806 E27)

* fix(engine-mcp): drop a stop word's inflections too, not just the word (PRD-806 E31)

* fix(physics): a forced trunk gets a positive capsule radius (PRD-806 E32)

* fix(strata): the spruce terminal minimum is taken by the loader, and the gate test drives it (PRD-806 E33)

* fix(strata-terrain-preview): bound each invalidated shadow region by the instance's own span (PRD-806 E34)

* fix(strata-terrain-preview): river rocks must sit on the river's bank, not just be counted (PRD-806 E35)

The test was the defect: the river-rock half asserted only a count, so rocks
anywhere passed. It now measures each rock against the drawn ribbon reach
(WATER_RIBBON_REACH widths either side of the river's centreline).

* fix(core): size the chunk shadow proxy index pool from the shadow ladder (PRD-806 E37)

* fix(core): keep a file URL's triple slash when resolving terrain splat layer maps (PRD-806 E38)

* fix(native): state the parity bound the first-frame shadow row enforces (PRD-806 E43)

The test was named "tolerates one-level rasterizer rounding and no more" while
it pins `{ pixelMismatchRatio: 0.02, perceptualDeltaE: 3.0 }`, so the claim was
false: 0.02 is 1.6x the 0.0125 llvmpipe measurement, and a synthetic pair with
2% of the frame drawn fully black scores 0.02 / 1.29 and passes. The test is
what is wrong here, not the registry value: the pixel bound was widened to
0.0125 plus headroom by 94357ebff with the llvmpipe evidence, and the 3.0 deltaE
is the bound `32-animated-shadows` and `33-virtual-shadow` share, not a rounding
claim. Tightening a desktop-gated GPU row cannot be verified in this lane, so
the name and the comment now say what the row enforces instead of more.

* fix(assets): report a GLB with no JSON chunk where the container shows it (PRD-806 E47)

* fix(terrain): wait for the export cook to start instead of a fixed sleep (PRD-806 E50)

* fix(three-native): release an object-valued uniform with the value it reads (PRD-806 E53)

* fix(terrain): let the example guard see path-string reads, not only imports (PRD-806 E54)

The finding is a defect in the test: the guard matched import syntax only, so landforms.spec.ts (new URL("../../../examples/...")) and kit-assets.spec.ts (join(root, "../../examples/...")) reached into the preview example invisibly. The guard now matches any quoted literal naming examples/, and the two readers it finds are declared by name so a third one fails the list.

* fix(terrain): describe the placement record as position, quaternion and scale (PRD-806 E55)

* fix(native-renderer): a mirror's pass is reused only while its reflector lives (PRD-806 E58)

* fix(scripts): describe terrain peers as shipped or external (PRD-806 E60)

* fix(scripts): resolve the terrain reply only on a newline-terminated line (PRD-806 E61)

* fix(terrain): cook both AC-3 arms as tool calls in a fresh process (PRD-806 E63)

The savanna cook was measured inside a spawned MCP child and the forest baseline by cookKit in the vitest worker, so the two numbers came from different contexts (cold code vs the already-warmed cooker from the cooks earlier in the file) with no margin. The baseline now goes through terrain_generate, the same kind of tool call, so both arms are a first cook in a fresh process; the assertion itself is unchanged.

* fix(core): the frame-buffer name test reads the pass through its guards

The test arrived written against a head where `setOutputNode` had one
signature and `renderTarget.textures[0]` was not index-checked: on the new
head it returns `void | IRenderOutputInstallation` and a texture read is
possibly undefined, so `pnpm typecheck` failed on the file the cherry-pick
added. The guards are the idiom this spec already uses for a missing
installation receipt.

Red-green: `tsc --noEmit -p tsconfig.json` reported TS2532 at 620:7 and
TS2339 at 631:17 before, clean after (the two remaining errors are the
action-rpg template's stale `dist/spells` resolution, unchanged at base).

* fix(physics): name a rolling ball in RigidBody3D's capability situations

* chore(runtime-native): regenerate the native coverage report for the PRD-806 low fixes

* test(core): the look constraint allow-lists the shadow level split out of virtual-shadow.ts

* fix(native-engine): the texture-copy test builds only where the host renderer exists

* fix(assets): the terrain cook loads @threenative/terrain when it runs, so the CLI loads without core

* chore(core): rebundle the engine MCP server for the stem stop-word filter

* fix(conformance): the virtual-shadow proofs keep their three authored levels by the named override

* chore(runtime-native): regenerate the native coverage report for the core-only test gate

* test(three-native): the MRT smoke builds its mrtNode through the facade's mrt({...}) record

* test(native-engine): the pooled image decode expects pooled items only where the pool has workers

---------

Co-authored-by: Claude <noreply@anthropic.com>
…mpiles out: cornell 4 to 10 of 12 rows (PRD-646) (#610)

* docs(prd): PRD-646 reads cornell's specular input from the level: 0.5, so F0 0.04, the harness's value (PRD-646)

* docs(prd): PRD-646 names the harness's energy loss: Unreal compiles energy conservation out (PRD-646)

* feat(unreal-look): the harness reads the project's energy switch, and cornell gains six rows

Unreal compiles energy preservation out for this project, so the harness's twin
stops subtracting a loss Unreal does not draw. `ENERGY_CONSERVATION` is the
project's switch, not the shader's default: `r.Substrate.ProjectGBufferFormat=0`
keeps the blendable Substrate GBuffer
(UE 5.8.3: Shaders/Private/ShadingEnergyConservation.ush:20-31,
RenderCore/Private/RenderUtils.cpp:2226-2233,
Engine/Private/ShaderCompiler/ShaderCompiler.cpp:4093), so
`ComputeEnergyPreservation` returns 1. The albedo scale is now
`preservation / (1 - F)` with F0 and F90 read from the material the engine
draws, so three's pointwise loss cancels and no loss is added; `?diffuseEnergy=three`
still draws the BEFORE arm.

The twin keeps its source's class, so `?specular=0` and `?specular=f0` stay
ablations under the term, and the owned engine, which bridges no
`MeshPhysicalNodeMaterial`, refuses the combination by name
(`TN_DIFFUSE_ENERGY_SPECULAR`) instead of drawing the term's frame for both
knobs.

`cornell` L1 4 of 12 rows to 10 of 12; `sunlit-room` 40 of 40 to 39 of 40
(`sun15-window` Floor, dP50 0.011 against 0.010). No constant was added.
AC-2 and the `cornell` box stay open with the numbers on them (PRD-646).

* docs(prd): PRD-646 round 13 visual judge, NEUTRAL on 8 sheets (PRD-646)
…rs, a router AGENTS.md (#619)

* docs(PRD-826): ship no junk to users, inventory of kits, tarball and repo root

* docs(PRD-826): agent decisions on the delegated calls (CHANGELOG option 1, delete stale audits and kit READMEs, keep ponytail and PERFORMANCE-LEARNINGS)

* chore(PRD-826): untrack personal files, move plans into the PRD folder, bring the CHANGELOG current

* chore(PRD-826): the scaffolder writes the MCP host configs, the npm package drops maintainer notes, kits lose READMEs and kit.json

* test(PRD-826): scaffold, tarball and repo-root allowlists keep the junk out; PRD-826 done

* docs(PRD-825, PRD-827): the kit AGENTS.md becomes a router to the engine, its MCP servers and conventions; kit sessions load the servers, watch gameplay and search before the first new file

Interview decisions (owner, 2026-11-05): router plus a one-line convention index,
generated from shared fragments and kit.json, proven by a paired kit:loop A/B,
enforced by a discovery hook gate. Evidence from four read-only scouts and the
Starlance session transcripts.

* docs(PRD-825, PRD-827): order with PRD-826 (MCP config proof, scaffold hash re-pins)

* feat(PRD-827): a kit session approves its MCP servers, and the animation server has one version

The scaffold .claude/settings.json names the six core servers in enabledMcpjsonServers, so an
interactive Claude Code session loads them with no approval step. The animation server pins
(servers.mjs, serverInfo, the captured tool list) move to the published 0.1.8, and
prepare-release keeps them equal to the package. Scaffold tree hashes re-pin once at the end of
PRD-827.

* test(PRD-827): re-pin the sixteen scaffold tree hashes for the approved MCP servers

* feat(PRD-827): every capability search returns game references, and engine_watch_reference shows gameplay footage

engine_search_capabilities names the reference tools and attaches up to three reference stubs on
every search, whatever the wording. engine_watch_reference fetches the released watch.mjs, runs
it into a tmp folder and returns the contact sheet, frames and watch.json; a missing yt-dlp or
ffmpeg, or an unreachable watcher, fails closed with the fix.

* feat(PRD-827): a discovery gate blocks the first new src/ file until the engine was searched; PRD-827 done

The scaffold ships .claude/hooks/discovery-gate.mjs on Claude Code (PreToolUse Write|Edit|MultiEdit)
and Codex (PreToolUse apply_patch). Each prompt records its time; a new src/ file waits for an
engine_search_capabilities line in .threenative/engine-mcp.log newer than that prompt, and the
block names the MCP call and the call.mjs form. TN_DISCOVERY_GATE=off opts out once per session.
Also fixes the scaffold-mcp engine tool list for engine_watch_reference, and re-pins the sixteen
scaffold tree hashes.

* feat(PRD-825): kit-loop records what a cold session did before its first src/ write

pnpm kit:loop record now also reads events.jsonl (the session as stream-json) and stores MCP
calls before the first Write/Edit under src/, whether the capability and reference searches
were among them, and call.mjs calls made while the engine server was loaded. A session with no
init event or no tool events fails closed.

* feat(PRD-825): one convention registry with an anchor page and kit notes pages; a spec fails a row with no override or page (AC-2)

* feat(PRD-825): the 16 kit AGENTS.md become generated routers of at most 120 lines and 1,200 words; flow, MCP table, convention index, one registry spec (AC-3, AC-4, AC-5, AC-6)

* feat(PRD-825): the capabilities skill follows the router flow, an existing game searches for each change, and the engine instructions and ponytail hook point to it (AC-7)

* fix(PRD-825, PRD-827): name engine_watch_reference in the root docs; type the kit-loop spec inputs

* fix(PRD-825): capability-recall sources name the router headings; tick AC-2 to AC-7 and record the A/B recipe

* docs(PRD-825): a convention missing from the templates' convention index does not exist

* fix(PRD-825): K1 sums every result event of a resumed session; record the A/B (ledger rows 5-12), tick AC-1 and AC-9, AC-8 open on K1
The #619 conflict resolution kept the animation server pins at 0.1.8 while the 0.3.12 cohort
bumped packages/animation-mcp to 0.1.9, so prepare-release.spec 'keeps one animation server
version' went red on develop.
… accepts the BEFORE late-counter diagnostic

The 88b988c baseline ships three 0.185.1 and an older three patch, so the identity check failed
before any run. 972d1e3 (#483) shares the candidate's three patch and dependencies. A failed
BEFORE late counter also reaches the runner diagnostics, which the gate rejected although it
allowed the counter itself.
…d shadow levels when it flips visibility

A world's main-pass batch casts nothing and hides when it empties. #pollCasters asked about
every caster-flag change, so the Strata fallback forest emptying five spruce main batches
~0.6 s after the reveal redrew every virtual-shadow level (reveal shadowInvalidated 5 -> 0).
…he plan says why when it is not

No CI runner has a GPU: TN_RUNNER_GPU names a runner that is not registered and its head pin is
from 2026-10-18, and the tn-local containers get no GPU device. Hosted SwiftShader allows 16
sampled textures per stage where the Strata terrain binds 24, so the job could not pass on any
run since #483 added it. The scope step now receives the GPU branch of the strata runs-on and
exempts the job with that reason (owner choice, 2026-11-06).
…b reaches a GPU runner

On hosted SwiftShader the forest reveal logs 490 console errors from the 24-sampled-texture terrain
(limit 16) and fails its reveal assertions, so the contract could not pass there. The steps read
the job's GPU route and write the skip reason to the step summary; count-loc --check still runs.
…ys owe the same jobs

native-platforms replays the plan with --validate-plan in a job that has no TN_CI_STRATA_GPU, so
it rebuilt strata as required and rejected the plan (CI_SCOPE_INVALID_PLAN: jobs does not match
the selected check families). The plan now carries strataGpu: false and every replay reads it.
…ption

validateEventPlan required every job but template-engine-cost on a promotion, so the integration
inventory and ci-required rejected the #620 plan (CI_REQUIRED_QUALIFICATION_MINIMUM). It now also
accepts strata when, and only when, the plan records strataGpu: false. Replayed the real #620 plan
locally: red before, green after.
…ch tests stay in a registered folder

The temp-dir guard named references.ts (#619) as an unregistered creator, and the unit shard that
ran the watch tests grew the suite temp namespace by one folder: a failed watch kept its folder,
and the success test never removed the one it got back. The tool now removes the folder when the
watch fails, the tests point TMPDIR at a registered folder, and the core bundle carries the fix.
… after the route leaves it

The route flies 52 m past the 256 m fixture to prove eviction. Since PRD-762 (#483)
no terrain tile wholly off the map is meshed, so a gaze that lands past the edge framed
only sky: CI integration / capture failed walk-28 at bright ratio 0.01689. The camera
now yaws only as far as it must to keep its line of sight on the map; walk-01..20 are
unchanged and the route still ends at [180, 24, 0].
…nd desktop dither alike

The grain hash fract(sin(dot(p, k)) * 43758.5453) multiplied float rounding by 43758: SwiftShader
Chrome and the lavapipe desktop host drew uncorrelated grain, and 55-57% of every dithered frame
differed by 1-2 levels (native-platforms desktop parity, run 42263498963: world-cells-proxy-stream,
world-cells-edited-proxy-stream, compressed-texture-preparation). three's integer hash() of the
pixel index draws the same grain on every backend.
…tolerance for one-level backend rounding

After the integer dither hash (25bed77), the CI replica (SwiftShader Chrome vs lavapipe desktop) leaves
1.25-2.17% of pixels one level apart, desktop darker, plus silhouette rasterization ties. lut-grade (0.04)
and world-terrain-inline-hash (0.06) already carry the same backend rounding reason.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Desktop parity fix on develop: 25bed7765 and 3a24645dc

Cause: the filmic output dither used fract(sin(dot(p, k)) * 43758.5453). That hash multiplies float rounding by 43758, so the SwiftShader web lane and the lavapipe desktop lane drew different grain. As a result, 55–57% of pixels differed in world-cells-proxy-stream, world-cells-edited-proxy-stream and compressed-texture-preparation.

Fix: the dither now uses three's integer PCG hash(), so every backend draws the same grain. The CI replica result:

Case Pixel mismatch before After
world-cells-proxy-stream 0.5725 0.0161
world-cells-edited-proxy-stream 0.5717 0.0165
compressed-texture-preparation 0.5518 0.0125

Remaining gap: the 4 cases still differ by one level of backend rounding, and desktop is darker. These cases now use a 0.03 tolerance with the measured reason. lut-grade (0.04) and world-terrain-inline-hash (0.06) already use the same reason. virtual-shadow-first-frame is at 0.0217, and its 11 differing pixels are ties on the sphere silhouette.

Visual judge (a fresh read-only subagent): NEUTRAL, with an IMPROVEMENT on SwiftShader, and no regression.

  • No banding, no tint, and no structure in the new grain: the FFT peak/median is about 28, which is the white-noise floor. The old grain on SwiftShader had periodic peaks at about 9700x the median.
  • Before and after differ by at most 2 levels.
  • Pixel differences between SwiftShader and NVIDIA dropped from about 65% to about 10%.
  • Adapters: google/swiftshader and nvidia/turing. Each arm ran twice, and the two runs matched bit for bit.
Before After
NVIDIA
SwiftShader

Judge crop sheet:

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant