Skip to content

Promote develop to main: doctor series fix, scaffolder 0.2.8 - #400

Closed
jonit-dev wants to merge 29 commits into
mainfrom
develop
Closed

jonit-dev wants to merge 29 commits into
mainfrom
develop

Conversation

@jonit-dev

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

Copy link
Copy Markdown
Contributor

Promotes develop to main after the 0.3.4 cohort was published.

Merge with a merge commit, not squash or rebase.

jonit-dev and others added 29 commits October 1, 2026 21:13
…pport, sandbox demos (PRD-469, PRD-473) (#382)

* feat(core): add SnowField, the ICEFIELD snow abstraction

Extract the supplied ICEFIELD study's snow math into a public, renderer-independent
@threenative/core/world abstraction and give Heightfield the smallest validated
dirty-region update contract it needs.

- Heightfield.updateHeights / version / refreshGeometry: one canonical surface that
  queries, rendered geometry and collider export all read.
- SnowField: indentation, bank, compaction and disturbance channels over that field,
  load-dependent penetration (pressure = load / area), bounded sparse recovery, depth
  changes and reset. Contacts take any game-authored footprint, not just a boot.
- snowDiscFootprint: a circular contact for spheres, wheels and probes.
- Source-derived regression fixture produced by the supplied document's own code
  (lines 85-193, sha256 85b1708...02f7); the extraction replays it to within 1e-9.
- AC-1 and AC-2 ticked with evidence in PRD-469.

Refs PRD-469

* docs(prd-469): attach the Phase 1 contact-shape figure

Panels are crops of one SnowField after six kinds of contact: boot prints, a rolled
sphere's continuous track, and a dropped crate plus a dragged plank. Rendered straight
from the public API; the whole figure is the extracted field's own channel output.

Refs PRD-469

* docs(prd-469): record Phase 1 complete, Phases 2-3 unstarted

Refs PRD-469

* docs(rain): plan the Tempest storm starter kit

* docs(rain): attach reference captures and require capability discovery

* wip(rain): Tempest kit scaffold, render, UI, audio extraction and native audio support (PRD-473, unverified)

* wip(snow): persistent-contact seam and attachSnowPhysics draft (PRD-469 Phase 2, unverified)

* fix(rain): sample the cloud pass right side up and drop the scratch probe (PRD-473)

The generator's pass-texture flip rule matched `uSky.sample(x)` but the transpiler
emits `uSky.sample( x )`, so the regenerated world shader never carried the flip and
the sky was mirrored. The rule now tolerates the spacing; world-shader.ts is
regenerated. Headed WebGPU boot playtest passes and the capture shows the ceiling
above the horizon. tools/.tmp-probe.mjs was scratch and is removed.

* feat(physics): solved-contact snow binding on web Rapier (PRD-469 Phase 2)

The wip seam looked colliders up by Rapier handle, but seam ids are logical
body ids, so readContacts never saw a contact. Resolve through the registry.

- readContacts: one record per solved manifold (mean solver point, summed
  normal impulse), total count returned so the caller grows and re-reads
  instead of losing a contact; sleeping bodies report nothing.
- attachSnowPhysics: one stamp per body per step centred under the body
  (not on the contact centroid, which smeared a rocking box's face),
  duration = fixed step, load = impulse / step (observe() names it an
  estimate); box/capsule prints follow the body's orientation; collider is
  rebuilt only past a named colliderTolerance so a resting body can sleep.
- Heightfield colliders set FIX_INTERNAL_EDGES: ghost edges kicked a
  resting sphere sideways and it never slept.
- Box/capsule prints are fully covered across the body outline and fade
  outside it, so an edge never rests on a half-pressed rim.

proof: pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts
packages/physics/__tests__/native-contract.spec.ts -> 34/34.

* feat(rain): bolt, held simulation, working fly camera and the control playtests (PRD-473)

Kit (game layer):
- the bolt from render/lightning.ts is now struck and drawn; the clouds glow and the
  coast is lit from the bolt's entry point, the thunder crosses the ground distance
- pause holds the simulation as the study did (frames still draw, weather snaps to
  the panel); window.tempest gains render(), step(dt) and a stop() that freezes the
  clock; setSafe takes auto lightning down at the intent door
- the camera read input.axis() for key bindings (always 0) and raw pointer motion that
  the tick had already spent: sprint, Q/E and drag-look never worked. Now
  vector("altitude"), pressed("boost") and a pointerRelative "look" binding.
- publishes position, strikes and lastStrike {at, metres, delay}

Harness (packages/playtest): web-only scenario field reducedMotion: "reduce" emulates
prefers-reduced-motion before navigation; schema test covers accept/refuse.

Playtests (headed, --browser-recipe webgpu, NVIDIA): storm 92.9% changed vs paused
null 0.48%; fly z 18 -> -4.9, y 2.85 -> 5.85, heading 355 -> 308; lightning: safe
strike flash 0, strike flashing, thunder pending at 36 ticks, one thunder cue,
delay 0.9968 s for 341.9 m; flash 98% changed while paused; reduced-motion passes
and fails without the emulation.

* feat(runtime-native): persistent contacts and in-place surface refresh on native (PRD-469 Phase 2)

The snow binding needs two backend operations the native seam lacked.

- Rust: tn_physics_read_contacts (one 8-float record per solved manifold,
  total count returned past capacity, sleeping bodies skipped) and
  tn_physics_set_trimesh_shape (same handle, parent, groups, events).
  Shape type 4 is a heightfield surface carried as a trimesh with
  FIX_INTERNAL_EDGES, matching the web heightfield's flag.
- C ABI + engine-agnostic bindings: readContacts, setColliderShape, and
  "heightfield" accepted by createBody.
- host.ts: heightfields cross as the trimesh of their canonical samples
  (Rapier column-major order, same cell diagonal as the web heightfield
  and Heightfield.toGeometry) instead of throwing; old runtimes fail closed
  with TN_NATIVE_PHYSICS_CONTACTS_MISSING / _SHAPE_REFRESH_MISSING.

proof:
- cargo test --release (native/physics): all suites green incl. new
  tests/snow_contacts.rs (airborne -> 0 records, resting load within 2x of
  m*g, sleeping -> 0, lowered surface wakes and re-seats the ball,
  malformed refresh refused).
- vitest native-contract.spec.ts 24/24; the web-vs-adapter surface test
  reads 0.777 m off with the diagonal flipped and < 1e-4 as built.
- pnpm native:build exit 0; pnpm census regenerated.

* feat(rain): meet the template contracts — real look files, tiers in quality.ts, kit docs (PRD-473)

- palette/sky/lighting/materials were the sailing kit's leftovers with no importer. They
  now decide the storm: haze, deck sky, sun direction and colour, overcast fill, the
  lightning light, lamp and sea colours, fed to the generated shaders as uniforms (the
  .frag sources take uniforms instead of literals). Boot capture against the previous
  build: mean channel delta 0.16, 0.012% of pixels over 8 (the fps readout).
- quality.ts: the engine chain's low/medium/high run no built-in stage and differ by the
  rung they report; the study's four tiers (cloud scale/steps, rain budget, reflections)
  move here from clouds.ts and state.ts.
- style.css paints no background on body, so the native web-view overlay is transparent.
- Q/E are two buttons rather than a down/up vector; the camera is registered as the
  player; boot.playtest.json becomes survives (ArrowUp moves the player 6.0 m); a
  1920x1080 performance scenario (p95 4.4 ms, 11 draws, 36,865 triangles).
- AGENTS.md rebuilt on the shared kit skeleton (95 lines).

Repo gates: visual-gate asks for soft shadow-map settings only when a light casts a
shadow (spec added); the assets default-config test allows per-clip audio declarations.
pnpm exec vitest run packages/create-threenative + instruction-budget + visual-gate:
52 files, 836 tests passed. visual-gate --structural-only and check-template-quality
exit 0.

* docs(PRD-473): Phase 1 verified and AC-1 ticked

headed WebGPU (NVIDIA Turing) storm/paused/fly/survives/weather/automation/simulation all pass;
visual-gate --structural-only and check-template-quality exit 0. prd:progress -> prd:25%.

* feat(rain): UI polish — pill beside the hints, scrolling panel, staged loading, hide-all (PRD-473)

- The sound pill moved into the footer with the key hints (above them on tall windows,
  beside them under 800 px), so neither the panel nor the hints can cover it; the panel
  is bounded and scrolls when the rendering details are open.
- H now hides the weather panel too (it stayed on screen); the restore button remains.
- Staged loading curtain from the study (BUILDING THE CLOUD VOLUME / LIGHTING THE COAST /
  ENTERING THE STORM) read off the engine's real startup progress, published from the
  scene's render hook so it lifts on the frame the storm is drawn; click-through while it
  fades. New playtests/loading.playtest.json.
- Mobile: 44 px icon buttons and pill, subtitle dropped under 480 px; the quality select
  draws its own chevron (the native web view drew a stock control); the pill's
  accessible name follows its state, with aria-pressed.

Headed WebGPU reruns pass: storm 92.9% vs paused 0.48%, lightning, automation, weather,
loading.

* wip(snow): snow glade starter kit, first playable pass (PRD-469 Phase 3)

templates/snow: procedural explorer on a CharacterBody3D planting the study's
boot print, a ball / crate / log pressed into the snow through
attachSnowPhysics, GPUParticles3D snowfall + powder bursts, TSL snow material
with compaction view and glints, crunch/wind clips through AudioBus, React
panel / readouts / help, touch stick, and the snow-physics playtest
(web + desktop copies).

Engine additions this needed:
- SnowField.takeDirtyRegion(): union of windows since the last take, for a
  renderer that refreshes once per frame (dirtyRegion keeps only the last).
- ISnowPhysicsBinding.loadOf(body): per-body load of the last step.

proof so far: snow-field.spec 19/19, snow-contacts.spec 13/13; packed
snow-proof passes playtests/snow-physics.playtest.json headed WebGPU on an
NVIDIA Turing adapter (exit 0). Registration, discovery and native run
still open.

* feat(rain): four tiers proven, measured cloud target, bolt and rain in the manifest (PRD-473)

- playtests/tiers.playtest.json switches the panel's select through high, balanced,
  performance and ultra (headed WebGPU, NVIDIA): cloud steps 64/48/32/88, cloud target
  864x540 / 691x432 / 576x360 / 1080x675 of a 1440x900 frame (0.6/0.48/0.4/0.75, the
  source's own shares), drops 9120/9120/4940/12160 = round(budget x 0.76); captures at
  every tier show the coast, clouds, sea and rain.
- cloudPass {width, height, steps} and the loading state are published from the scene's
  render hook, which runs after the frame is drawn — the target is only resized by the
  render, so the frame function read the previous tier's size.
- Capability manifest: createWeatherWorld/createStormAudio constraints brought up to date
  (no rainBudget, tiers from STUDY_TIERS, thunder cue proven), new generated-source
  entries createStormLightning and createStormRain; recall rows rain.lightning and
  rain.streaks. pnpm caps:recall exit 0, recallAtK 66/73, all five rain rows hit.

* feat(templates): register the snow kit and add its playtests (PRD-469 Phase 3)

survives, footsteps, weather, touch-controls and performance scenarios;
native survives; AGENTS.md for the kit; CI template-nonvisual matrix row,
conventions table row, README/getting-started/create-threenative rows and
the recall template list.

proof: create-threenative vitest 783/787 before these fixes, template.spec +
template-vite-plugins green after; scripts template specs 229/230 (the
count-loc touch list is a fixed eight and was left alone); footsteps,
weather and touch-controls pass headed WebGPU against the dev server.

* fix(rain): transpose fbm's mat2 and un-mirror the bloom — the kit now matches the study (PRD-473)

Comparing the kit against the reference at the same viewports (both frozen at t=28,
quality high) found three real differences, all now fixed:

- GLSL mat2(a,b,c,d) is column-major, TSL's constant mat2(a,b,c,d) builds a row-major
  Matrix2, so fbm's octave rotation came out transposed: terrain ridges, puddles and
  cloud shapes all drifted. The generator now swaps the off-diagonal pair for every
  constant mat2 (both shaders regenerated).
- The bloom pass never received its resolution (uRes stayed 0, the kernel divided by
  zero) and sampled the scene render target bottom-up, so every highlight bloomed
  mirrored about the horizon. postprocessing.ts now sets it and the bright pass flips.
- bloom-shader.ts / post-shader.ts claimed to be generated by a generator that no
  longer emits them; their headers now say they are maintained by hand.

Mean absolute pixel difference, kit vs reference: desktop 1.16% -> 0.41%,
tablet 1.30% -> 0.52%, mobile 1.26% -> 0.77%; pixels off by more than 24/255 on
desktop 1.76% -> 0.33% (the remainder is rain streak phase and the UI changes).

* fix(rain): shortcuts in the game's input map, so a native player has them; native lightning proof (PRD-473)

The native desktop scout's "interface but no storm" was the kit's body background: the
native web view overlay is opaque if style.css paints body (red-green on the desktop host:
background restored -> UI over a flat #0e1921, removed -> storm under the UI). Fixed in
2a752cd; template-vite-plugins.spec already guards it.

On the native host the interface is a web view that never has keyboard focus, so X, H and
Space — handled by a browser keydown listener — did nothing natively (probe: safe and
paused unchanged after the keys). They now live in the game's input map (hideUi, pause,
safe, plus M for sound on/mute) and go through intentPatch like the panel's controls.
F and P stay in the UI realm because both are browser APIs. Toasts are said on the state
transitions the game reports, so a key and a click announce alike; a strike reads its
distance and thunder delay off the strike, as the study's toast did.

native-playtests/lightning.playtest.json on --target desktop (built mystral host, Vulkan,
RTX 2080, private Xvfb): this host's web view reports prefers-reduced-motion, so a strike
is suppressed at boot (strikes 0); X turns flashes on, M turns sound on, L strikes
(status flashing, strikes 1), thunder pending at 36 ticks, one thunder cue after,
delay 0.9968 s. Capture shows the bolt, the lit coast and the toast
"LIGHTNING · 0.34 km · thunder in 1.0 s". Browser lightning and automation rerun green.

* perf(snow): bounded redraws, no bloom, a merged explorer (PRD-469 Phase 3)

- core Heightfield.refreshGeometry grows the existing bounds by the
  refreshed window instead of recomputing over every vertex: 2.1 ms per
  footprint-sized refresh on a 321-sample field (micro-benchmark). The new
  bounds test fails with the expansion removed and passes with it.
- snow surface uploads only the refreshed rows (updateRanges) and slices
  wide refills eight rows a frame.
- explorer rigid parts merge to one mesh per material; far forest keeps
  every other bough tier and casts no shadow.
- bloom off in every tier: at the default 0.2 threshold the snow hazes, and
  at 0.92 the native desktop frame came out blank behind the HUD (Dawn,
  Vulkan, RTX 2080) while the web was fine; recorded in quality.ts.
- auto-explore is scene-owned; the end-of-frame state write no longer races
  the toggle.
- native survives asserts movement and planted steps; desktop rejects
  visual and visibility assertions.

proof: packed snow-proof, production preview, headed WebGPU (NVIDIA Turing):
all six playtests/*.playtest.json pass (exit 0); performance world p50
3.2 ms, overall p95 23.9 ms; desktop: native-playtests/snow-physics and
survives exit 0 against the worktree-built host.

* fix(rain): loading curtain as text over the engine's loading screen; deterministic tier steps (PRD-473)

- The window-sized DOM curtain cost a 121 ms p95 frame at 1920x1080 (performance
  scenario red; bisected to 76ce647, 4.6 ms with the curtain removed): a painted layer
  over the WebGPU canvas is composited every frame. The curtain is now only its title,
  stage and note around the engine's own canvas loading bar (src/render/loading.ts, now
  a one-pixel line at the centre, as the study drew it), the controls stay hidden until
  it lifts, and it unmounts after its fade. performance p95 back to 4.7 ms.
- "Ready" is the canvas loading screen having lifted (loading.ts exposes `done`), so the
  interface and the world appear on the same frame; compileSettled showed the controls
  over a black screen.
- cloudPass.steps comes from the frame that simulated the tier and only the size from
  the render hook, so the tiers scenario no longer depends on when the runner renders.
- loading.playtest.json asserts the curtain lifted with progress 1 (allowTrivial when a
  warm pipeline cache finishes the launch before the first observation).

Headed WebGPU: storm, loading, performance, tiers pass.

* fix(snow): honest ball telemetry and a kick that leaves the crater (PRD-469)

- telemetry reads the solver pose, not the drawn object a step behind it
- "airborne" is the underside clearance over the whole footprint, and an
  airborne load needs clearance at both ends of the step: a landing or a
  kick-off touches at one end of it
- roll ratio is turn x radius over ground covered; the playtest band is
  0.6-1.6 since a ball carving its own trench over-spins as it slows
- the kick lifts as well as pushes (24 N s, 40% up): a dropped ball can
  sit 10 cm down and a sideways shove rolled it up the wall and back
- physics spec: a sideways shove does not dig a pit (regression guard)

proof: snow-contacts.spec 14/14; native desktop
playtests/snow-physics.playtest.json --target desktop: 3/3 runs exit 0
(previously 1 in 5 rolled back into the crater); web dev 2/2 exit 0.

* docs(prd-469): Phase 2 verified (AC-3, AC-4, AC-5)

proof: snow-contacts.spec -t sphere 6/6, -t "box|capsule|lifecycle" 8/8;
TN_TEMPLATE_ONLY=snow pnpm test:templates exit 0; native desktop
playtests/snow-physics.playtest.json --target desktop exit 0 on 3/3 runs.
pnpm prd:progress: 2/3 phases, 5/8 boxes, prd:50%.

* feat(capabilities): snow discovery and recall rows (PRD-469 AC-7)

- GPUParticles3D gains snowfall/blizzard and powder-spray situations;
  SnowField a compaction situation; attachSnowPhysics states its verified
  platforms (browser WebGPU, native Linux desktop).
- nine snow rows in the recall corpus (template:snow#Commands and map),
  budget row ids registered and the floor ratcheted (0.909, 70/77).
- brief.fps.5 had dropped out at Phase 1: TracerPool3D sat at 0.28
  against the 0.27 floor and the snow entries shifted the weights. A
  one-tracer-per-press situation now recalls it at 1.03.

proof: pnpm caps:recall exit 0; pnpm capabilities:check fresh (369
entries); engine-mcp search/server/capability-examples specs 71/71.
Manifests and reference regenerated by their generators.

* test(rain): auto-lightning, cloud gate and cinematic playtests; gates green (PRD-473)

- auto-lightning: the scheduler's first strike lands at t = 37.017 (28 + 9) under the
  storm's cloud; auto-lightning-clear: with cloud cover dragged to 0.09 nothing strikes
  through t = 44.2; cinematic: the orbit runs, W hands the camera back.
- The curtain lifts when loading.ts makes the canvas layer transparent again, rather
  than a `done` field the canonical loading source does not carry: loading.ts is
  restamped from template-assets/loading.ts (loading-screen.spec and the pristine
  scaffold typecheck were red).
- Render-source comments no longer spell the package scope (core-boundary budget).
- Lint: two pre-existing errors in the kit's tools fixed; spec and playtests formatted.
- docs/verification/native-coverage-2026-08-28.md regenerated by
  `pnpm --filter @threenative/runtime-native native:coverage` (exit 0), since the wip's
  native audio graph changed the source digest.

pnpm typecheck exit 0; pnpm lint exit 0; vitest create-threenative + playtest + core audio
+ assets audio + visual-gate + capability-recall + instruction-budget +
verify-template-playtests: 156 files / 2191 tests, the two red ones fixed here and rerun
green (62/62).

* feat(snow): UI and look polish pass (PRD-469 Phase 3)

44 px hit areas for the compaction switch, mobile sliders and weather
buttons; Escape closes help and its action takes focus; a header scrim
keeps light text legible over snow; finer 15x20 footprint probe;
portrait framing widens the field of view instead of filling the screen
with the explorer; a darker touch-stick ring that shows on snow; the far
forest keeps every tier with fewer boughs; storm flakes streak; the
blizzard toggle is scene-owned like auto-explore (its toast had inverted);
the surface view frames the newest print the explorer does not hide.

* docs(PRD-473): Rain verified — all phases and AC-1..AC-5, captures committed, PRD archived

- PRD rewritten to what was built and run: the abstraction map now names the kit's own
  ray-march (the first plan's Heightfield/WaterSurface3D/GPUParticles3D mapping is
  replaced, with the decision recorded), per-row AC-4 evidence for inventory rows A-F, the
  AC-5 comparison (mean pixel difference 0.41% / 0.52% / 0.77% at 1440x900 / 1024x768 /
  390x844, and every remaining visible difference listed), Phase 2 tiers and Phase 3
  native results. The owner's visual score is the one Blocked on line.
- Evidence (2.0 MB JPEG): docs/verification/visuals/rain/kit-{desktop,tablet,mobile}.jpg
  beside the reference PNGs; docs/verification/PRD-473/ side-by-sides, UI states (help,
  hidden, rendering details, mobile panel and help, loading curtain), lightning strike,
  three tiers, native desktop boot and lightning.
- git mv to docs/PRDs/done/starter-kits/ (its batch sibling PRD-469 is on another branch
  and not done, so this PRD is archived alone; links rewritten for the new depth).

Final sweep at 47ccaef: 16/16 browser scenarios pass headed on NVIDIA WebGPU; native
boot and lightning pass on --target desktop. pnpm prd:progress -> prd:100% — ready.
pnpm budgets exit 0 (docs/verification 66.9 MB of 72); check:docs 2381 links ok;
ci-structure, primary-docs, check-template-conventions, check-doc-links, evidence-budget,
evidence-citations: 6 files / 167 tests pass; pnpm build exit 0 with no manifest drift.

* docs(PRD-473): record the rain template playtest gate (exit 0)

* perf(native): snow surfaces are native heightfields, not refreshed trimeshes (PRD-469)

The trimesh adapter rebuilt a 321-sample snow surface in 101 ms on native
(179 ms at 401, 244 ms at 481; cargo micro-benchmark), so every footstep
hitched the desktop host by a tenth of a second. A Rapier heightfield
rebuild only copies its samples: 0.24 ms at 321, 0.38 ms at 401.

- Rust: tn_physics_add_heightfield_body / tn_physics_set_heightfield_shape
  (column-major samples exactly as the web backend hands Rapier,
  FIX_INTERNAL_EDGES); the trimesh-surface path and its shape type are gone
  and add_trimesh_body is back to a plain trimesh.
- C ABI + bindings: createBody and setColliderShape take "heightfield".
- host.ts passes heightfields through instead of triangulating them; the
  web and native surfaces are now the same shape by construction.
- core: Heightfield.refreshGeometry computes normals from the grid (same
  central differences as normalAt; 27-row slice of a 401 field 0.6 ms);
  SnowField.copyChannel is the bulk read a renderer encodes from (a
  sample() per vertex cost ~10 ms per 10.8k vertices).

proof: cargo test --release green incl. snow_contacts (heightfield now);
native-contract + snow-contacts + snow-field 58/58 (copyChannel matches
sample to 1e-6; rendered normals match normalAt within 0.001 over the
field); pnpm native:build exit 0; census regenerated.

* feat(snow): 6 cm snow, separate print rows, robust physics telemetry (PRD-469)

- 401 samples across 24 m (6 cm), now affordable on native
- a 0.2 m stance: at 0.165 the two print rows merged into one centre
  trench at this resolution (measured 5 cm deep under the body line)
- surface redraw discards the field's first full write and slices wide
  refills in proportion to what is waiting (a 0.12 m render lag had
  persisted at 401 rows); renderError redraws pending windows first
- the ball path records only on contact (a kick hop pressed nothing)
- playtest push floor is reach >= 0.5 m with >= 5 track points: how far
  depends on the slope it was pushed up; perf triangle cap 1.0 M
- 16k flakes on desktop, 6k on a phone

proof: packed snow-proof, production preview, headed WebGPU (NVIDIA
Turing): all six playtests pass (exit 0). Desktop:
playtests/snow-physics.playtest.json --target desktop exit 0 on 5/5
runs; native-playtests/survives exit 0.

* fix(snow): judge-pass polish (PRD-469 Phase 3)

Auto-explore button fills its row; footer label keeps its space at 1024 so
the key hints stay centred; looser headline gap on short screens; a
compaction legend when the view is on; a softened footprint probe; the
phone toast clears the stick; blizzard fog thinner (0.016 + 0.05 storm)
so the explorer stays readable, storm flakes larger; the surface view
looks back at the print from beyond it.

* fix(snow): render source imports no engine package (PRD-469 AC-8)

TN_CORE_BOUNDARY: templates/*/src/render must stay portable. bootPrint and
snowSurface now name the shapes they read structurally, and weather takes
a particle factory the scene fills with GPUParticles3D (the shooter vfx
pattern). Native coverage report regenerated for the new native sources;
abyss-framework build report moved with the engine bytes.

proof: pnpm budgets exit 0; pnpm typecheck exit 0; pnpm lint exit 0;
pnpm build exit 0; packed snow-proof tsc clean.

* fix(create-threenative, core): friction from the rain sandbox demo (PRD-473)

Building Storm Chaser on a cold `--template rain` scaffold (sandbox/rain-demo) found three
engine-side gaps:

- Every scaffold's .gitignore now ignores the asset pipeline's compiled outputs
  (public/assets.manifest.json, public/bake.receipt.json, hashed output files). Only
  starter carried such rules, and even it missed the bake receipt; rain ships source
  clips, so its first commit would have carried cooked WAVs. Red: new scaffold test
  failed for rain ("public/assets.manifest.json" missing); green after the rule; the
  ten pinned scaffold hashes recomputed (gitignore + the manifest change below).
- `engine_search_capabilities("countdown timer that ends the round")` answered
  TracerPool3D. The Scheduler now carries that situation (worded without "round" — the
  first wording displaced brief.fps.5's ammo round); recall row rain.round-time-limit
  red -> green, recallAtK 67/74 >= floor, rejectHits 16.
- The rain kit's AGENTS.md now names where gameplay attaches: strike() in Boot.ts, the
  frame function, a GameState field, Billboard3D for world markers (96 lines).

pnpm exec vitest run packages/create-threenative: 50 files / 818 tests; caps:recall exit 0;
capability-recall, instruction-budget, template and playtest specs pass; pnpm lint exit 0.

* docs(PRD-473): sandbox demo — Storm Chaser on the rain kit

sandbox/rain-demo (sandbox repo fbc56a8, local): a cold --template rain scaffold plus a
small game (two survey lamps and a field station inside 75 s; a strike within 500 m in the
open costs a nerve, a lamp is shelter). chase-win and chase-lose pass headed on NVIDIA
WebGPU; chase-win passes on --target desktop. Five compact captures (420 KB JPEG) and a
Sandbox demo section in the PRD; the engine-side friction fixes are 1b326f1.
prd:progress still prd:100%; budgets exit 0; check:docs 2386 links ok.

* docs(prd-469): record the native heightfield and bloom decisions, refresh AC-5 evidence

* chore(runtime-native): native coverage runs on LLVM 23, regenerate the merged report

llvm-cov 23 rejects --fatal-warnings (Unknown command line argument), so
native:coverage failed after the 2026-10-01 LLVM 22 -> 23 upgrade. runForStdout
already throws on any llvm-cov stderr output, so dropping the flag keeps the
warning gate fail-closed on both versions.

Regenerated docs/verification/native-coverage-2026-08-28.md for the rain + snow
merge (both sides changed native source): 24356 lines, 78.72%.

Gates: pnpm budgets 0; pnpm exec vitest run 525 files / 6555 tests passed.

* fix(physics): powder resists what ploughs through it (PRD-469)

Layer: engine. Found building the snow sandbox demo: SnowField deformation took no
energy, so a 10 kg ball at rest on the kit's 7% glade slope rolled 4.0 m away on
its own, and a pushed ball was still moving at 0.72 m/s four seconds later. Every
snow game inherits that, so it belongs in the binding, not in game code.

attachSnowPhysics now takes back horizontal momentum from each supported dynamic
body with the soft-ground rolling-resistance coefficient sqrt(sinkage / width)
times its solved load, capped at the body's own momentum so a resting body stops
rather than being pushed uphill. Sinkage is what the field measures under the
body; width comes from the footprint's measured area. `resistance` scales it
(default 1, 0 turns it off, negative or non-finite throws); observe().resistance
reports the impulse taken each step.

Red -> green (packages/physics/__tests__/snow-contacts.spec.ts, real Rapier):
- 7% slope, resting ball: rolled 4.22 m -> 0.03 m (resistance 0 still > 0.5 m)
- 18 N s push: 0.72 m/s after 4 s -> at rest, short of the frictionless run
- negative / NaN resistance: accepted -> throws
The track-continuity and collider-orientation tests pass resistance 0, since
they measure the roll and the axes, not the drag. 17/17 snow-contacts,
native-contract 23/23.

The snow kit passes resistance 0.3 (a push carves a track you can follow) and
its AGENTS.md names the convention, the override and the report.

* fix(physics): an off-centre force's torque lasts one step; powder slows a roll

Layer: engine, both backends. applyForceAtPoint promises a force for one fixed
step, but web and native cleared only forces after each step: the torque an
off-centre force adds stayed in Rapier and kept spinning the body up forever.
Found when the powder resistance (previous commit) moved to the rolling lever.

- simulation.ts and native lib.rs: resetTorques / reset_torques beside the
  existing force reset.
- red -> green, web (actuation.spec.ts): spin in a late 10-frame window was 3.3x
  the early one -> within 0.7-1.3x. Native (tests/actuation.rs
  force_at_point_torque_lasts_one_step): spin went 0.044 -> 0.194 rad/step ->
  constant within 5%; cargo test --release 9/9 actuation.

Powder resistance now acts at I/(m r) above the centre (0.4 r for a ball,
0.5 r for a capsule, the centre for a box), so a slowing ball rolls slower rather
than skidding: the snow kit's packed playtest read rollRatio 2.3 (spinning in
place) with the centre impulse. snow-contacts.spec.ts asserts a resisted ball's
roll ratio stays in 0.6-1.25. Physics suite 204/204.

* fix(snow kit): the ball stays on its tee, a push still carves a track (PRD-469)

Layer: template. With powder resistance in the binding the kit's ball no longer
rolls off its tee down the glade's 7% slope at load (it used to wander away on
its own). The kit passes resistance 0.15 so a push carries about 1.5 m.

The roll-ratio ceiling in both snow-physics scenarios rises from 1.6 to 2.2:
a ball braking uphill out of its own crater over-spins as it stops (measured
1.98 on web). The floor of 0.6, which is what rejects a sliding or copied
transform, is unchanged.

TN_TEMPLATE_ONLY=snow pnpm test:templates: exit 0, 7 scenarios, NVIDIA Turing.

* revert(physics): withdraw powder resistance; keep the one-step torque fix

Withdraws the resistance added in 6fe5cb2 and reshaped in the torque commit,
and the snow kit's tuning on top of it. The torque reset (web and native) and
its tests stay.

Three models were tried, each measured on real Rapier, and none holds on both
backends:
- sqrt(sinkage/width) * load at the centre: a pushed ball skids and spins in
  place (kit roll ratio 2.3-4.5 on web);
- the same at the rolling lever I/(m r): web 1.98, native desktop 5.9 with
  reach 0.47 (< 0.5), so the kit's native scenario fails 3/3;
- compaction work removed from horizontal momentum: landing impacts kill a
  kick (0.08 m) while steady rolling barely slows (2.9 m down a 7% slope);
- (and a force-couple torque, swept headless: ratio up to 3.4 at full strength,
  no slope hold at kit strength).

So the binding is back to deforming snow without taking energy: a ball on the
glade's 7% slope still rolls off a tee, and pushes are not slowed by powder.
Recorded as open engine work in PRD-469 rather than shipped half-right.

* docs(prd-469): AC-6 and AC-7 verified, visual record, snow scaffold hash

AC-6: TN_TEMPLATE_ONLY=snow pnpm test:templates exit 0 on the rain + snow merge,
7 scenarios on NVIDIA Turing; native snow-physics 4/4 + survives on a fresh pack.
AC-7: build, capabilities:check, caps:recall, engine-mcp 71/71, and the shipped
engine MCP server answering snow queries from the packed snow-proof.

Captures: 11 JPEGs under docs/verification/PRD-469/ (0.72 MB) - the kit at three
viewports, blizzard, surface view, physics on web and native desktop, and the
supplied ICEFIELD document at the same three viewports.

Scaffold hash: snow added to PRD_201_PARENT_SCAFFOLD_HASHES (create-threenative
specs 829/829). Decisions record the withdrawn powder resistance.

* docs(prd-469): AC-8 verified; PRD-469 complete, moved to done

pnpm typecheck, lint, test, budgets and check:docs all exit 0 on de581ef (rain +
snow merged): root 526 files / 6,567 tests, runtime-native 132 / 1,558; budgets
includes the 72 MB evidence cap and the regenerated native coverage. sync:agents
and MCP config checks clean. The acceptance section gains its two platform-lane
boxes (browser WebGPU, native Linux desktop) so prd:progress reports 100%.

* docs(prd-469): sandbox demo - Snow Putt

Records the sandbox demo built cold on the snow kit (sandbox commit 08b84cb1e,
local): web putt-win, putt-lose and five kit scenarios pass headed on WebGPU;
native putt-win 6/6 and survives pass on the desktop host. Three compact
captures (0.19 MB); docs/verification is 68.1 MB of the 72 MB cap.

* fix(runtime-native): audio biquad uses a literal pi, MSVC defines no M_PI (PRD-473 Windows native leg)

* fix(playtest, rain): click interface controls by element, not by font-dependent pixels (PRD-473)

CI `template-nonvisual (rain, 1/1)` failed rain-auto-lightning-waits-for-cloud-cover
(target.cloud stayed 0.76, so the storm struck) and rain-cinematic-orbit-yields-to-the-player
(cinematic never true). Both clicked panel controls at fixed viewport pixels. The runner
has different fonts (monospace -> DejaVu Sans Mono, Arial -> Liberation Sans), the panel
lays out a few pixels differently, and a 15 px switch and a 3 px slider track miss.

Red, reproduced locally in CI conditions (FONTCONFIG limited to DejaVu + Liberation,
SwiftShader adapter, TN_PLAYTEST_ALLOW_SOFTWARE=1, the CI flags): the same two
scenarios fail with the same assertions. Timing was not the cause: the CI report's
per-step state shows the UI ready (loading.ready, uiReady) before each click.

Harness (packages/playtest): a click step's `at` may now be
`{ element: { id } | { selector } }`, resolved to the centre of the element's live box on
the browser target; a missing or invisible element fails closed; device targets refuse
it with TN_PLAYTEST_UNSUPPORTED_ON_TARGET. Schema and runner tests added; AGENTS
documents it.

Kit: presets, the strike button and the sound pill get stable ids; every kit scenario
that clicked a control now clicks it by element (sliders: focus, then Home/End).

Green: the 5 CI-kept scenarios pass in CI-like mode (SwiftShader + Ubuntu fonts); all 16
kit scenarios pass headed on NVIDIA WebGPU with the Ubuntu fonts. vitest create-threenative
+ playtest: 151 files / 2086 tests; pnpm lint 0; pnpm budgets 0.

* fix(snow kit): the explorer walks on a coarse surface; frame p95 44 -> 28 ms (PRD-469)

After the develop merge the kit's production-performance read frame p95
39.9-44 ms against 33. Develop's loop now leaves boot frames out of the window,
which exposed an existing cost.

Root cause, measured:
- The slow frames are the runner's waitTicks bursts: about 8-10 fixed steps per
  presented frame (FixedStepLoop.advance), at about 2.5-3 ms per step on web.
  Nothing grows with time; active cells plateau and frames return to 1-2 ms
  once the ticks stop.
- Most of a step was the explorer's CharacterBody3D. Without its movement the
  physics step fell from about 1.3 to 0.4 ms. Rapier sweeps a character capsule
  against every collider triangle under it: one capsule castShape costs
  0.04 ms on a 51^2 heightfield, 0.63 ms on the kit's 401^2 (6 cm) snow, and
  8 ms on 401^2 squeezed into 6 m. 8x8 tiles change nothing, so the cost is the
  triangles under the sweep, not collider size.

Fix:
- Kit (layer: template, the kit decides what its character stands on): the
  explorer collides with a coarse walk surface, ground plus fresh snow at 101^2
  (24 cm, `walkSurface` in src/terrain.ts). It has its own collision layers, so
  the ball, crate and log keep the 6 cm snow collider and never touch the walk
  surface. The explorer's drawn height and boots already came from the canonical
  snow, so nothing visible changes. AGENTS.md names it.
- Engine: attachSnowPhysics compares only the window written since its last
  collider rebuild instead of the whole field every step, through a new
  Heightfield.trackChanges() (per-consumer change windows, union per tracker)
  and Heightfield.colliderHeight(row, column). Unit tests in
  world-heightfield.spec.ts; snow-contacts, native-contract and the physics
  suite stay green (234/234 with the heightfield spec).

Red -> green with the scenario itself, threshold unchanged: p95 39.9, 39.9, 44.1
-> 27.8, 27.5, 30.0 ms. TN_TEMPLATE_ONLY=snow pnpm test:templates exit 0, all 7
scenarios (NVIDIA Turing). Native snow-physics 3/3 and survives pass on a fresh
pack. create-threenative specs + instruction budget 896/896, typecheck, lint,
budgets, check:docs, caps:recall 0. PRD-469 Decisions now carry the cause.

* docs(prd-469): real-time numbers, trade-off and capability entry for the walk-surface fix

Follow-up to 23eff53, per the review constraints:
- Real-time play measured, not only the runner's tick bursts. Wall-clock run,
  10 s of walking at 1920x1080 on NVIDIA Turing. Before: per-frame p95
  14.2-17.1 ms, update p95 2.8-3.8 ms, 130-195 fps. After: per-frame p95
  12.8-14.3 ms, update p95 1.7-2.3 ms, 158-248 fps. The burst-mode scenario
  passes at the unchanged 33 ms (p95 27.5-30.0).
- Visual check: re-captured the walking kit and the surface view; the prints
  and planted boots match the committed captures, which are refreshed here.
- AC-3/AC-4 untouched: snow-contacts.spec.ts 14/14 is unchanged, and the ball,
  crate and log still press the 6 cm snow.
- Heightfield.trackChanges() kept: 1.29-1.45 -> 0.89-0.91 ms per binding step
  on a 401^2 field that changes every step. The class-level @Situation now
  carries it into the generated manifests and reference (pnpm build); there
  are no hand edits. Existing callers see no behaviour change.

Gates: typecheck, lint, budgets, check:docs, sync:agents --check, caps:recall
all exit 0. Physics + heightfield + snow-field + create-threenative + engine-mcp
specs pass 1,213/1,213. Scaffold hashes did not move.

* docs(prd-469): drop develop's planning copy; the finished PRD lives in done/

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…onsole capture

CI job `clean-consumer` failed on 5 of 6 sequential runs with
TN_PLAYTEST_RUNNER_FAILED: on a hosted SwiftShader emulator `adb logcat -d
-v brief` drops the transport mid-dump, exits non-zero after printing part
of the buffer, and `adb()` threw with the whole 82 KB logcat embedded in the
message.

captureConsole() now re-reads once after `wait-for-device` — the shape the
native harnesses already use (#310, b12becd) — so a dropped transport is a
retry rather than a failed run. A `KO:` reply is the device refusing the
command and is not retried, so no named refusal is masked. Exhausting the
attempts throws the same `adb logcat -d -v brief failed:` error with the
output capped to its last 2 KB, because the truncation is the evidence and the
head is 82 KB of Android noise.
… 0.x series; scaffolder 0.2.8

The 0.3.4 cohort ships @threenative/assets at 0.3.5 on purpose (create-threenative requires
>=0.3.5), but `doctor` failed any project whose @threenative packages were not byte-identical in
version, so the post-publish registry clean-room reported A1 red on `npm:doctor`/`pnpm:doctor`.
The check now compares the major.minor series, which is what the peer ranges name; a 0.3 package
beside a 0.4 one still fails.

The doctor ships inside create-threenative, so the fix needs a new scaffolder: 0.2.7 -> 0.2.8,
template pins and the byte-stable scaffold digests moved with it.
…ts real name

templates/rain/tools/verify-noise-volume.mjs imported ../src/render/noise-volume.js, but the
template ships noise-volume.ts, so publish:check refused the scaffolder tarball
(an installed copy cannot resolve that import). The tool already runs under tsx, which loads an
explicit .ts. Rain's scaffold digest moves with the file.
A1 passes against the registry (create-threenative 0.2.8, assets 0.3.5, metahuman 0.1.0). PRD-196's
hosted clean-room box stays open: the tag run failed on the SwiftShader upgrade proof before
clean-room ran.
* docs(prd341): track tone gate implementation phases

* feat(playtest): measure six tone metrics during PNG inspection

* feat(playtest): validate tone bounds and report shared CLI metrics

* feat(playtest): record tone from browser and device captures

* test(playtest): capture real exposure regression in maintained WebGPU lane

* fix(playtest): require final valid tone evidence and use built capture runner

* docs(playtest): attach real underexposed and restored tone capture proof

* docs(prd341): archive qualified tone gate and retain acceptance proofs

* fix(playtest): type tone averages without suppression
Add 15 bounded proposed PRDs and one execution index referencing 10 existing canonical owners. Preserve existing files, completed evidence, and implementation PR ownership.

Correct prior audit assumptions about merged rain/snow and open-world work, upstream rendering primitives, and runtime-vs-target decoder capabilities. Add the reported native bloom regression as a reproduce-first correctness item.

Verification: 18 Markdown files checked for local links, balanced fences, placeholders, phase/checkbox/proof structure; all 18 uploaded blob hashes and ZIP contents match the local documents. New implementation boxes remain unticked.

Not run: repository pnpm prose lane or runtime gates; full checkout/dependencies unavailable in this environment. No product code changed.
* docs(prd): PRD-476 Fluid Lab particle water on the GPU at 60 fps

Plans absorbing fluid-lab-v2.html as FluidParticles3D (GPU PBF), reusing
Buoyancy3D, RippleField and GPUParticles3D, and a sandbox fluid-lab game.
Measured cause of the 30 fps wall: the CPU PBF step is 29.9 ms at the
default quality, 1.8x the frame budget.

* feat(core): FluidParticles3D, a GPU particle-fluid solver (PRD-476 phase 1)

Position Based Fluids as ordered TSL compute passes: grid hash, density
constraints, XSPH viscosity, cohesion, vorticity confinement, collider
projection, plus a density volume and column heights read back through
GPUReadback. Draws nothing; the game owns the look.

examples/prd476-fluid-particles runs a gated dam break on the Linux
hardware WebGPU adapter and settles with mean compression 0.0012.

* test(core): FluidParticles3D spec, ?bench step timing, stir queue reset (PRD-476)

Unit spec for option validation, dispatch order, ring emit, capacity and
release. The example gains ?stress (6,000 particles) and ?bench (steps
timed to GPU completion). A stir queued on an empty solver no longer
survives into a later step. PRD boxes ticked with their results; the
6,000-particle presented-frame box stays open (no valid presentation lane).

* feat(core): colliders, floating bodies and a filtered surface for FluidParticles3D (PRD-476 phase 2)

* feat(runtime-native): fluid-particles conformance case and desktop playtest (PRD-476 phase 2)

* chore: lockfile for the example's physics dependency (PRD-476)

* feat(core): manifest recall for particle fluid and splash spray (PRD-476 phase 3)

* fix(core): waive the TSL namespace cast in FluidParticles3D (quality gate)

* docs(prd): PRD-476 phase 3 results, blocked presentation lane

* fix(core): FluidParticles3D frees its buffers on detach, validates colliders atomically, exposes staleFrames (PRD-476 review)

* docs(prd): PRD-476 done — frame cost measured with GPU timestamps; presented fps stays unmeasured

* fix(example): prd476 fluid example defaults to the raymarched water look

The first screenshot a reviewer saw was the debug point view: unlit square
sprites that merge into flat blocks. The volume look is now the default
(?points keeps the debug view), with saturated water colours, a lighter
backdrop and a camera that frames the whole tank.

* fix(core): FluidParticles3D defaults to water-like damping; example renders through the glass

The water read as jelly and plastic. Viscosity 0.075 killed relative
velocity in about 13 steps, the spawn lattice was jittered by 4 mm and
not at all in y (layer stripes never mixed), and the raymarch drew a fake
free surface against the tank wall because the density field fades there.

Defaults are now viscosity 0.008, cohesion 0.03, vorticity 0.015, with the
lattice jittered in all three axes. The example raymarch clamps its samples
inside the walls and travels through the water when a ray starts under
it, with absorption along the path. A ?points query keeps the debug view.

* docs(prd): PRD-476 presented fps measured on the real display (59.97 fps, p95 16.8 ms)

* docs(prd): PRD-476 explain the 16.8 ms presented p95 as the display period

* test(core): pin water-like fluid defaults; fix raymarch first-sample interpolation; PRD render headroom and limits

* docs(prd): PRD-476 acceptance boxes ticked with proofs, status DONE

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…s to PRD-478 (#384)

* docs(PRD-475): open world at 120 fps with no visual loss

Follow-up to #375. Machinefall's map-walk holds CPU and GPU p95 at or
under 8.3 ms on the RTX 2080 WebGPU adapter, judged against the look
develop a602467 restored. Three phases: sync Machinefall with
develop and baseline it, cut what the baseline ranks first in the
engine, then hold the budget in the game's own scenario.

PRD-473's AC-5 (the 120 fps target) moves here; PRD-473 keeps the
GPU-driven mechanisms.

* docs(PRD-475): Phase 1 baseline: the CPU render phase is the frame

Machinefall map-walk on develop a602467, 3 runs at load 3.4-3.8 on
the RTX 2080: frame p95 26.3 ms, CPU render p50 12.1 / p95 26.1 ms, GPU
p50 7.4 / p95 11.3 ms. Recorded in runtime-perf-state.md; Phase 1 boxes
ticked. Phase 2 gains a box for the unlogged world-load failure seen
once in five walks.

* fix(core): a failed world load names its asset or cell (PRD-475)

`WorldCells` counted a refused asset, chunk or attach in `#failures` and
discarded the error, so a playtest that reds on `state.failures` 0 -> 1 could
say a load was lost but not which one. All three sites now print one
`TN_WORLD_CELL_FAILURE` line naming the stage, the asset id or cell key, the
paths being read and the message behind it, through one private helper beside
`#reportImpostor` and on the same `key=value` convention the other world
markers use. A `console.warn`, not `console.error`: a refused load is a world
that draws the rest of itself, and a playtest treats an error as fatal.

Red-green: `packages/core/__tests__/world-cells.spec.ts` "names a refused world
load in a TN_WORLD_CELL_FAILURE marker" fails on `lines[0]` being undefined
before the change and asserts `kind=asset id=pine`, the `pine.glb` path,
`message=pine is offline` and `stats().failures === 1` after it. Counting is
unchanged.

* feat(core): report the terrain tile census as TN_TERRAIN_TILES (PRD-475)

Step 0 of the PRD-473 terrain tile draw reduction plan: the stat the merge is
measured against has to exist before the merge does, and it has to print with
the merge off as well as on — the before/after census is the only way to read
what the merge cut.

- `terrainTiles` gains `blending`, so the report names the tiles mid LOD morph
  that are always individual meshes.
- `debug()` publishes it beside `lodTransitions`.
- The marker is `TN_TERRAIN_TILES` (was `TN_TERRAIN_MERGE`, which only fired
  with the merge on) and prints tiles, blocks, draws, blending and rebuilds.

Test: `reports the tile census beside the lodTransitions stat, counting the
meshes it submits` walks a 3x3 island merged and unmerged and asserts `draws`
equals the visible level and block meshes the main pass would submit, `tiles`
the resident count and `blending` the morphing count. Red before the change:
`Cannot read properties of undefined (reading 'draws')` — the stat was not
published.

* feat(core): charge a terrain super-tile's bytes to the resident budget (PRD-475)

Step 1 of the PRD-473 terrain tile draw reduction plan. The merge itself
(mergeLevelGeometry, the block set, the per-block bounds, hiding a member's own
mesh) landed with the streamed-open-worlds work; what it did not answer is the
plan's memory question: a block duplicates the level vertices of the tiles it
covers, and nothing said so.

The per-tile level geometry stays — a tile that leaves its block, dissolves it
or starts a LOD morph draws its own mesh again, and regenerating it on demand
would cost more than the copy — so the copy is charged instead. `IMergedBlock`
carries `bytes`, `#blockBytes` accumulates into `residentBytes` beside the
stitch bridges, and a dissolve gives the bytes back. A budget sized for
unmerged terrain now admits fewer tiles, which is the honest number.

Test: `charges a merged block's bytes to the resident budget instead of holding
both for free` walks the same island merged and unmerged and asserts the two
residencies differ by exactly the block's geometry bytes. Red before the change:
279144 vs 791712 — the 512 KB block was held for free.

The 3x3 same-LOD island test also now proves the merge is vertex-exact rather
than position-exact only: every merged vertex carries the normal of the tile it
came from, the drawn triangle set is the nine tiles' triangle set, and the
island is one visible mesh.

* style(core): format the terrain merge triangle helper (PRD-475)

Biome's line width; the step 1 commit carried the unformatted signature.

* feat(core): rebuild one terrain super-tile per frame (PRD-475)

Step 2 of the PRD-473 terrain tile draw reduction plan. `#rebuildDirtyBlocks`
claimed to rebuild at most one block per `follow` and then looped while the
frame's admission budget still had room, so a follow whose streaming churn
left several blocks dirty paid for all of them at once — the frame-time spike
the plan lists under streaming churn, with `TN_FRAME_SPANS` as the witness.

One block per follow now, whatever the budget holds, and a refusal still defers
rather than drops: the lowest dirty key goes first, so the work is
deterministic, and a still follow point converges.

Tests, all red before the change:
- `rebuilds one dirty block per frame, so three dirty blocks take three frames`
  — one follow over a 3x3 ring split across four block cells left three
  block-forming rebuilds dirty; it built all three. Red: expected 1, got 3.
- `defers a rebuild the frame budget refuses to the next frame that allows it`
  — a refusing budget must build nothing, say so through `deferredAdmissions`,
  and let the next allowing frame build exactly one. Red: expected 1, got 3.
- `re-merges a blending tile's block on a later frame once its blend ends` — a
  tile that blended must rejoin its block rather than stay an individual mesh.

`keeps a lone block member individual rather than copying its geometry` now
follows the ring until it converges: it asserts the settled draw structure, and
the ring settles one block per frame now. No seam, height or visual assertion
changed.

* feat(core): merge terrain super-tiles by default (PRD-475)

Step 3 of the PRD-473 terrain tile draw reduction plan. The merge exists to cut
the main pass's terrain draws, it cannot change the picture (a merged vertex
lands on the world position its tile's vertex did, on the same game-owned
surface), and every draw-structure test around it is green — so it is on unless
a game says otherwise. `mergeTiles: false`, `?tnTerrainMerge=0` or
`TN_TERRAIN_MERGE=0` turns it off for a run; the reader only hears those three
now, so a URL still carrying the `=1` that used to ask for the merge stays on.

Tests that drew the ring one mesh per tile and now draw it merged:
- `world-terrain-merge.spec.ts` reads the opt-out instead of the opt-in, and
  keeps an explicit `mergeTiles: false` case for the unmerged draw structure.
- `world-tiles-cost.spec.ts`: `renderedPositions` records the meshes the main
  pass actually submits (a super-tile under its block name, a tile that draws
  its own level under the tile key) instead of assuming every tile owns a
  visible one; `bridgeOf` no longer takes the first bare mesh, which is now a
  possible super-tile; and `ring()` follows until the last dirty block is built,
  because blocks rebuild one per frame and a ring that has not converged is not
  the settled ring those cost cases are about.

No seam, height, LOD-pop or visual assertion changed. Core: 2189 passed,
2 skipped. typecheck, lint (0 errors) and quality clean. The playtest suite's
`generated-shooter-input.spec.ts` fails identically with the merge forced off,
so it is a pre-existing end-to-end mouse-path failure, not this change.

* docs(PRD-475): Phase 2 cut 1 landed, Machinefall A/B pending (PRD-475)

The terrain super-tile cut is in the branch: the TN_TERRAIN_TILES census, the
resident-byte charge for a merged block, one block rebuilt per frame, and the
merge on by default with mergeTiles: false as the escape. The CPU render phase
box stays open — it needs the Machinefall A/B, not a unit test.

* perf(core): shadow levels read a caster table instead of walking the world (PRD-475)

Cut 2 of the open-world CPU render phase. `VirtualShadowNode.#probe` walked the
whole caster root on every level render — 3.9% of Machinefall's map-walk CPU
render phase — to re-derive a caster set a walking camera does not change.

Two memos, and neither of them decides anything:

- The caster set itself, rebuilt when three's own `childadded` / `childremoved`
  fire on a parent, or when a re-parented light moves the root. Not a version
  counter and not a child-count checksum: a streamed cell admitted and an
  evicted one in the same frame cancel out in both, and the level would then
  derive its depth from geometry it never saw. The listeners come off the
  previous walk before the next one starts, so an object that left the tree
  cannot keep marking this stale for whatever it holds now.
- Each caster's world sphere and box height range, keyed on the 22 doubles the
  two `applyMatrix4` calls actually read: the world matrix, the local sphere,
  and the local box's two Y values. A key that matches cannot have a different
  answer, so a mesh under a frozen subtree and a stag that walked are the same
  case and neither is special-cased — whatever changed its world matrix is
  recomputed, the rest are not.

Everything else is still read on the render itself: `visible`, `castShadow`,
the layer masks, the name, `userData.tnBundled`, the material and the geometry
all stay live reads, because a game changes them without touching the tree.
`#probe` output is byte-identical to the walk it replaces for the same scene,
and `#deriveDepth` still reads only the pool.

Tests, red before the change:
- `walks the caster root once for two renders of a world that did not change` —
  two renders of the finest level with an unchanged world. Red: the spied
  `scene.traverse` was called twice.
- `walks again when a caster joins the tree, and says the same thing as a fresh
  walk`, `sees a caster that stops casting, without the tree changing`, `sees a
  caster that moved, without the tree changing` and `drops a caster that leaves
  the tree` each compare the memoised node's reported bill, size gate and derived
  depth against a node that has never seen the world, and each assert the report
  changed. These are the guard on the memo: a memo is only worth having if it
  still says what a full walk says.

Core 2194 passed, 2 skipped. typecheck, lint (0 errors) and quality clean. The
Machinefall A/B and a playtest run are not claimed here — the PRD's CPU render
phase box stays open for them.

* docs(PRD-475): Phase 2 cut 2 landed, Machinefall A/B pending (PRD-475)

The shadow-caster memo is in the branch: the caster set is rebuilt only when
three's own childadded/childremoved say the tree changed, and each caster's
world sphere and box height are re-derived only when the numbers they are read
from changed. Four new specs hold it to what a full walk of the world says.
The CPU render phase box stays open — it needs the Machinefall A/B, not a unit
test.

* fix(core): terrain super-tiles never evict resident tiles (PRD-475)

b9442ec made `residentBytes` include `#blockBytes`, so a merged block --
a derived copy of tiles that are already resident -- was charged against
the same `residentByteBudget` that admits tiles. A budget sized for
unmerged terrain therefore held fewer tiles with the merge on: the
Machinefall map-walk correctness pair drifted 293 -> 282 -> 278 -> 248
resident tiles while `rebuilds` climbed to 336. Losing terrain detail is a
visual regression; the merge is supposed to change draws only.

Blocks stop counting toward tile admission. Their bytes stay visible as
`terrainTiles.blockBytes` (also on the TN_TERRAIN_TILES line), so the
memory a run holds is still observable in a browser console.

The byte-accounting spec from b9442ec ("charges a merged block's
bytes...") is rewritten to the new contract: the copy is reported, not
charged, so `residentBytes` is the same ring either way. The walk spec
that sizes its budget from the unmerged ring now reds on the ring
shrinking and greens with the fix.

Also: `?tnTerrainValidate=1` turned the seam and LOD-pop measurements on
but nothing printed them in a browser run. The TN_TERRAIN_TILES line now
appends maxSeamGap, maxLodPop and maxVisualSeamGap when validation was
asked for, and stays unchanged when it was not.

Gates: pnpm exec vitest run packages/core (2196 passed, 2 skipped),
pnpm typecheck, pnpm lint (0 errors), pnpm quality.

* docs(PRD-475): name the cut 1 fix commit

* fix(core): every resident terrain tile draws exactly once under the merge (PRD-475)

A walk spec drives TerrainTiles with mergeTiles on along a long straight-then-turning
follow path with a frame admission budget, and asserts four invariants every frame: a
settled tile is drawn by its own mesh or by the one block that holds it, never both and
never neither; a tile waiting for its block keeps drawing itself; residency equals the
merge-off walk; and the `tiles` stat is the resident count. Red at frame 18: a block kept
drawing a tile that had left it (a second copy of that ground, at the level the tile left)
for as many frames as the rebuild queue took to come back to it, and `tiles` counted the
same tile twice.

Two causes, both in how a block's geometry and its record could disagree:

- `#rebuildBlock` replaced a block's geometry but kept the member set from its first
  build, so the record the census counted and the leave-restore loop walked was the one
  the geometry had moved past.
- A tile leaving a block — evicted, or re-levelled — left that block drawn with the tile's
  geometry still inside it until this frame's one rebuild could replace it, and one
  rebuild a frame never arrives before the next departure. `#releaseStaleBlock` now
  dissolves the block the frame a tile leaves it: nothing stale stays on screen, every
  tile the block still holds goes back to its own mesh, and the rebuild queue brings the
  block back whole. Additions still wait for their rebuild and still draw themselves.

One rebuild per frame and vertex-exact merging are unchanged; draws on the walk stay
below the unmerged ring's 81 (41 at the end of the leg, 79 at the worst frame).

* docs(PRD-475): name the cut 1 fix 2 commit

Records what the per-frame walk invariant found under the merge and what fixed it, so
Phase 2's cut 1 line carries both correctness pairs rather than the first one alone.

* fix(core): a terrain super-tile sits where its tiles do (PRD-475)

A merged block's geometry is written relative to its block origin, but the
mesh was added at (0, 0, 0). Every block away from the world origin therefore
drew a whole blockOrigin from the ground it replaced: in Machinefall the
high/far poses showed vertically-streaked floating slabs over a hole where
the ground is, while the same build with `?tnTerrainMerge=0` was correct. Only
a block at (0, 0) hid it, which is the only case the unit tests merged.

The block mesh now carries the block origin its geometry is relative to. Two
tests in world-terrain-merge.spec.ts compare each block's vertices, read
through `matrixWorld`, with the vertices of the tiles it covers — one with an
identity parent, one under a translated terrain root. Red before
(48.016m off for a block at 0,1), green after.

Machinefall `apps/client` on core 0.3.4-prd475-cut12c-de8453459 (installed
dist sha256 equals the engine's), map-walk and map-views once each on
artifacts/prd-475/cut12c: walk-03-highway.png shows the full road and terrain
to the horizon with no floating slab, matching the merge-off reference.

Gates: `pnpm exec vitest run packages/core` 174 files / 2199 passed,
`pnpm typecheck`, `pnpm lint` 0 errors, `pnpm quality` exit 0.

* docs(PRD-475): cuts 1+2 measured on a clean machine

frame p50 9.55 -> 7.20 ms, p95 20.55 -> 17.22 ms, main draws 335.5 ->
246.5 against develop, 4+4 interleaved; blind judge SAME. Records the
third cut 1 fix (block placement).

* docs(PRD-475): cut 3 measured, not fixed - nothing re-uploads per frame

The cut-3 hypothesis was that per-frame resource version bumps make three
re-upload a compressed texture and rebuild node state per draw. A throwaway
probe in Machinefall's bundled three says no, on this build.

Counters over an 8400-frame map-walk (machinefall@8107d51, core prd475-cut12c):

- Textures.updateTexture past its version guard: 0 calls (plain, compressed and
  storage all zero). _copyCompressedBufferToTexture: 0. Nothing is re-uploaded.
- Source.needsUpdate: <=0.5 per 1000 frames. BufferAttribute.needsUpdate, which
  StorageBufferAttribute inherits, so the GPU scene's source/keys/locals/args
  are counted: <=0.6 per 1000 frames. Every one is a real write - mergeParts
  cloning a geometry during a streamed chunk merge, and GLTFLoader/KTX2Loader
  admitting a newly resident asset. #writePlacement and #writeKey only run on
  a placement or key changing, never per frame; the per-frame cull output
  (drawn) is written by the kernel and is not marked.
- NodeMaterialObserver.needsRefresh: 0.33 calls per 1000 frames, so the
  per-draw node-state refresh already takes its settled fast path.

The attrib2 profile agrees and corrects the stack summary it came from:
_copyCompressedBufferToTexture is 156.6 ms of 27.6 s, 0.57% inclusive, not
26% / 2.95 ms per frame. Its samples are ancestors of a path that is already
short-circuiting; the 34.1% getNodeBuilderState is the per-draw needsRefresh
check itself.

So there is no unnecessary bump to fix, and no test to make red. No engine
change and no commit for a fix that would only re-assert correct behaviour.

Probe removed: node_modules reinstalled with CI=true pnpm install --force,
three's build files byte-identical to their pre-probe state, no TNPROBE in
node_modules or dist.

* docs(PRD-475): cut 4 measured, not built - three already culls per level

A shadow level renders through its own ortho camera and three culls every
object against it (Renderer.js:3132, frustum.intersectsObject), so a level
already draws only the caster batches its window holds. A throwaway probe
through that exact path: 17 cluster batches at 24 m spacing, level 0 (48 m
window) kept 1, level 1 (192 m) kept 5, and a caster beyond the box toward
the light was still kept. The batches are spatially bounded per grid square
and publish their own bounds (#rebound, world-cells.ts:1148), so a
hand-rolled batch-bounds cut would test the same volumes a second time and
skip nothing three keeps.

Two brief premises corrected: frustumCulled = false (world-cells.ts:5952) is
#dressGpu, main-role only, and main meshes never cast (castShadow = false,
layer 0), so no caster batch is GPU-dressed and the main camera's dispatch
never sets a caster count.

* fix(core): a shadow level re-renders when its casters change (PRD-475)

A level holds its map until something asks it to redraw, and a caster
leaving or joining the world was nothing. Measured with three new specs
(T1 removed caster, T2 added caster, T3 caster that stopped casting) on
the harness the existing caster-draw spec uses: all three fail at HEAD
*and* at d47a24d^, so this was missing on develop, not a cut 2
regression. The ghost shadow the map-views camera jump leaves behind —
trees gone from the world, their shadows still on the ground — is this.

Two holes, both in `VirtualShadowNode`:

- `#onTreeChanged` only set `#casterStale`. That decides what the *next*
  render reads, and a level that never renders is the whole problem, so
  nothing ever asked. It now measures the changed child and calls
  `invalidateRegion` with its world box (a removed child's matrixWorld is
  the one the stale maps hold, so it is read as it stands; an added one is
  composed into its parent first). The caster's own `boundingSphere` is
  the region, which for a world cluster is its whole grid square — the
  only thing that measures where an evicted cell's instances stood. A
  caster with no bounds asks every level rather than none.
- `visible` and `castShadow` fire no event at all, so a tree listener
  cannot see them. `#pollCasters` reads both per frame off the memoised
  caster table — one flag pair per caster, no matrix, no bounds, no walk
  of the world — and asks for the levels covering a caster that changed.
  It runs before `#probe`'s own writes to those flags, which
  `#restoreHidden` and the mover exclusion both make and undo inside the
  same call.

Cut 2's CPU win is untouched: the table is still rebuilt only when the
tree changes, and the per-caster world spheres are still memoised on
their world matrix. Three new specs go green; the seven existing ones in
that file and the 2202 in packages/core stay green. `pnpm typecheck`,
`pnpm lint` and `pnpm quality` exit 0.

* fix(core): a removed caster invalidates every level its sphere reaches (PRD-475)

`#casterBounds` built its region from `setFromCenterAndSize` with the sphere's
*radius* as the full size, so the box it named was half the sphere on every axis:
it stopped at the sphere's own centre. A caster straddling a level or page edge
therefore named no level at all — the level whose window the far half of that
sphere reaches kept its map, and with it the shadow of geometry the world had
just dropped. That is the same ghost an eviction leaves, which is what
`invalidateRegion` exists to prevent.

The size is the diameter. `virtual-shadow-caster-draws.spec.ts` gained the case
that separates the two: a 16 m cube whose centre is parked `1.05 × radius` past
the finest window's edge, read off `node.clipmap` rather than hardcoded, so the
placement survives a change to the clipmap's own grid. It fails on the half-size
box with `rendered: 0, invalidated: 0` and passes with the diameter.

Gates: `pnpm exec vitest run packages/core` (2203 passed, 2 skipped),
`pnpm typecheck`, `pnpm lint` 0 errors, `pnpm quality` exit 0.

* fix(core): seed pending world builds before GPU scene activation

Restart CPU-started admission jobs before dressing main meshes so every
placement slice supplies GPU source records. Keep outgoing batches drawn
until replacement and return unpublished claims through the CPU path.

The regression previously supplied 164 of the CPU path's 262 placements.
Cover four activation timings, source matrix parity after eviction and
readmission, and multi-part capacity and bindings after partial eviction.

* fix(core): continue coarse shadow probing after skipped casters

The memoised caster loop retained two traversal-callback returns.
Alpha foliage or missing bounds aborted the entire level probe, leaving
later casters out of depth derivation and skipping caster-layer selection.

Replace both returns with continue. Add red-green specs for clustered and
wide casters under streamed subtrees across camera/caster movement, plus
a missing-bounds case. All three previously reported zero caster draws.

Core: 2211 passed, 2 skipped. Typecheck/lint/quality exit 0; lint 0 errors.
Pre-cut comparison: 12 coarse observations match. Browser A/B pending.

* docs(PRD-475): AC-3 and AC-4 pass on the fixed build

Blind 3-rater A/B against develop: 7 poses equal, 1 win, no loss.
Popping series: same as develop, 0 candidate-only pops, road shadows
match. Records the three visual fixes found on the way.

* fix(core): chunk shadow proxies draw whichever caster half a level selects (PRD-475)

Put chunk proxies on both shadow-only layers and count their shared cost independently of the caster-half choice.

Red: wide-half level drops the WorldCells proxy on 6bfe5f0 and develop d727783; cluster-half control passes.
Green: chunk-merge and virtual-shadow-caster-draws specs, 23 tests. Browser proof remains with the Machinefall owner.

* perf(core): preserved opaque chunk parts cast through one shadow proxy per side (PRD-475)

Consolidate retained static instances into separate per-side shadow-only proxies while preserving each source draw range, placement, side, visibility, frustum and per-level texel gate. AutoLOD, alpha, morph and deforming sources retain their own casting. Main geometry, materials and hierarchy are unchanged.

Freeze proxies after parenting so chunk-local vertices draw at their world position. Red-green triangle comparisons include indexed and non-indexed inputs, quantized positions, nested transforms, rotations, partial ranges, all three levels and both caster halves. Five layer-0 casters become zero plus two proxies; eviction releases both.

Gates: core 2216 passed / 2 skipped; typecheck exit 0; lint exit 0 with 0 errors and 997 warnings; quality exit 0. Browser Machinefall timing and visual A/B remain pending.

* docs(prd): record chunk shadow consolidation commit SHAs (PRD-475)

Record both source commits on the requested Cut 5 line. Machinefall visual and performance A/B remain pending.

Documentation links passed; 180 focused documentation and CI-structure tests passed.

* fix(core): merged chunk shadow proxies stay off wide shadow levels, as on develop (PRD-475)

Putting merged proxies on both caster halves (7376879) made coarse, wide-half
levels draw merged slabs into their large texels. Machinefall's bridge deck then
self-shadowed as wide acne stripes across the road (map-views v3, walk-04); an
ablation build with only 7376879 reproduces it, develop a602467 does not.

Merged proxies return to the cluster half only. Retained proxies (cut 5) keep
both halves: they stand in for layer-0 casters, which every level drew.

Red: chunk-merge spec "wide casters draws the merged chunk proxy: false" and the
proxy layer-mask assertion fail with the proxy on both halves. Green: 26 tests.

* docs(prd): cut 5 browser result, bridge-deck acne and its fix (PRD-475)

* perf(core): repeated chunk parts draw as one instanced group (PRD-475)

Share exact preserved resident chunk buffers through projection's existing
uniform-colour lane. Keep every vertex channel, original per-source culling
and shadow depth, stable velocity slots, eviction ownership and independent
AutoLOD selection. Admit only lossless rigid transforms and identity-model
EXT groups; keep other parts on their existing lane.

Red: three chunk files submit 18 main/layer-0 candidates instead of 4.
Green: 18 -> 4 with identical triangle/attribute/colour inputs; eviction,
LOD, clip-level bounds, live buffers and per-pass history specs pass.

* docs(prd): record Cut 6 implementation SHA and checks (PRD-475)

Link the verified chunk instancing commit; Machinefall A/B remains pending.

* Revert "docs(prd): record Cut 6 implementation SHA and checks (PRD-475)"

This reverts commit 4762c74fd24ddac9bcdb21970d7b85d89c80f689.

* Revert "perf(core): repeated chunk parts draw as one instanced group (PRD-475)"

This reverts commit 3353732dc8f7f8d4132619bc7ca554b9f3e5fc4c.

* docs(prd): cut 6 reverted (bridge-deck acne), cut12i timing split walk vs idle (PRD-475)

* perf(core): retained shadow proxies rewrite their index only when a level's selection changes (PRD-475)

Keep a camera's selected index geometry between shadow-level draws, sharing
positions and reusing a bounded pool of three buffers. Dispose the whole pool
with the chunk, including when projection draws through an exact stand-in.

Red: both caster halves reported 12 index-version bumps after warm-up instead
of zero. Green: 32 focused specs preserve exact triangles, hidden parents,
changed gates, bounded allocation, eviction and Three's hook-time geometry swap.

The single requested full core run passed 2231 tests and skipped 2, with one
constraint failure caused by array .map matching the material-property ban.
Replace it with a loop; the focused constraint and regressions pass afterward.
Typecheck, lint (0 errors) and quality exit 0. Browser performance is unverified.

* docs(prd): cut12k timing and visuals, walking-tail attribution (PRD-475)

* perf(core): visit only active terrain LOD blends (PRD-475)

Red: blendingTiles walked Map.values once for 25 settled tiles.
Green: zero scans, identical settled vertices; 10 terrain specs pass.

* perf(core): skip unchanged terrain neighbor LOD scans (PRD-475)

Red: 75 resident-map lookups per settled follow, expected 25.
Green: 25 lookups and identical drawn positions; 11 terrain specs pass.

* perf(core): share measured shadow and streaming frame budget (PRD-475)

Reuse AdmissionBudget across shadow rendering and WorldCells streaming.
Cull before the world draw, then meter deferrable streaming against actual
static and mover shadow costs. Keep existing local caps, force one waiting
stage per update, and bound each stage to five frames. Expose frameWorkBudgetMs
for an explicit allowance or opt-out.

Red: shadow frame called terrain follow once instead of zero; cached static
maps with two mover draws charged zero instead of six measured milliseconds.
Green: shadow frames hold terrain, queued batches, seams and active morphs;
continuous exhausted frames complete the same work and produce identical
settled terrain positions and instance matrices. The game-loop spec verifies
cull, world draw, then deferred streaming and clean release on stop.

* revert: share measured shadow and streaming frame budget (PRD-475)

This reverts commit c13e35a.

Measured in Machinefall map-walk on the RTX 2080 (clean-ab5, pairs 1-3, quiet
gate): walking render p95 26-35 ms against develop's 21.5-24.7 ms, p50 no
better. Moving streaming after the draw and deferring it in shadow frames
stacked the backlog instead of spreading it. The first walk capture also
caught the bridge pose before a shadow level caught up (blind LOSS, not
repeated on recapture). The two exact scan cuts before it stay.

* docs(prd): PRD-475 frame-budget revert result (cut12l timing and visuals)

* perf(core): reuse shadow shader state across levels (PRD-475)

Share three's stock constant shadow-colour node across the virtual level
materials. Keep each material, camera and draw binding separate; the existing
caster prewarm now fills the node-state key reused by later levels.

Red: world-cells-shadow-prewarm.spec.ts expected one state, found two for
nine prewarmed casters on 4839631. Green: node-state cache misses at the
loading gate fall from 34 to 21 (shader compiler stubbed); no late misses
for prewarmed keys. Source ranges, materials and instance counts match on
every pass. 39 settled draws still refresh, with no object creations or
geometry swaps: steady per-object overhead is unchanged.

Checks: core 2246 passed, 2 skipped (--maxWorkers=2); typecheck exit 0;
lint 0 errors; quality exit 0. Browser playtests were not run.

* docs(prd): PRD-475 partial — merge the shipped cuts, walking p95 moves to PRD-478

* docs(perf-state): PRD-475 merged-state frame numbers and the tail handed to PRD-478

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…Q11] (#394)

* fix(shooter): release owned decal materials on teardown

* fix(shooter): release scene-owned decal texture

* feat(shooter): add opt-in bounded receiver decal projection

* docs(vq11): retain inspected hosted decal screenshot progress

* test(vq11): discriminate visible decals from populated hidden slots

* docs(vq11): record verified visible marks and hidden-material control

* docs(vq11): retain verified atlas fade captures and open residency failure

* test(vq11): sample residency after completed stable world renders

* docs(vq11): record zero-growth rendered lifecycle and browser qualification

* test(vq11): qualify packaged native decals through supported bridge observations

* docs(vq11): retain native decal progress and failed residency observation

* test(vq11): observe completed native decal renders across counter resets

Sample draw counters after the ordinary world render and consume each snapshot once on the next game update. Preserve the original 256-draw, stable-interval and zero-growth gates. Includes a real Three Info.reset regression and strict missing/repeated snapshot controls.

Independently reviewed at local 185b1ef7; actual repaired native qualification remains pending.
Plans absorbing fluid-lab-v2.html as FluidParticles3D (GPU PBF), reusing
Buoyancy3D, RippleField and GPUParticles3D, and a sandbox fluid-lab game.
Measured cause of the 30 fps wall: the CPU PBF step is 29.9 ms at the
default quality, 1.8x the frame budget.
…ew fails

The threenative-assets skill told agents to run `fabcli auth login`, whose
WebView dies on Wayland (Gdk Error 71). finding-assets.md now documents the
working path: `fabcli auth login --manual` under a pty fed by a FIFO, the
user pasting the authorizationCode from Epic's redirect endpoint, and the
pty echo landing in the log, which must stay private and be deleted. The
skill points to it in one sentence.

The skill ships in every scaffold, so all eleven scaffold tree hashes move;
recomputed through createProject by the spec that asserts them.
Self-hosted ephemeral tn-local runners behind a TN_RUNNER kill switch and a
fork guard; macOS, Windows, iOS and arm64 stay hosted.
…nt loop

From a 14-day Actions audit (115.5k runner-min): tree-keyed verdict reuse plus a
merge queue, shared dist, warm native cache, resized shards, narrower triggers;
a green fast pre-push hook and a push rule for the 43.1k min of cancelled runs.
PRD-480 names the right repository.
…op stale PRD-364 copy

PRD-379 phase 2 observed on main: CI 36134527157 (2 attempts) triggered
native-release 36140281930 and 36142531873, gates 39 s, no polling.
PRD-303: all five doors closed by other work; moved to done as superseded.
PRD-364: the CI/ copy was a stale draft resurrected by e8ee477; done/ holds
the completed one. PRD-380 reshaped to current PRD rules.
Biome now skips git-ignored files and .leanpi/, which is excluded through
.git/info/exclude where useIgnoreFile cannot see it. The native-overlay PRD
links were one directory short. PRD-482 phase 1 groundwork.
* ci(prd-481): integration and pipeline-cache triggers fire once, on paths they read

Phase 3 of PRD-481. The audit behind it cost 2.4k runner-minutes on
pipeline-cache alone, all of it on a branch whose lane had landed three
weeks earlier.

- pipeline-cache.yml: `push.branches` loses feat/prd-368-persistent-pipeline-cache.
- integration-csg.yml: the unfiltered `push` beside `pull_request` is gone, so a
  commit with a PR no longer runs the proof twice.
- integration-decals.yml: the per-branch `push` for fix/vq11-decal-material-lifetime
  is gone; PR #394 merged on 2026-10-02 and the branch outlived it.
- every integration lane now skips draft pull requests, and every one of those
  triggers gained `ready_for_review` so the draft guard cannot silence the lane
  instead of cheapening it.
- ci-structure.spec.ts: a workflow-file allow-list read off disk (a new workflow
  has to be named here to land), the push+pull_request and per-branch rules with
  fixture cases, and an assertion that pipeline-cache fires only on paths its own
  steps read.

Both new assertions fail on the pre-change workflow files and pass after.

* ci(prd-481): consumers take the compiled workspace from build-artifacts; native cache key drops ci.yml

Nine jobs each compiled the workspace dist (71-95 s) because they started in
parallel with its producer. They now need build-artifacts and download its
upload. The native-build cache key no longer hashes ci.yml, so a workflow
edit stops forcing a cold 1.35 GiB Linux host build.

* ci(prd-481): shard counts that put every measured leg inside six minutes

Phase 2's third box. Both lanes shard by a rule that knows nothing about duration, so
the run's slowest leg is decided by arithmetic nobody measured. Timestamped on run
37049488719.

test-unit, 3 -> 4 slices. `vitest --shard` sorts the file list by sha1 of its path and
slices that order, so the split is exact about file count and blind about time. The
three legs ran 163s / 285s / 472s of vitest over 397s / 712s / 709s of test time.
Replaying vitest's own partition over those per-file durations reproduces all three
assignments, and at four slices predicts 80s / 114s / 211s / 320s of vitest — three
legs inside six minutes once the 40-121s of job overhead is added.

The fourth leg cannot be, and no shard count fixes it: it carries
`create-threenative/__tests__/template-assets-compile.spec.ts` at 320s on its own, and
a shard cannot split a file. A fifth slice leaves both numbers unchanged and buys a
runner.

template-nonvisual. Per-scenario seconds on the same run, with the ~65s each leg spends
on its own setup:

  shooter 26 at 32-47s, 922s total -> 4 -> 363s, 5 -> 356s, 6 -> 288s. Six.
  sailing 6 at 17/163/23/75/22/44  -> 2 -> 347s.
  rain    5 at 331/202/222/78/61   -> 5 -> 396s, and that is its floor: five scenarios,
    one per slice, heaviest alone 331s of software rendering. It cannot reach six
    minutes at any shard count, and it is not split past its scenario count to pretend
    otherwise.

The box stays unticked: both lanes still have a leg over six minutes that no shard
count reaches, and its proof is a full CI run's per-job durations.

ci-structure.spec.ts holds the measured template counts (puzzle 2, sailing 2, rain 5,
shooter 6) beside the partition and scenario-count guards it already had.
196 passed; the three new pins fail on the pre-change workflow.

* ci(prd-481): a tree that already passed does not run the board twice

The board is chosen from a whole-repo tree SHA rather than from the diff. When the
candidate's tree is the tree a completed, successful `CI` run already tested, `scope`
emits `selection: reused` with that run id, every gate skips, and `ci-required` passes
only after reading the source run's own `ci-required` conclusion through the Actions
API.

- `scripts/ci-change-scope.mjs`: `findReusableRun` lists the recent successful CI runs
  (`gh api`, the job's own `GITHUB_TOKEN`, `actions: read`), compares each run's
  `git rev-parse <head_sha>^{tree}` with the candidate's, and fetches a commit this
  clone cannot see rather than assuming it. A miss, an unreadable or uninterpretable
  listing, `workflow_dispatch`, an explicit `--full`, a non-Actions run, and Sunday's
  nightly all keep the full board; the miss reason is appended so a full run that never
  looked cannot read like one that looked and found nothing.
- `scripts/ci-required.mjs`: a reused verdict resolves the source run by id and fails
  with `CI_REQUIRED_SOURCE_UNKNOWN` / `CI_REQUIRED_SOURCE_NOT_SUCCESS` unless that run's
  own ci-required job concluded success.
- `.github/workflows/ci.yml`: `merge_group` trigger (types `[checks_requested]`); the
  draft guards stay true for a merge group because it carries no pull_request object;
  `lint` and `supply-chain` now name the two selections they admit instead of refusing
  `prose`, which would have read a reused tree as "not prose" and re-run them.
- `AGENTS.md`: "Never cache test verdicts" becomes the 2026-10-02 decision wording, and
  the CLAUDE.md mirror is regenerated.

Verification: `pnpm exec vitest run scripts/__tests__/ci-needs.spec.ts` 25 passed (4 of
the 5 new cases red before the change); the five-spec lane (ci-structure, ci-needs,
ci-efficiency, sync-agent-docs, primary-docs) 218 passed; `pnpm lint` exit 0;
`pnpm typecheck` exit 0; `pnpm check:docs` 2496 links clean; `pnpm sync:agents --check`
in sync. The CI-run boxes stay unticked: nothing here has run on GitHub yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(prd-481): a reduced pass never stands in for a fuller requirement

An identical tree is not the same validation. PRD-380 gives an ordinary pull request a
reduced native matrix and the planner already lets a clean develop pull request skip
`native-platforms`, so the previous contract — identical tree plus a green source
`ci-required` — let a develop pass satisfy a promotion on that tree. Reuse now requires
the source run to cover what this run proves, and any gap fails closed into the full
board.

The profile is a pure function of the run's event, its base branch and whether it owed
the native lane, and it is computed the same way for the source and for this run:

- `validationProfile` returns `{ target, native }`. `target` is `main` for a promotion or
  a main push, `develop` for an ordinary pull request or the merge queue, and `other`
  for anything whose target the API does not report — which ranks lowest, so an
  unproven target never stands in for a proven one. `native` is `none`, `reduced` or
  `full`, following the target.
- A run record in this repository reports no `base_ref`, so `sourceBase` reads a source's
  target from the pull requests its commit belongs to (`commits/<sha>/pulls`); nothing
  readable is no base.
- `currentRun` reads this run's own job graph (`actions/runs/$GITHUB_RUN_ID/jobs`) for its
  required set — every board job and matrix leg its job graph carries, minus the ones the
  plan waived, since a waived job is still listed as a skip — and for the runner class
  its routing chose.
- `coverageMiss` refuses a source whose profile is weaker on either axis, that never ran
  a required leg, whose leg concluded anything but `success`, or whose jobs ran on
  another runner class (`hosted` or `tn-local`, from the labels and `runner_name` the
  jobs API reports). A reusable workflow's `native-platforms` arrives as one check, so
  the lane's own legs stay inside it.
- A `workflow_dispatch` is now excluded as a reuse source as well as a reuse target, and
  a truncated job listing is a miss rather than a quietly shorter requirement set.

`ci-required` repeats the coverage check with its own reading of the source run, so a
reuse it cannot confirm is not a pass; it re-derives only what policy owes each target,
because a reuse plan records nothing about what was required.

Verification: `pnpm exec vitest run scripts/__tests__/ci-needs.spec.ts` 30 passed (5 red
before the change: the four coverage cases and the reused verdict); the five-spec lane
(ci-structure, ci-needs, ci-efficiency, sync-agent-docs, primary-docs) 223 passed;
`pnpm lint` exit 0; `pnpm typecheck` exit 0; `pnpm check:docs` 2496 links clean;
`pnpm sync:agents --check` in sync. The CI-run boxes stay unticked: nothing here has run
on GitHub yet.

* ci(prd-481): one integration workflow, one job per lane

Five workflow files each carried their own triggers, permissions and concurrency group
for one feature's proof. The owner's rule is that no feature gets a workflow file: its
proof is a job in an existing workflow. `integration-csg`, `integration-decals`,
`integration-ik`, `integration-tone` and `integration-vegetation` fold into
`.github/workflows/integration.yml`, one job per feature, each with the paths filter,
draft guard, runner, checkout ref, timeout and artifact names it had before.

- `.github/workflows/integration.yml`: one `paths` gate job reads the pull request's own
  changed paths once with `dorny/paths-filter` (pinned to the v4.0.3 commit) and publishes
  one output per lane, so a lane this commit does not touch is skipped rather than given a
  runner, and editing the workflow still re-runs every lane (each filter names it, as each
  old trigger did). The csg and decals supersession groups become one; every step, timeout,
  runner line and artifact name is unchanged.
- `scripts/__tests__/ci-structure.spec.ts`: the workflow allow-list names one integration
  workflow and rejects a new `integration-*.yml` by name; a second assertion reads the merged
  file and requires one filter per lane, a job behind each gate, and the draft guard on every
  gated job.
- `scripts/workspace-packages.ts`: the hand-listed-package gate counted distinct package names
  per *file*, so merging five files put two unrelated targeted `--filter` builds in one file
  and tripped it. It now counts per `run:` block — one command or one run block naming several
  packages is the drift this gate exists for; two targeted builds in two unrelated jobs are two
  builds — with a case for each direction.

Verification: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts scripts/__tests__/ci-efficiency.spec.ts scripts/__tests__/sync-agent-docs.spec.ts scripts/__tests__/primary-docs.spec.ts`
223 passed (131 in ci-structure, whose two new assertions were each red first: a lane without
`needs: paths`, a lane gate without the draft guard, and an added `integration-newlane.yml` for
the allow-list); the whole `scripts/__tests__` suite 1638 passed, 1 skipped; `pnpm lint` exit 0;
`pnpm typecheck` exit 0; `pnpm budgets` ok; `pnpm check:docs` 2496 links clean;
`pnpm sync:agents --check` in sync.

For the owner: three checks change name (`test` becomes `csg`, `ik`, `vegetation` — they were
three indistinguishable checks), a ~30-second gate job now appears on every pull request, and the
three lanes that queued behind a superseded run are cancelled by it instead. Branch protection
naming those checks has to follow.

* ci(prd-481): the integration gate reads changed files itself, no third-party action

dorny/paths-filter was new to the repo for what one gh api call and a regex per
lane do. Each lane's former paths globs translate one-to-one; spot-checked hits
and misses per lane.

* fix(ci): scope fixtures never inherit GITHUB_ACTIONS from the CI job

Run 37071464562, test-unit (3/4): inside CI the workflow-only classification case
turned on the live tree-reuse lookup and its reason grew an Actions API error.
isolatedGitEnvironment now strips GITHUB_ACTIONS; red under GITHUB_ACTIONS=true,
then 131 passed. PRD proof markers moved onto each box first line.

* ci(prd-481): the two legs a shard count cannot reach

Phase 2's third box, measured on run 37071464562: `test-unit (4/4)` ran 481s and
`template-nonvisual (sailing, 2/2)` 467s against a six-minute leg. `rain (3/5)` 358s and `rain
(2/5)` 299s are the rest of it — every other unit and template leg was under 360s. Two changes,
each from a timestamp rather than a preference.

`template-nonvisual`. Sailing's six non-visual scenarios measured 55/247/30/98/28/59 seconds in
classifier order (fail, float-after-the-run-ends, float, sails, touch-controls, win). Two slices
put `float-after-the-run-ends` alone with `sails` and `win` — 404s of scenario work, and the same
run attributes ~63s of overhead to a slice, which is the 467s it measured. Three slices put one
heavy scenario on each and the worst at 275s of work: ~335s predicted, and 3 is the smallest count
that clears it. Six scenarios, three slices, nothing dropped.

`test-unit`. The fourth leg carried `create-threenative/__tests__/template-assets-compile.spec.ts`
at 317s on its own. `vitest --shard` sorts the file list by `sha1(path)` and slices that order: it
divides a list of files, never a file, so no shard count reaches that 317s. The spec is now two
files over the two halves of the template list, with the cases in
`template-assets-compile-cases.ts` beside them — the same 40 tests, the same names, the same
assertions, all 13 templates, and the one whole-suite assertion kept once instead of copied into
both, with `TN_TEMPLATE_ASSETS_HALF_EMPTY` failing closed where a slice could come out empty.
Replaying vitest's own partition over that run's per-file durations, which reproduces its four legs
exactly (261/429/554/630s of test time), predicts 180/240/224/137s across the four legs.

The partition is still blind to duration: the two halves' hashes land them in the two lightest
legs, and renaming either file reshuffles that. Five slices would predict a 223s worst leg for one
more runner; four is what the measurement needed.

- `.github/workflows/ci.yml`: sailing 2 -> 3 slices, with the per-scenario seconds that chose it.
- `packages/create-threenative/__tests__/template-assets-compile-cases.ts` (new): the templates,
  the two halves and the three cases, once.
- `packages/create-threenative/__tests__/template-assets-compile-{1,2}.spec.ts` (new),
  `template-assets-compile.spec.ts` (deleted).
- `scripts/__tests__/ci-structure.spec.ts`: the measured sailing pin, 2 -> 3.
- `docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md`: the overs from 37071464562 and what
  this commit changes under the shard box, which stays unticked — its proof is a full CI run.

Verification: `pnpm exec vitest run packages/create-threenative/__tests__/template-assets-compile-1.spec.ts
packages/create-threenative/__tests__/template-assets-compile-2.spec.ts` 40 passed in 2 files
(120.8s and 142.7s of file time), against the 40 the unsplit file measured in 270.6s;
`pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-efficiency.spec.ts
scripts/__tests__/ci-needs.spec.ts` 208 passed; `pnpm lint` exit 0; `pnpm typecheck` exit 0. That
run's `test-unit (3/4)` failure was `ci-structure.spec.ts > classifies workflow-only changes as
full CI with no prose skip`, which passes here and is untouched by this change.

* fix(ci): a draft pull request run never cancels a ready one

Pushing then marking ready fires a synchronize run whose payload still says draft;
in the shared 'latest' group with cancel-in-progress it skipped every job and
cancelled the real ready_for_review run (#403, run 37075825121). Draft runs now get
a per-run group. New ci-structure case red first; PRD-481 dist-once box ticked.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(prd-380): the release proof stops running on every PR push

native-release.yml ran its macOS/Windows/Linux/Android board on any PR touching one of four
paths, one of them (`native-platform-workflow.test.mjs`) touched by ordinary runtime-native
PRs: 7.0k runner-minutes over 2026-09-18 to 10-02, against a pool that holds about three runs.

- `pull_request.paths` is replaced by `types: [opened, synchronize, reopened, labeled]`.
  `labeled` is what makes the `release-proof` label route reachable; the open/reopen events are
  what make a promotion PR reach it.
- `gates` — which every proof job `needs:` — refuses a PR that is neither targeting `main` nor
  carrying `release-proof`, so an ordinary push to develop spends no runner. A promotion PR is
  `develop -> main`: PRD-373 retired the `promotion/<sha>` ref on 2026-09-12.
- One shared `group: native-release-proof` across branches, `cancel-in-progress: false` kept, so
  at most one release proof holds the pool. This reverses the old event-name/head-SHA keying on
  purpose; queueing behind a superseded proof is now the cheaper of the two.
- npm-release.yml and release-candidate.yml trigger no part of this proof and are unchanged; a
  new assertion pins npm-release's `v*` tag route so narrowing the native trigger cannot quietly
  narrow it too.

Tests: native-release-proof.spec.ts 61 passed, ci-structure.spec.ts 123 passed, ci-efficiency
47 passed (231 across the three). New assertions were red first — 3 in
native-release-proof.spec.ts (ordinary PR still runs, no `labeled` type, per-run concurrency
group) and 2 in ci-structure.spec.ts (the existing group's string, the new cross-branch case).

Phase 1 of PRD-380. Phase 2 untouched; phase 3 follows in the next commit.

* ci(prd-380): a merged or closed PR stops spending runners

A closed pull request's runs prove nothing — no verdict can change, no artifact gets read — and
the head ref went on holding runners until each one finished on its own clock: 5.0k runner-minutes
over 2026-09-18 to 10-02.

New `.github/workflows/ci-janitor.yml`, one job on `ubuntu-latest`:

- `pull_request: types: [closed]`, so it fires within a minute of the merge.
- `permissions: actions: write` — cancelling a run is an Actions write, and a token that cannot do
  it reports a green job while cancelling nothing.
- The head ref arrives through `env:`, never interpolated into `run:`: it is branch text chosen by
  whoever opened the PR, and this job holds `actions: write`.
- Fork PRs are skipped by a job-level `if`, because a fork's runs live in another repository and
  cannot be cancelled from here — trying would put a red check on every fork PR close.
- Lists the head ref's `queued` and `in_progress` runs, skips the janitor's own run id, and cancels
  each with `gh run cancel` scoped to this repository. The lookup is collected before the loop so a
  failed call fails the step instead of cancelling nothing; a cancel that loses the race to a
  finishing run is reported, not propagated.

`ci-structure.spec.ts` gains the janitor case (red first: no such file) and now reads ci-janitor.yml
in its `gh`-needs-an-explicit-repository sweep. The existing "no job cancels its own run" guard keeps
its ban and names ci-janitor.yml as the single exception, with its four bounding conditions pinned.

Tests: ci-structure.spec.ts 124 passed; 232 across the three specs.

Phase 3's box stays unticked — its proof is the janitor run id and the cancelled run ids on one
merged PR, which needs a live merge. Phase 2 untouched.

* fix(ci): janitor skips integration-branch heads; only proof runs share the release group

ci-janitor: a promotion PR's head is develop, so closing it would have cancelled
develop's own CI runs. native-release: with the paths filter gone, every PR push
starts a no-op run; in one shared group those would queue behind a real proof and
replace a pending one. Non-proof runs now get a per-run group.

* ci(prd-380): the workflow allow-list names ci-janitor.yml

PRD-481 on develop rejects any unlisted workflow file; the janitor is reviewed here.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
* ci(prd-482): pre-push hook runs its stages in parallel, without the scaffold typecheck

The four ci:fast stages share nothing, so they now run at once: 9.2 s wall
on a clean develop, from 42 s with three stages red. template.spec.ts leaves
the hook: its scaffold typecheck reads built dist and went red on any stale
checkout. CI runs it in the test job and ci:local keeps it.

* docs(prd-482): phase 1 complete; CI unit shard still runs template.spec.ts

* ci(prd-482): ci:fast names the missing dist on a never-built checkout

Every fresh lane worktree failed drift with three resolve stacks; it now exits 2
with one line saying the checkout needs a workspace build first.

* docs(prd-482): push once per run; ponytail context once per session

AGENTS.md gains the push rule (commit locally while CI runs; a PRD tick rides
with the next code push). The ponytail hook drops UserPromptSubmit and
SubagentStart, about 400 tokens per turn; SessionStart still covers compaction.

* docs(playtest): cut AGENTS.md from 5,256 to 1,113 words, moving reference into two linked docs

The always-loaded half of playtest rules is what an agent pays for on every playtest turn, and
5,256 words of flag tables and assertion vocabulary was most of it. AGENTS.md now carries only
the rules: how to run a scenario, the traps (never `--headless`, never `xvfb-run`, a private
Xvfb's frame rate is wrong not missing, a bridge failure is not a reason to delete the
assertion), the fail-closed rule with its named failures, tick semantics, where things live, the
salvaged-code boundary and the test-layout trap.

Twelve sections moved verbatim to `packages/playtest/docs/reference.md` (flags and exit codes,
the private-Xvfb frame rate, `assert.tone[]`, one scenario four targets, `perf`, `audio`,
startup observations, `deviceMetrics`, the room and the feet, `assert.causedBy`, `setup`/`aimAt`)
and `packages/playtest/docs/determinism.md` (ticks and the startup-ready wait). Every section
that left keeps a one-line link, and a coverage diff over every line of the old file finds no
paragraph dropped — the only edits are the intro sentence and the scaffolded-project paragraph,
both re-wrapped into the bullets that replaced them.

Gates: `wc -w packages/playtest/AGENTS.md` 1,113 (< 2,000); `pnpm check:docs` 2,504 links across
1,223 files clean; `pnpm sync:agents --check` 22 mirrors in sync; sync-agent-docs, primary-docs,
instruction-budget and check-doc-links 36 passed; packages/playtest/__tests__/doc-drift.spec.ts
2 passed; `pnpm lint` exit 0.

PRD-482 Phase 2 is now COMPLETE (5/5 boxes); the PRD reads PARTIAL because AC-1 audits the seven
days after this lands.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…er feature (#408)

* docs(prd): CI execution order adopts the Astra review; fork approval done

Reuse must match validation, not only the tree; a light runner lane; the merge
queue waits for merge_group and starts at build concurrency 2.

* docs(agents): no workflow file per feature

About ten per-feature "Integration X" workflows exist only on PR branches, each
running on its own. A feature adds its proof as a job in an existing workflow;
PRD-481 phase 3 folds the existing ones and its allow-list rejects new files.

* docs(prd): CI execution order records what landed and the flaky template lanes

Rows now carry state, not plans. New row 5: tower-defense and rain template
lanes fail about half the time and bounced the merge queue three times. AC-2 and
the 7-day acceptance criteria are named as what is left.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
* ci(prd-480): the tn-local runner stack, without compose

Phase 1 of PRD-480: the image, its entrypoint, and the host-side
lifecycle script. No runners are started and nothing is registered
yet — the script fails closed without a token, and `status` reports an
empty pool.

No compose.yml (owner-approved deviation, noted in the PRD): a host-side
loop of `docker run --rm` is what makes each job a fresh container
filesystem and hostname, which `restart: always` on one long-lived
container cannot.

Evidence:
- `docker build -t tn-ci-runner tools/ci-runners` — clean.
- smoke: cmake 3.28.3, clang 18.1.3, ninja 1.11.1, /usr/bin/{Xvfb,ccache,jq},
  `ls -A /tmp | wc -l` → 0.
- `TN_RUNNERS_ENV=/nonexistent scripts/ci-runners.sh up` → exit 2, nothing built
  or started. `up 0` and an unknown subcommand also exit 2.
- `docker run --rm tn-ci-runner` without the env file → exit 1 naming
  RUNNER_ADMIN_TOKEN.
- `scripts/ci-runners.sh status` → TN_RUNNER=unset, 0 containers, 0 online.
- `bash -n` clean on both scripts (no shellcheck installed here).

* ci(prd-480): route every movable Linux job through the TN_RUNNER switch

Phase 2 of PRD-480. 23 `runs-on` lines (18 in ci.yml, one each in the five
integration-*.yml) now read:

  runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}

Deleting the TN_RUNNER repository variable is the whole kill switch, and a fork
PR always runs hosted. `supply-chain` keeps ubuntu-latest because gitleaks needs
`docker run` and the socket would hand every job root on the host;
integration-decals.yml's ubuntu-24.04-arm job and every macOS/Windows job are untouched, as
is native-platforms.yml (Phase 3).

A new ci-structure.spec.ts test enforces it: every job in ci.yml and the
integration workflows either carries that exact expression, is on the hosted
allow-list, or is a non-Linux runner.

Evidence:
- red, no workflow edited: "ci.yml scope runs on `runs-on: ubuntu-latest`".
- red, after routing, ci.yml:1051 (budgets) put back on ubuntu-latest: same test
  failed naming that job, then it was restored.
- green: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts
  scripts/__tests__/ci-needs.spec.ts` — 142 passed, 0 failed.
- `pnpm lint` exit 0, `pnpm check:docs` 2496 links, `pnpm sync:agents --check` 22 mirrors.
- all six workflows parse as YAML and every runs-on resolves to one plain scalar.

One pre-existing assertion was narrowed rather than deleted: template-nonvisual
forbade the substring `pull_request` anywhere in the job, which the routing
expression mentions in every job without gating on it. It now forbids an `if:`
gate naming pull_request.

* fix(ci-runners): the runner image works from /home/runner, where config.sh lives

First live up: 0 of 4 online, the pool tore itself down and cleared TN_RUNNER.
With the fix, 4 tn-local runners come online.

* fix(ci-runners): the image carries Node 20 and gh, as hosted ubuntu-24.04 does

The scope job runs node straight after checkout; on tn-local it failed with
"node: command not found" (run 37061064658).

* fix(ci-runners): hosted parity — Rust and media tools, and CPUs pinned per slot

PR #404 run 2: test-native failed on "rustc: command not found"; three unit tests
timed out because a --cpus quota leaves nproc at 24, so vitest ran 8 workers in a
4-core budget. Each slot now owns two whole cores (nproc = 4, like hosted), and the
image adds rustup stable, 7zip, ffmpeg, imagemagick, zstd, file, lsof, procps and
mesa-vulkan-drivers.

* fix(ci-runners): five slots on whole cores, two cores and the RAM kept for the host

12 GB per slot with no extra swap (5 x 12 = 60 of 62 GB worst case, typical jobs far
less) and --oom-score-adj 800, so memory pressure kills a CI job before anything
of the owner's. up refuses a pool that would take the host's two reserved cores.

* fix(ci-runners): drop mesa-vulkan-drivers from the image, as hosted has none

Workflows that need Mesa install it themselves. With it baked in, the tone and
decals captures went blank on tn-local (run 37062759406: TN_CAPTURE_BLANK) while
the same job passed on the image without it.

* docs(prd-480): pinned five-slot pool, host reserve, and a reserved light lane

Records the pool shape measured on the first live runs and the owner's stability
constraint, and adds Astra's light-lane review point as a Phase 2 box.

* ci(prd-480): give small joins an unpinned tn-local-light lane

A ten-second verdict queued behind five twenty-minute builds is the wait
this removes. `ci-runners.sh up` now starts one light slot alongside the
heavy ones: same image and host loop, `--cpus 1 --memory 2g
--memory-swap 2g --oom-score-adj 900`, no cpuset, registering
`tn-local-light` (entrypoint takes the label from `TN_RUNNER_LABELS`), and
it advertises `TN_RUNNER_LIGHT` only once that runner is online — so the
kill switch stays one deletion and `down` clears both variables before it
stops anything.

`scope`, `golden-path`, `build`, `ci-required` and `run-summary` route to
it: the five ci.yml jobs whose whole work is a script or a summary, none of
which builds the workspace or runs a test suite. Every other movable Linux
job keeps the TN_RUNNER expression. `ci-structure.spec.ts` now requires
each Linux job to use exactly one of the two expressions, and asserts the
light allow-list in both directions, so a heavy job on the light lane and a
light job off it both fail.

* fix(ci-runners): JDK 17 in the image, as hosted has it

test-native on tn-local (run 37062759791): the Android packaging tests drive a fake
gradlew built on jar, javac and java; without a JDK every one exited 127.

* fix(ci-runners): Android platform-tools at the hosted ANDROID_HOME

test-unit 3/3 on tn-local: the playtest doctor spec failed on "adb was not found".

* fix(ci-runners): slot loop reads shape and labels from the right positions

Inside bash -c the first argument is $0, so the label landed where the image
name goes and docker tried to pull tn-local:latest. up then failed closed.

* fix(ci-runners): C.UTF-8 locale, as hosted runners have

test-native run 37068328553: javac read MystralActivity.java as US-ASCII and
rejected an em dash, so the Android metadata probe failed. 1 of 1593 left red.

* ci(prd-480): route the native-platforms Linux legs onto the tn-local pool

Phase 3 of PRD-480. Eight Linux jobs in native-platforms.yml and the
`starter-linux/linux-x64` matrix row now pick their runner from the same
expression ci.yml uses, so the pool is still changed by setting or deleting one
repository variable. `scope` and `networking-matrix` — the two whose whole work
is a script with no `pnpm install` — take the light lane; everything else is
heavy. `github.event` inside a `workflow_call` is the caller's event payload,
so the fork guard reads the same flag ci.yml did when it called this file.

Kept hosted, with reasons in the file and in the spec's allow-list:
`publish-android-v8` is the release write itself (`contents: write` plus
`gh release upload`), and macOS, Windows, `linux-arm64` and iOS have no
tn-local counterpart. The `desktop` matrix has no Linux row at all.

The image gains the SDK the routed Android steps pin: cmdline-tools 16.0 with
licenses accepted, platform-tools, platforms 35 and 36 (compileSdk),
build-tools 35.0.0 and NDK 28.2.13676358 (ndkVersion), owned by `runner`
because sdkmanager writes into ANDROID_HOME at job time. `emulator` and the
system image stay out: android-emulator-runner installs those itself.
9.28 GB against the pool image's 5.25 GB, chowned inside the install layer so
the SDK is not written twice.

Heavy slots pass `--device /dev/kvm` where the host has one, and the entrypoint
sets the node's mode before the job reads it: docker creates it 0660 root:kvm,
which android-emulator-runner cannot open. That also means the lane's
`udevadm control`/`udevadm trigger` lines can never succeed in a container — no
udev, read-only /sys — so they are now `|| true`, which is what the step's own
"report the mode, never assert it" rule already says. The spec compares the
two emulator lanes' provisioning with that suffix normalised.

Evidence: `docker build -t tn-ci-runner:phase3 tools/ci-runners` succeeded, and
`docker run --rm --device /dev/kvm --entrypoint bash tn-ci-runner:phase3 -c
'sdkmanager --version && ls $ANDROID_HOME && ls -l /dev/kvm'` printed sdkmanager
16.0 with build-tools, cmdline-tools, licenses, ndk, platform-tools and
platforms. `emulator -accel-check` inside the container reports "KVM (version
12) is installed and usable" with no missing shared libraries.
`bash -n scripts/ci-runners.sh` passes. `pnpm exec vitest run
scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts` —
142 passed; red first on `.github/workflows/native-platforms.yml scope runs on
ubuntu-latest`, and again on a `starter-linux/linux-x64` row put back to bare
`ubuntu-24.04`. The six sibling workflow specs (ci-efficiency, ci-local,
compiler-cache-setup, hosted-software-callers, workspace-dist-cache,
native-release-proof) pass at 149. `pnpm lint` exits 0, `pnpm check:docs`
checks 2496 links. Not run here and not claimed: a live `native-platforms` run
on the pool, which is what the two phase boxes still wait for. `pnpm typecheck`
and `pnpm budgets` fail in this worktree on missing `packages/*/dist` build
output, in files this change does not touch.

* docs(prd-480): AC-1 green on tn-local; focused checks then push while TN_RUNNER is set

Run 37070815769: 39 jobs green, 33 on tn-local, 5 on tn-local-light, supply-chain
hosted. 38 distinct runner names, so each job got a fresh container. ci-required
started 3 s after its last need. AC-2 measured and not met (317 min summed wait,
42 min wall vs 39 hosted); owner decision recorded as pending.

* fix(ci-runners): emulator lane and starter matrix pass on tn-local

Run 37074908699: the emulator lane failed writing its udev rule because the image
had no /etc/udev/rules.d; and starter-desktop.test.mjs pinned a bare ubuntu-24.04
for the linux-x64 row that now routes through TN_RUNNER. Census regenerated for
the runtime-native test edit.

* fix(ci): Windows packaging retries the Chocolatey installs

Run 37079046851 and the develop promotion both failed Windows desktop core on
"nsis not installed. The package was not found" for a version the feed still
lists. Three attempts, 30 s apart; makensis -VERSION still fails closed.

* ci(prd-480): Android emulator parity stays hosted

On tn-local the emulator got KVM but renders through SwiftShader on the CPU; run
37082733117 spent 41 of its 45 minutes still running APKs (hosted ~30 min). The
hosted allow-list names it with that reason; every other Linux leg still routes.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…ator guide, stale-runner cleanup (#410)

* ci(prd-480): the runner pool forgets runners that died idle

Stopping an idle ephemeral runner leaves its registration offline forever; the
list had grown to 24 for 6 live runners. up and down now delete offline
tn-local* registrations. PRD-480 records the boot-service operation.

* ci(prd-480): pnpm ci:runners:install sets up the runner pool end to end

install checks the prerequisites, creates .worktrees/ci-runners, writes and
enables a systemd user service that starts the pool at boot from the latest
develop, enables lingering, and starts it. uninstall reverses all of it.
tools/ci-runners/README.md is the operator guide; contributors need none of it.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
On the 5-slot local pool the ~25 template legs waited 17-25 min for a runner
(run 37070815769), which kept wall time at ~40 min. They now run hosted in
parallel while builds and tests hold the local pool.

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
…use, image deps, PRD close-out) (#409)

* ci(prd-380): an ordinary pull request runs the Linux native rows only

The native matrix is one of the three multipliers that starves the runner pool: ci.yml ran its
full macOS/Windows/iOS/arm64 legs for any `selection == 'full'` pull request. Phase 2 makes the
scope planner decide how much of that matrix a run owes, once, and the workflow read the answer.

The plan gains `nativeTier` (`full` | `reduced` | `none`), computed in `selectionPlan` from whether
the change reached native code and the branch it targets, recomputed and compared by `validatePlan`,
and published as the `native_tier` output. `native-platforms.yml` consumes it: `desktop` (macOS,
Windows) and `ios-simulator` skip unless the tier is `full`, and `starter-linux` runs `linux-x64`
alone because a job's `if` cannot read `matrix`, so the `scope` job shapes the rows instead and an
unreadable tier keeps every row. `web-reference`, `android-v8-source`, `android-emulator-parity` and
`desktop-parity` are Linux rows and still run on an ordinary pull request. A `main` push, the
nightly, a `workflow_dispatch` audit and any merge group or pull request into `main` classify
`full`; ci.yml now passes the merge group's own base branch, which is what tells those two apart.

PRD-481's reuse profile follows: a reused plan keeps the native reach of the full plan it stands in
for, and `ci-required` records the tier and demands it of the source run instead of blanket-exempting
`native-platforms` on develop, so a reduced pass can never satisfy a full requirement — nor a
skipped lane satisfy a reduced one. No label exempts the matrix.

Evidence (2026-10-02, red before green):
- scripts/__tests__/ci-efficiency.spec.ts + ci-structure + ci-needs: 216 passed; 2 new
  ci-efficiency assertions and 4 ci-needs cases failed against the pre-change files.
- packages/runtime-native tests/native-platform-workflow + starter-desktop: 79 passed. The arm64
  matrix rows moved from the job body into the `scope` step that shapes them, and that test's row
  assertions follow.
- pnpm lint, pnpm typecheck, pnpm check:docs, pnpm census: clean.

* docs(prd-480): phase 3 proven by run 37089715252

Every native-platforms leg green; Linux legs on tn-local and tn-local-light.

* ci: the janitor also cancels a dequeued PR's merge-group runs

Dequeueing, admin-merging or closing a pull request drops it from the
develop merge queue but does not cancel the merge-group run GitHub
already started, so that run keeps holding self-hosted runners for a
verdict nothing will read: run 37093098790 ran 9 jobs for #404 after #404
had merged. The janitor only swept the head ref, which never names that
branch.

It now also cancels queued and in-progress runs whose branch starts with
`gh-readonly-queue/<base>/pr-<number>-`, built from the event's own base
ref and PR number through `env:` — no `github.*` context reaches the shell
— and filtered in jq off one `gh run list --json
databaseId,status,headBranch --limit 100`. The fork, develop/main-head and
self-run guards are unchanged.

Evidence:
- `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts
  scripts/__tests__/ci-efficiency.spec.ts` — the janitor case now pins
  the env-built prefix and the `startswith` filter: red on the old
  workflow (1 failed | 134 skipped), green on the new one (182 passed).
- `pnpm lint` clean.
- The step's shell, extracted and run against a stub `gh`, cancelled the
  head ref's in-progress run plus both of PR 404's queued/in-progress
  merge-group runs, and spared a completed run, a `main`-based group,
  another PR's group, `pr-4040` and the janitor's own run id.

* fix(ci): tree reuse compares runner class per job, so mixed boards can reuse

currentRun() demanded one runner class for the whole run and returned a miss
otherwise, but every board is mixed on purpose: supply-chain is hosted, the
small joins run on tn-local-light and the platform legs are hosted. Reuse could
therefore never trigger.

currentRun() now returns the class each job routes to, read from that job's own
labels (known before the job starts), and coverageMiss() compares the source's
class for each required job against it. A job whose class is not assigned yet —
a reuse run skips every leg it replaces — falls back to the class most of the
run's known jobs report, so an unprovable leg is a miss unless it agrees with
that routing. Profile coverage, matrix legs and success-only are unchanged.

Spec: the mixed-board case now reuses, and a source whose `test` ran hosted
while this run routes it to tn-local runs full naming the job.

* docs(prd): CI PRD status lines match what is verified

* docs(prd-480): the fallback runs fully hosted (dispatch run 37093694594)

* ci(runners): bake the native jobs' build packages into the image

test-native spent 52 s per run on apt-get install (run 37089715252); the -dev
packages and xcompmgr now ship in the image. Mesa stays per-job.

* docs(prd-380): phase 3 proven by live janitor runs

* ci(runners): three light slots so small joins stop queueing behind one runner

* ci(janitor): force-cancel, since a plain cancel can hang on a job queued for a self-hosted label

* ci(runners): a balancer makes the local pool overflow capacity on top of free hosted runners

* ci(runners): six 3-thread slots instead of five 4-thread ones, same reserved desktop cores

* ci(scope): a CI-configuration-only pull request runs the `ci` selection

A workflow-only PR to develop spent the whole board — test-native, both browser
and playtest lanes, the 28-leg template sweep, golden-path, benchmark, the
native matrix — to prove that a YAML file still parses. None of those jobs has
a consumer for CI configuration.

`ci` keeps exactly the gates that read it: typecheck, lint, budgets, the
secrets scan, and the test-unit shards, which are where every `ci-*.spec.ts`
runs. build-artifacts comes with them because those three jobs consume its
upload; dropping it would leave three required jobs waiting on a producer that
never ran.

Qualifying paths are `.github/workflows/*.yml`, `scripts/ci-*.{mjs,sh,ts}`,
`scripts/__tests__/ci-*.spec.ts`, `tools/ci-runners/**`, and anything already
classified prose or instructions. `.github/actions/**` is excluded because the
native matrix consumes it, and a lockfile because it changes what every job
installs. A diff touching any native path keeps `full` — that includes
`native-platforms.yml`, which is the matrix's own definition, so narrowing it
would waive the lane that has to prove the change.

Never for the merge queue, a push, the nightly, an explicit audit or a
promotion into main. Those qualify a tree rather than review a change, and the
queue already runs the full board before anything lands.

Reuse needed no new guard: `coverageMiss` already refuses a source on the
matrix legs it skipped, and a new test in ci-needs.spec.ts proves a `ci`-lane
run cannot stand in for a full requirement. `classify`'s complexity is
unchanged from its pre-existing warning — the family loop moved to
`diffSelection` rather than pushing it further over.

`pnpm ci:local --affected` accepted only full/prose/instructions, so a
CI-configuration branch failed closed on an unknown selection; it now accepts
`ci` and runs the same bounded docs lane as the other narrowed selections.

* ci(janitor): a CI run that failed only on known flakes reruns its failed jobs once

workflow_run on CI completion: when every failed primary job shows a known
environmental signature (SwiftShader device lost, bridge budget, adapter
provenance, dev server not up, 5xx, runner shutdown) and it is attempt 1, rerun
only the failed jobs. Any other failure stays red. Takes effect from main.

* ci: template legs use the balanced routing, so idle local slots run them too

* fix(ci): starter-linux matrix rows are {include:[...]}, not a bare array

Run 37100382876: the native scope step wrote the rows as an array, GitHub created
no starter-linux job, and the reusable workflow still concluded failure, so
ci-required read a red native lane with every leg green.

---------

Co-authored-by: Joao Paulo Furtado <jfurtado141@gmail.com>
@jonit-dev jonit-dev closed this Oct 3, 2026
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