Repository navigation
Conversation
…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.
…k approval still open
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>
This was referenced Oct 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Promotes
developtomainafter the 0.3.4 cohort was published.create-threenative0.2.8:doctoraccepts a patch difference inside one 0.x series (assetsships 0.3.5 beside 0.3.4); rain verifier imports its.tssource by its real name so the tarball resolves.Merge with a merge commit, not squash or rebase.