Skip to content

Promote develop to main - #595

Merged
jonit-dev merged 82 commits into
mainfrom
develop
Nov 1, 2026
Merged

jonit-dev merged 82 commits into
mainfrom
develop

Conversation

@jonit-dev

Copy link
Copy Markdown
Contributor

What is this PR about?

Promotes develop to main. It carries the stress lab 120 fps work (#594) and PRD-769.

Why does it matter?

main takes only this ordinary full-checked develop -> main PR. Merge it with a merge commit, not squash or rebase, after the full CI board is green.

jonit-dev and others added 24 commits October 28, 2026 19:52
…gate, RTS motion blur off

- core: in a browser page, start() routes console.log/info lines starting with TN_ to console.debug;
  TN_LOG=1, ?tnLog=1 or __THREENATIVE_LOG__ restores them. warn/error and native hosts untouched.
- three-native: Emscripten print hooks read the console per line, so engine TN_RENDERER notes follow.
- runtime-native: the web frame gate declines a frame only when the oldest unfinished frame is 4 loop
  intervals old (cap 6), not at two unfinished frames: late work-done promises declined 7.5 % of RTS
  frames. Frames without a submit 1.52 % -> 0.35 % on the RTS kit (Chrome, RTX 2080).
- rts kit: high tier sets motionBlurEnabled false; scaffold hash re-pinned.
… ABI crossings per army.sync)

- Timestamp sampling: backend.trackTimestamp reaches the native GPU timer through renderer state (web host
  ABI 5 -> 6), so core's 1-in-8 gpuTimestampFrameInterval holds; it mapped a readback every frame.
- Typed arrays cross as TN_VALUE_BYTES with their element type, not widened to doubles: a 112x112x4 fog
  texture made a 401,408-byte overflow allocation per refresh, now none.
- updateRanges: clear then push makes one clear and one add, no JSON reads.
- Attribute itemSize/count/normalized/gpuType and InstancedMesh count/instanceMatrix/instanceColor are
  mirrored in JS; copy and replacement invalidate.
CPU census on the real RTS army (seed 17): 995 -> 582 crossings; instance data SHA identical across arms.
Diagnosis and implementation: Codex gpt-6.1-sol; 141 focused tests pass.
… records its serial

A render target draw batches its submit until the next unbatched submit, so a target drawn last left
the frame's serial off the queue. The per-frame timestamp readback used to flush it; with sampling at
1 in 8 frames (previous commit) no work-done arrived, the gate declined every frame and nothing
submitted again. Red (RTS kit, Chrome, RTX 2080): drawn 0.0 fps, 300 of 300 frames skipped per
window. Green: drawn 56-59 fps, 0-9 skips per window, update p50 1.8 ms.
…y default

Owner, 2026-10-28, against the AstraCraft reference (WebGL MSAA): "on the html version pixel remains
sharp" and our frames looked "as if things briefly lost definition". Unreal's desktop defaults,
temporal AA and camera motion blur, both soften anything that moves. Core's high tier now draws
4x MSAA (Unreal's own forward and mobile default) with motionBlurEnabled false; medium keeps SMAA.
temporalAA: true and motionBlurEnabled: true are the named opt-ins.

- rts drops its explicit TRAA and blur overrides; the look guard states the owner decision.
- 11 kit AGENTS.md files said AA was temporal on high and medium; they now name MSAA/SMAA.
- 12 scaffold hashes re-pinned. Specs: 18 files, 444 tests (2 rts look-guard reds fixed);
  kit-look-defaults 87/87; scaffold byte-stable green; check:docs green.
# Conflicts:
#	packages/create-threenative/__tests__/scaffold.spec.ts
… and re-pin the rts hash after merging develop
The always-on bridge warning is back to reads a game repeated in one frame
(597faf2); what all crossings cost rides the debug-only census, where a
report can rank it, so a scene with 995 distinct reads is not a warning any
more. Counters whose name multiplies with content (one per asset URL, one per
bridged property) no longer create a frame-budget series: the debug report
ranks the run's largest ten under contentScaled. The web renderer stops
re-observing the temporal totals the native renderer already exports, and the
report drops three rules no producer writes.
…bug-telemetry

# Conflicts:
#	packages/three-native/__tests__/bridge-crossings.spec.ts
#	packages/three-native/__tests__/browser-renderer.spec.ts
#	packages/three-native/src/browser-backend.ts
…elay

A loaded host resolves Chrome's work-done promise a steady 73 ms (p50) after
the submit while the GPU frame itself costs 2.6 ms, so the oldest unfinished
frame was always older than kBehindIntervals x interval (67 ms) and the gate
declined most of a 60 Hz page's frames: live counters showed renderSkips
105-167 of 300 and ~31 drawn fps under a 60 fps rAF. A constant signal delay
is not a GPU backlog, and an absolute age cannot tell them apart.

FrameGate::decline now learns that delay from the completions it pops into an
EMA (0.1 weight, samples clamped 0..1000 ms) and declines only when the oldest
unfinished frame is kBehindIntervals loop intervals older than the learned
delay. Until a frame has completed the delay is zero, which is the shipped
absolute-age rule: the safe fallback with no evidence yet. kMaxFrames and the
gateAttempts / gateDeclinedAge / gateDeclinedCapacity counters are unchanged.

Tests (packages/runtime-native/tests/native-engine/frame_gate_test.cpp):
- steady_late_signal_draws_every_frame fails on the old rule with 192 of 540
  frames declined after the warm-up, passes with 0.
- growing_backlog_is_declined holds the queue at kMaxFrames while still
  drawing ~1 frame per GPU frame, and documents why the capacity bound is the
  only trip there.
Existing cases unchanged and green; both new cases registered in
cmake/NativeEngineCore.cmake.
…dropping every bind group

materialTexture() released a map's record for every `texture.needsUpdate`, which dropped every
material bind group and every recorded bundle and re-created the GPU texture, its view and its
sampler. The RTS kit's 56x56 fog-of-war DataTexture updates up to 20 times a second, so each of
those updates re-recorded the whole scene (the 40-56 ms GPU-process stalls in the owner's trace).

A version bump whose key still matches - shape, mip-level count, format, usage, kind, and the
sampler parameters - now uploads into the texture that is already there and regenerates its mip
chain through the same code the create path uses, so the view, the sampler, the groups keyed by
them and the bundles replay untouched. Extent, format, mip count, kind or sampler changes keep
the full recreate, and the create and upload paths take their descriptor and their bytes from
that one key.

Both paths now count into waste::textureCreatedBytes / waste::textureUploadBytes, which only
GpuResources incremented, so ?tnDebug=1 reports what the material-map path allocates and uploads.

tests/native-engine/renderer_caches_test.cpp: texture_update_in_place (no bind group created, no
bundle re-recorded, the frame samples the new texel) and texture_update_reallocates (a changed
extent still rebuilds the record and draws the new pixels).
…n InstancedMesh

InstancedMesh.setMatrixAt/setColorAt are mesh-level engine calls: the native body writes the
engine's own attribute store and never the JS shadow `attribute.array` hands out on its first read,
so the next `needsUpdate` sent that frozen snapshot back and the mesh kept drawing where it stood
(the RTS's invisible soldiers, once core's velocity capture had read the array).

Write the item the call makes (index*16 elements, index*3 colour lanes) into the shadow the game
already holds, as three does: two map lookups and an array copy, no engine call, and no shadow is
created when there is none. BatchedMesh keeps its matrices in its own per-instance structs (three
exposes them through a texture), so the rule has no other mesh-level writer to cover.

Red-green in browser-backend-coverage.spec.ts: "keeps a setMatrixAt the instanceMatrix.array a game
reads would clobber", "keeps a setColorAt the instanceColor.array a game reads would clobber", and
"creates no shadow for a setMatrixAt the game holds no array for".
Root typecheck failed on develop with 3 errors in debug-telemetry.spec.ts
(the TextureLoader.load stub returned Texture<unknown>, and an indexed
total could be undefined). 24/24 spec tests pass; root tsc has no core errors.
…einfo-free callable (web Wasm 4.21 MB -> 3.73 MB)

develop is red on TN_WEB_ENGINE_OVER_BUDGET (4204799 bytes, allowed 4194304). The ABI registry stored about a
thousand lambdas in std::function (Ctor, Method, Getter, Setter, callbacks, events). Each instance carried a mangled
type name, a typeinfo, a vtable and clone, destroy and target thunks: about 320 KB of type-name strings alone.

binding.h gets Fn<Sig>: a callable of at most two words that is trivially copyable lives in the object (its call thunk
is all it costs); any other is heap-held with one clone and one destroy function. Same call cost (one indirect call),
no RTTI, no flag change, no behaviour change.

web .wasm bytes (pnpm wasm:build, same host, a8e7c48):
  tn-native-engine-web-threads.wasm  4214135 -> 3733581  (-480554)
  tn-native-engine-web.wasm          4205086 -> 3725144  (-479942)
Budget 4194304: about 460 KB of room on both.

Tests: native_engine_abi_binding_fn (new: inline and heap callables copy, move, destroy apart); 148 native engine tests
for abi, bindings, fixtures, shadows, bundles, geometry caches and renderer scenes pass; native_engine_wasm_browser_backend
and native_engine_wasm_browser_gc pass on the rebuilt Wasm module.
* docs(PRD-765): plan the RTS kit's AstraCraft look and HUD port

* feat(rts kit): AstraCraft command HUD — top bar, objectives, rivals, quick bar, selection card, command/construct card (PRD-765)

* feat(rts kit): AstraCraft battlefield — painted ground, sandstone mesas, scrub, bridge, ACES look (PRD-765)

The ground is painted once on the CPU (noise, roads with ruts, shorelines, tiled
aprons, expansion rings) instead of a metre grid; every rock the sim paths around
now has a mesa; hemisphere + warm key + cool fill under ACES 1.02 (named override).
The runtime-cost spec passes the viewport height army.sync gained in 51612a8.

* perf(rts kit): paint the ground straight into the texture bytes, roads measured once per segment (2.2 s -> 0.67 s in Node) (PRD-765)

* test(rts kit): re-pin the scaffold hash, kit AGENTS.md names the AstraCraft look; PRD-765 phases 1-2 verified (judge IMPROVEMENT)

* fix(rts kit): shadows cover the whole frame; AstraCraft colours read as legacy linear, fog of war blended in gamma, no bloom/IBL (PRD-765)

Owner bugs, 2026-10-28:
- Shadow cut-off on pan: the sun's 64 m shadow window was smaller than the ~150 m frame. Same on the
  threejs engine, so the kit owns it. follow() now fits the window to the frame's ground corners in
  light space (AstraCraft's max(44, zoom*1.7) still clips the 2.1:1 frame's corners).
  rts-shadow-coverage.spec.ts: red 4/4 before, green 4/4 after.
- Yellow tone: AstraCraft ran three r140 legacy colour, hex = linear; we decoded hex as sRGB, so its
  sun turned orange. legacyColor() reads lights, fog and flat materials as linear. Its fog-of-war
  sheet blends in display space; the ground material now does that blend in gamma 2.2 through
  colour + emissive (outputNode/output fails on the Wasm engine ABI). No bloom, local exposure or
  sky environment: AstraCraft has none. Sand/pad/scene vs astra.png: lightness within 0.02.

* fix(rts): smooth unit motion, AstraCraft health bars and fog tone (PRD-765)

Units blend the last two 20 Hz rules poses in a beforeRender draw instead of
jumping each rules step. Health bars are AstraCraft's 30x3 / 60x3 px fills
with a 1 px frame, snapped to whole pixels. The fog of war gets AstraCraft's
blur and slate colour, the vignette is gone, and envStandard colours decode
from sRGB like the reference.

* docs(prd-765): tick the production 60 fps box with the quiet-GPU paired runs

* perf(rts): HUD state on a 50 ms clock, no per-frame instance bound

Takes perf/rts-kit-frame 76baed0 and 5ab6c8d onto the kit. Its unit-pose
interpolation (690c844) is already covered by f4b13cb.

* fix(rts): the rubber band is a box with a live count, not an empty badge

The band's CSS is AstraCraft's: `#drag-box:after { content: attr(data-count) }`
draws the badge above it. The kit never set `data-count`, so the padded, bordered
`:after` rendered as an empty little square hanging over the corner — the owner's
"squared bug" on the selection box — and the band had no count at all.

Three rules now come from the reference and from one place:

- `dragBoxRect` in `picking.ts` is the band's geometry: the same rectangle whichever
  way the drag went, and `null` under the 4 px slop, where the gesture is a click.
  The scene publishes `dragBox` from it, so a two-pixel press no longer flashes a band.
- `dragCount` is the length of the *same* `unitsInBox` scan a release runs, so the badge
  cannot promise a number the selection does not deliver. It is rescanned only while the
  pointer moves, and the box array is still mutated in place.
- A press while an order mode is armed is that order, not a band (`#mode === ""` opens
  one), and a left release in a mode confirms it, as in AstraCraft: the banner says CLICK
  and now a click is what it means. A pointer cancel keeps the mode.

`rts-drag-box.spec.ts` is red on the old behaviour (`dragBoxRect` returned a 2x1 band
under the slop) and green after; it also pins that the preview count equals the release
selection, and that the band leaves the mining crew out unless Alt is held.

* fix(rts): the selection card counts a bunker's garrison and calls a blocked exit blocked

AstraCraft's card says what the selection is doing; the port dropped the two lines that are not
an order kind. A selected Citadel Bunker now reads `GARRISON 2 / 4` — which is also its damage,
since its gun is the infantry inside it, so the card no longer reports a full bunker as `0 DAMAGE`.
A unit that finished training but cannot leave the door reads `EXIT BLOCKED` instead of
`PRODUCTION ACTIVE`, because the queue is full and the unit is not moving.

The status rules move out of the JSX into `ui/labels.ts` (no React, no DOM) so the state machine
the panel draws is testable: `rts-selection-card.spec.ts` is red on the pre-fix labels
(`COMBAT READY` for a garrisoned bunker, `0` damage for three Vanguards inside) and green after.

* feat(rts): the tactical overview is an overview, and the quick bar prints the keys it has

The minimap was a picture. AstraCraft's is the map you drive from: a click recentres the
camera there, a drag pans while the button is held, and a right-click issues the selection's
order at that ground point. It is now all three. The click is turned into world metres in the
UI layer (`ui/minimapPoint.ts`, read off the frame's own rect) and handed to the scene as a
`focus` or `orderAt` intent, so the map never touches the rules; `#orderAt` and the new
`#orderGround` share one command path, which is why a minimap order means exactly what the
same click on bare ground means. The inline square style is gone with it: the reference's CSS
stretches the canvas to the frame, and the letterboxing bars were the port's own addition.

The quick bar's RETREAT prints `V` and the command card's Stop prints `S`, as in the reference.
Both keys are now bound (`retreat: KeyV`, `idle: F4`, `stopOrder: KeyS`) and route through the
same intents the buttons use — a label without a binding is worse than no label. `S` was already
free: this kit declares `move` as arrows only so the letter keys are orders.

`rts-minimap.spec.ts` pins the mapping (corners, the stretched middle, a drag clamped at the
edge). It is a new surface rather than a fixed bug, so it is green from its first run; the
click-to-focus behaviour is proven by the capture, not by the spec.

* feat(rts): the top bar gets AstraCraft's edge-scroll toggle

`↔` was the one top-bar button the kit could honestly ship: edge panning is a feature it already
has, so the button toggles it (`edge` intent, `edgeScroll` in the published state, `.active` while
on, the reference's "Edge scrolling enabled./disabled." toast). `HQ`/`LQ` needs a live post-chain
rebuild in `src/render/postprocessing.ts` and `♫` needs an audio system the kit does not have, so
neither is faked here; both are named in UI-PASS.md.

The pointer-over-the-HUD case is still AstraCraft's hardcoded DOM bands, which this scene cannot
see: a cursor resting on a top-bar button still edge-pans. Named in UI-PASS.md rather than guessed
at with a duplicated margin.

* fix(rts): AstraCraft crystals, thin selection rings, its start frame, bar colours on threejs (PRD-765 judge rounds 2-3)

* feat(rts): a Mender's Support ally command, which the simulation already understood

`follow` is in `COMMAND_KINDS`, `combat.ts` treats it as a heal that stays attached, and no UI
element could ever issue it: AstraCraft's `Support ally` tile was the missing half. It appears for
a Mender selection, arms the mode whose banner text is the reference's (`SUPPORT · CLICK A FRIENDLY
GROUND UNIT`), resolves against the friendly unit under the cursor in `#contextual`, and reports
the reference's refusal when there is no such unit. The card's status line names it `RESTORING
ALLY`, beside `heal`, because the simulation treats the two the same way.

Wiring only: no rule moved, so no spec — the behaviour it exposes is already covered by the sim's
own tests.

* docs(prd-765): defer the CLOSE palette and shadows, labels, paint details and boot edge pan with revisit conditions

* feat(rts): a refused order reads as a refusal, and two commands the sim already had get tiles

`.toast.error` has been in the kit's CSS with nothing setting it, so "Requires a completed War
Foundry." looked exactly like "Barracks online." The scene now publishes `noticeError` with the
notice (a refusal sets it, a confirmation clears it) and the toast draws the warning style, as
AstraCraft's does.

`unloadGarrison` and a site's `builderId` were both already in the simulation with no way to reach
them from the HUD. A selected bunker gets `Unload all` and an unfinished site gets `Select builder`,
which selects and frames the Surveyor the site is waiting on — the reference's two missing tiles.
Wiring only, so no new rules and no spec.

* fix(rts): a sim event clears the toast's refusal flag

The refusal style rides one `noticeError` flag, and `#drain` set the notice without it: a refused
order followed by "Barracks online." would have drawn the confirmation in the warning style. Every
event path now states its own answer, and a lost construction site is an error only when nothing
was refunded, which is the reference's rule too.

* fix(rts kit): materials.ts reads the fog-of-war sheet from palette.ts

The sheet's colour was inlined as vec3(2, 10, 22)/255 when the blend moved into the
material, which dropped materials.ts's only palette import and failed
TN_VISUAL_STRUCTURE_FAILED (scripts/visual-gate.ts: materials.ts must import palette.ts).

palette.ts now owns the sheet as `fogOfWar: 0x020a16` and exports `channels()`, the
undecoded hex split a shader node needs; the values are unchanged, so the approved look is
byte-identical. The water tints stay beside the material, as puzzle's do.

* test(rts kit): rts-attack expects the AstraCraft start cameraX -66

84275a5 moved the opening frame to AstraCraft's (-66, 57); the scenario still pinned
-70, the pre-AstraCraft start, so TN_PLAYTEST_RESOURCE_TRANSITION_ASSERTION_FAILED named
state.cameraX at box-release and armed. The recorded run observed -66 at both steps and
never moved: the 50 ms stateFlushMs clock is not the cause, because the playtest bridge
flushes and reads the live store (packages/core/src/playtest.ts stateResources -> getState()).

* test(rts kit): rts-build probes the fog ground is lit, not a black sheet

The 24x24 patch at (1098, 274) was pinned dark (0.98 of pixels under 0.2) in c93b427,
when the battlefield was the pre-AstraCraft dark look. PRD-765 then opened the kit on
AstraCraft's frame, painted its approved fog (sheet 2, 10, 22, no vignette) and put the
rivals card over the top of that patch: the 3 panel rows are the only dark pixels left
(0.125), the ground below them reads 0.40-0.47.

The patch moves clear of the card (y 292) and states the rule the approved look holds:
that fog-of-war ground is lit, not black, so a return to a dark sheet fails it again.
Observed 0 dark pixels at maxLuminance 0.1, nonblank ratio 1.

* fix(rts kit): the top-location separator clears the HUD contrast floor

hud-clean's HUD audit judged 90 text items and the "/" between KEPLER–09 and 3-WAY
SKIRMISH was the only one under 3:1: #495e66 on the ink plate is 1.81:1, and 2.82:1 on the
top bar's real gradient. #77929b keeps it a step below the location's own #8ba4ab grey and
reads 3.74:1, the floor every glyph is held to (TN_PLAYTEST_HUD_CONTRAST).

* test(create-threenative): re-pin the rts scaffold hash after the kit's three fixes

Only the rts tree moved: 61e16ddf -> be6b0265, measured through createProject on a clean
checkout; the other twelve hashes are byte-identical.

* test(create-threenative): re-pin the rts hash after merging the UI final pass

* docs(prd-765): finish the RTS AstraCraft port: contracts green, move to done

test:templates (rts) exit 0 with every scenario passing and 0 kit contract
failures; sync:agents --check, check:docs, typecheck and lint exit 0. The
UI pass's drag-box spec narrows dragBoxRect's null, biome sorts four import
blocks, and the rts hash is re-pinned for them.

* fix(core): read the debug launch flag by string key instead of a biome-ignore

The merge queue stopped on scripts/__tests__/quality-json.spec.ts: the telemetry merge added a new
suppression-class finding (debug-telemetry.ts:12 biome-ignore useNamingConvention). The flag is now read
through a named string key, so no suppression is needed. quality-json and debug-telemetry specs: 25/25.

* test(runtime-native): the spans spec reads the opt-in GPU timer and its availability query

web_host.cpp turns the GPU timer on only while timestamp tracking is asked for (debug telemetry, 0112ecd) and query 10
reports whether the timer is available (50e2758); the source spec still expected setGpuTimer(true) and gpuTimerEnabled.
native-engine-game-spans.spec.ts: 6/6.

* test(runtime-native): any template tier with no post stage passes as a proven no-post chain; refresh native coverage

The AstraCraft RTS look (PRD-765) turns every post stage off at the low tier, so WorldEnvironment takes its direct tone
mapping and installs no output graph. template-post-packages allowed that only for snow; it now allows it for any template
whose preset proves it asked for no stage (the same checks). Red: rts-low TN_TEMPLATE_POST_EMPTY, failures=1.
Green: rts-low no post chain, noPostChains=1, failures=0.

Native coverage record regenerated for tonight's C++ (telemetry, texture in place, frame gate, Fn binding): total 81.91%,
src/engine 84.15%. pnpm budgets no longer reports the record stale.

* fix(core): a WorldEnvironment with no post stage still reports its render-chain tier

Both no-chain paths (the early return with no base colour or exposure meter, and the direct
output install) now print TN_RENDER_CHAIN and remember the report with the requested tier and
stages: [], and apply().dispose forgets it.
Red-green: world-environment-defaults.spec 'still reports its render-chain tier when every post
stage is off' failed (undefined) before, passes after; 85/85 in the 5 related core specs.
rts-ai-attack on a forced SwiftShader adapter (TN_QUALITY_TIER low software=true): renderChain.tier pass.

* test(create-threenative): two specs follow core's sharp AA default (4x MSAA, no TRAA)

Since the owner's sharp-AA decision (2026-10-28) the high tier's world pass is 4x MSAA with no temporal AA, and an
omitted option inherits it. world-environment-lifetime: an undefined samples now observes 4 (red on PR #590 CI:
expected 4 to be undefined). template-quality: no shipped tier runs traa any more, so the spec opts high into the named
temporalAA: true override to keep proving debug().temporal reports the engine's live state (red: expected undefined).
Both green: 22/22 and 51/51; verify-golden-path 23/23, check-native-coverage pass, pnpm lint and pnpm typecheck exit 0.
…, CPU attribution, plan off the main thread (#594)

* docs(agents): open a PR only when a PRD or the owner asks for one

* garbage removal

* docs: slice engine performance architecture into scoped PRDs

* docs: require validation scenes proving net performance gains

* feat(dashboard): Stress lab tab for the sandbox stress-scene perf loop, KPIs at 120 fps (8.33 ms)

pnpm stress:loop record <report.json> appends a fail-closed validated row; the hub renders
docs/stress-loop/ledger.json on each load.

* docs(stress-loop): baseline row, 1.36 fps at the real preset with the kit's virtual shadow recipe

* feat(dashboard): FPS is the first Stress lab KPI card (goal >= 120 fps)

* perf(renderer): virtual-shadow casters batch in the main pass, shadow copy stays per caster

Under VirtualShadowNode every shadow caster (and every skinned rig) left the batch lanes,
so the stress lab's 600 colour-only props drew 616 main draws instead of 66. The caster now
joins the main-pass batch and keeps its own shadow-only item for the per-page cull and mover
detection.

Stress lab, still pose, static layers, RTX 2080, 2 runs per arm: main draws 616 -> 66,
fps 43.5/44.4 -> 57.4/54.8, GPU p95 17.9/25.1 -> 11.9/10.5 ms. Full real preset: main
draws 2795 -> 1803; frame still ~800 ms (virtual page redraw, next lever).
Visual judge (fresh Sonnet subagent, 2 runs per arm): NEUTRAL both frames, pass.
Tests: render_database 97/97 cases, 2 new red-green cases.

* docs(stress-loop): row #2, VSM casters batch in the main pass (keep)

* feat(dashboard): Stress lab tracks JS-Wasm crossings, host calls, GPU upload, JS alloc; row #2 note corrected

* perf(renderer): virtual-shadow rig bounds come from bones, not CPU-skinned vertices

Each frame the virtual shadow bounds pass skinned every vertex of every rig on the CPU
(getVertexPosition per vertex, a dynamic_cast each). With the stress lab's 120-rig crowd
that cost about 760 ms a frame. SkinnedMesh::boneReachBounds measures once per geometry
revision how far each bone's weighted vertices reach in the bind pose, then fits a box
to the posed bones in O(bones), as UE 5.8.3 fits skinned bounds to bones. The box holds
every vertex whenever weights sum to 1; otherwise it falls back to the vertex scan.

Stress lab, RTX 2080, 1280x720, WebGPU:
- full real preset: 1.38 -> 7.99 fps, frame p50 797 -> 73 ms (single run each)
- crowd, still pose, 2 runs per arm: 1.18/1.18 -> 21.1/21.7 fps, 808/828 -> 45/45 ms
- crowd closeup, 2 runs per arm: 1.30/1.17 -> 23.1/20.8 fps
Visual judge (fresh Sonnet subagent, frozen pose, 2 runs per arm): NEUTRAL, pass;
541 of ~921k scene pixels differ, nearly all by one level.
Tests: render_database 98/98 (new bone_reach_bounds_hold_posed_vertices), VSM ctest 14/14.

* docs(stress-loop): row #3, rig shadow bounds from bones (keep)

* feat(dashboard): Stress lab tab refreshes itself every 15 s

* feat(mcp): ship Chrome DevTools MCP by default as threenative-devtools

Core wires chrome-devtools-mcp 1.11.0 through a shim, like the Blender
server. The shim launches Chrome with the playtest WebGPU recipe, an
isolated profile, and usage statistics and CrUX lookups off. Chrome
starts only on the first tool call.

trace-a-slow-frame.md tells authoring agents to use it after playtest
trace, and not to quote its CPU times: async stacks inflate them.

The registry-install probe checks it by initialize and tools/list only,
because every DevTools tool starts a headed Chrome.

* docs(templates): tell authoring agents to use threenative-devtools on a slow game

Every kit's AGENTS.md points a slow or stuttering game at the
performance skill. The skill now gives the order: playtest trace
first, then the threenative-devtools MCP, quoting its GPU, heap and
network numbers and not its inflated CPU times. The skill word cap
moves from 260 to 285 for this section.

* docs(stress-loop): row #4, VSM pan keeps covered pages (judge pending)

* perf(vsm): a pan keeps the shadow pages the window still covers

Any window move redrew every page of the level: the clipmap re-centred at texel
precision, so no page key survived a pan. A pan now moves the window by whole pages
from its previous origin (the first placement and a cut keep the exact texel origin, so
the GPU reference renders still match), and the atlas keeps a page whose slot already
held that world page while the depth mapping and page size are unchanged; dirty pages
still redraw. Pattern from UE 5.8.3 virtual shadow clipmap panning.

Stress lab, full real preset, RTX 2080, 6-window median: 4.8 -> 8.27 fps, frame p95
223 -> 159 ms, GPU p95 125 -> 72 ms, presented p95 533 -> 283 ms.
Visual judge (fresh Sonnet subagent, orbit frozen at 12 s after a pan path, 2 runs per
arm): NEUTRAL, pass; scene differences are 1-2 px edge fringes, no seams.
Tests: new native_engine_vsm_pan_keeps_pages red -> green; VSM ctest 15/15 including
the GPU reference renders; render_database 98/98.

* docs(stress-loop): row #4 judged NEUTRAL and committed

* docs(perf): stress lab levers and the named web profile method

* feat(telemetry): the renderer's costly CPU stages ride TN_FRAME_BUDGET.counters.engine

A slow web frame named only `mainPass`; finding which part of the 2,000-line renderPrepared cost
it took a custom checkpoint build. The renderer now keeps running CPU ms (thread CPU where the
platform has it, else wall) for the virtual shadow pages and caster bounds, the draw plan, the
shadow passes' draw lists, the whole prepare half and the encode half. tnw_web_frame fields 66-70
read them, and three-native adds them to __tnEngineCounters, so core reports per-frame p50/p95
whenever debug or the engine-cost knob is on: every game gets the hotspot by name.

Stress lab, full real preset, RTX 2080 (one window): cpuVirtualPagesMs p50/p95 11.3/64.4,
cpuShadowBuildMs 1.9/89.1, cpuPrepareMs 17/113, cpuEncodeMs 2.0/5.7, cpuPlanMs 0.15/0.2.
Checks: VSM ctest 15/15; browser-renderer + frame-counters specs 53/53; native targets build.

* docs(perf): renderer CPU stage counters as a method entry

* perf(three-native): a quaternion syncs its object's rotation in JS, not by an engine call

Every quaternion write ran three's onChange, which called Euler.setFromQuaternion in the engine:
one crossing per body per frame. Physics interpolation sets every body's quaternion each frame,
so the stress lab paid 1,732 crossings a frame for it. The bridge now computes the rotation with
Euler.cpp's own formulas (update off, as the binding calls it) and writes the lanes, as the
opposite Euler-to-quaternion sync already does. The engine's ieee754 functions are V8's fdlibm,
so Math gives the same bits; a lane offset missing from the registry keeps the call.

Tests: bridge-crossings.spec red on the old bridge (rotation lanes untouched, no export), green
now: 200 quaternion writes cross 0 times and match three's Euler.setFromQuaternion bit for bit;
all six orders and the gimbal-locked branch match three. Spec file 26/26.
Stress lab (plainShadow, RTX 2080): debug census `call Euler.setFromQuaternion` 1,732 -> 0 per
frame. Frame time inconclusive: host load 22.8 (a Codex lane linking), 2 runs per arm.

* feat(web-engine): every page runs the threaded engine and refuses to start without worker threads

Owner, 2026-10-29: "just force all to use worker threads bro, there's no other way for us".
The page loaded the single-thread engine whenever the threaded module was absent or the page was
not cross-origin isolated, and TN_FRAME_BUDGET only reported `threads: off`. Now the plugin
always imports tn-native-engine-web-threads, the build fails with TN_WASM_THREADS_MISSING when
it is absent, and the page throws TN_WEB_THREADS_OFF when it is not cross-origin isolated or the
job pool starts no worker. The single-thread module stays for Node hosts (tests, ABI check).

Tests: web-engine.spec red on the old loader (no TN_WASM_THREADS_MISSING), 26/26 green.
Stress lab, RTX 2080: threads "on (3)", no errors; ?tnIsolation=0 throws TN_WEB_THREADS_OFF.

* feat(runtime-native): the job pool always has workers on native, and refuses a count of 0

Owner, 2026-10-29: "force on native too" / "we cant run without this, performancen wll suck".
TN_JOB_WORKERS=0 turned the pool off and a one-thread CPU started no worker. Now a count of 0 or
a non-number throws TN_JOB_WORKERS_OFF, and the default is at least 1 worker (at most 3, as
before). Tests still size a pool with resetShared, including 0, to compare walks.

Tests: native_engine_job_pool_is_always_on red on HEAD (CHECK failed: refused), green now;
job pool ctest 4/4. Threaded Wasm build links.

* docs(stress-loop): row for forced web threads and the JS Euler sync

* perf(runtime-native): an action's timeScale, paused, time and weight and a mixer's timeScale are read in place

An AnimationPlayer reads timeScale and paused on every action and the mixer's timeScale every
frame; each read was a bridge crossing (stress lab: 445 + 330 + 360 a frame). The bindings now
declare them as fields, so the Wasm back end reads the engine's memory, as it already does for
the mixer's time and an object's visible flag. The action's block starts at `time`, so the
offsets hold in the 64-bit registry dump and the 32-bit Wasm build (static_assert on the layout).

Tests: bridge-reads.spec red on the old registry (1 crossing), green now (0 across 200 frames).
three-native specs 511/512; the one red (shadow-node.spec, TN_NATIVE_SHADOW_NODE_UNSUPPORTED)
fails on the HEAD registry too.
Stress lab (plainShadow, threaded engine, RTX 2080): debug census 3,696 -> 1,400 crossings a frame.

* docs(perf): stress lab levers: JS Euler sync, animation field reads, forced worker threads

* docs(stress-loop): row for animation field reads

* docs(stress-loop): clean re-measure at head

* perf(runtime-native): virtual shadow pages cull instanced casters, reuse static bounds and evict without a copy

Three CPU levers in the virtual shadow map path, found in the stress lab (12,000 instanced trees):
1. PhysicalPagePool::allocate copied and walked every resident entry to pick an eviction victim; it now
   walks resident_ in place, with the same predicates and tie-break.
2. renderPrepared rebuilt each caster's world bounds (every vertex, every instance) on every call; a
   static caster's box and diameter now live in its DrawCache, keyed on its stores' version, epoch and
   writes, instance count and world matrix; rigs, morphs and position nodes still rebuild.
3. The page overlap guard skipped every instanced caster (`!item.instanceMatrices`), so each instanced
   group drew into every shadow page; static instanced groups now cull by the union of their instance
   boxes, as Unreal's per-page instance test does (UE 5.8.3:
   Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapBuildPerPageDrawCommands.usf:225-251).
   GPU-deformed joint batches keep the bypass.

Found and patched by the Codex lane, reviewed by the coordinator.
Tests: vsm_eviction_allocation, virtual_shadow_bounds_cache, virtual_shadow_instance_pages red before,
green after; VSM ctest 18/18 on the RTX 2080 (GPU reference renders included); render database 45/45.
Lane runs (full ?stress, 3 fresh tabs each): virtual pages p50 13.07 -> 4.95-5.46 ms, prepare p50
22.65 -> 10.22-15.17 ms, presented fps 8.81-8.95 -> 8.43-12.39.
Visual judge (Sonnet, BEFORE/AFTER x2, freezeAt=12): NEUTRAL, no missing shadow, no page-border cut.

* docs(stress-loop): row for the VSM CPU levers

* perf(runtime-native): virtual shadow pages select their casters on the job pool

Each page of a light picks its casters from the same frozen draw list with read-only filters, so pages run as
independent items on JobPool::shared(), with errors rethrown on the caller in page order. A light below 16384
page x caster pairs stays serial. The new native_engine_virtual_shadow_page_jobs case compares the serial and
pooled shadow plans byte for byte over 20 frames.

Stress lab (main.html?stress, RTX 2080, threads on (3)): 12.62 -> 16.33 fps, presented p95 150 -> 100 ms.
Visual judge (Sonnet, 2 runs per arm): NEUTRAL.

* docs(stress-loop): row for VSM page caster jobs

* perf(three-native): cut 660 bridge crossings a frame from hierarchy, visibility and matrix restores

- An object's __hierarchyRevision joins the kept hierarchy answers: Object3D::markHierarchy is its only
  writer and stamps the counter the bridge already re-reads (120 a frame on the stress lab).
- Core's camera cull answers 'shown in the world' with its own parent walk: visible is an engine field and
  parent a kept answer, so the walk crosses nothing (240 a frame).
- Physics interpolation's restore arms matrixWorldNeedsUpdate in place on the Wasm engine instead of calling
  updateMatrix: the flag moves into the Object3D flags block and is published as a byte field; the engine
  walk composes the same local matrix (300 a frame).

Stress lab (main.html?stress, develop core both arms): bridge crossings p50 2520 -> 1838, 13.45 -> 14.03 fps,
frame p50 48.0 -> 45.4 ms. Visual judge (Sonnet, 2 runs per arm): NEUTRAL.

(cherry picked from commit 2b89e7a0845891754bee47d338b0bdd9c14bc1bb)

* docs(stress-loop): row for the bridge crossing cuts

* docs(stress-loop): stacked F2+B baseline row

* docs(stress-loop): quiet-host develop baseline and rejected F3 row

* fix(core): scene warning names its real count and prints only in a debug launch

The human-readable scene warning printed triangles per draw after "largest term at",
so 52298 read as an object count. It now names the dominant term's own count and unit.
The owner asked for the warning in debug launches only: TN_SCENE_WARNING and the UI
post now run only when isDevLaunch() is true. Native playtests set DEV_MODE=1 so the
perf-warnings evaluator keeps reading the marker.

(cherry picked from commit d0da747b7799d52c0f923b1eeeea7458c337a08a)

* docs(perf): stress-lab learnings for F2, the bridge cuts, host load and the F3 bone search

* fix(runtime-native,core): reveal the first frame only once shadows and exposure are ready

The stress lab's first visible frame had no cast shadows and was over-exposed (mean 140-143
against a steady 107-109), then a 1 s gap and a dark frame. Two engine causes:
- F1: the environment bake presented a frame. `tnw_web_prepare_environment` (web host ABI 7)
  bakes in its own step, and the facade rewrites the scene/camera slots before each step.
- F5: the first ordinary frames reached the canvas before the virtual shadow atlas was mapped
  and before auto exposure settled. Web host ABI 8 adds `tnw_web_set_presentation_hold`: held
  frames do every pass (shadows, post, temporal history, the exposure meter) but take no canvas
  texture. Core engages the hold at renderer setup and releases it when StartupReadiness sees a
  world submission, an initialized atlas and an exposure within 3% of target.
Review fixes (F5b + coordinator): the canvas texture is acquired lazily again, so a frame that
draws nothing presents nothing; the image wait is bounded at 3 s from the frame window or from
any later cold picture, with one TN_STARTUP_IMAGE_TIMEOUT line naming the cold input; fields
71/72 and the atlas walk run only while the hold is up.

Proof: 4 runs (VSM and plain shadows, 2 each): first revealed frame mean 106-110, cast shadows
from frame 1, no gap. Fresh visual judge: IMPROVEMENT on all 4 sheets. Paired steady state:
17.24 fps against develop 17.02, no per-frame cost after the reveal. 195 related vitest cases,
17/17 native VSM tests including the GPU ones, Wasm 3.74 MB under the 4 MiB budget.

(cherry picked from commit 4732d7d1e5c7f188510d8800c10810d0d940aa2a)

* docs(stress-loop): row for the startup presentation hold

* docs(stress-loop): rejected F3+F8 row

* perf(three-native,core): skip the virtual shadow LOD walk when nothing it reads changed

`syncVirtualShadowLods` walked every light's whole scene graph on every render to send discrete-LOD
chains to the native shadow pass: 1.43 ms self and 3.99 ms inclusive per frame on the stress lab
(4000 trees, 6000 rocks), and three hunter lanes found it independently. The walk's answer can
change in only three ways, so each shadow node now records them and skips the walk while none moved:
- the engine's hierarchy revision on the light's root (a backend without it always walks);
- core's discrete-LOD chain registration count (a chain joined onto a geometry already in the scene);
- the facade's count of `mesh.geometry` assignments (a geometry swap moves no hierarchy).

Proof: paired stress-lab runs on one host state, same Wasm: 26.98 fps mean (27.34, 27.06, 26.53)
against develop 22.43 (22.62, 22.88, 21.80), +20%; render p50 20.7 ms against 26.3 ms. Fresh
visual judge: NEUTRAL on both frames, no missing or wrong-LOD shadow. Red-green: the walk-skip case
fails on an unchanged frame re-walking and on a geometry swap not re-walking; 120 related vitest
cases pass, three-native tsc clean.

(cherry picked from commit 532fc7a62d96acf15d74d2290bf4bb622e512beb)

* docs(stress-loop): FS1 kept, FS2 rejected

* docs(perf): stress-lab learnings for FS1, FS2, F3+F8 and the startup hold

* perf(runtime-native): let the job pool use up to 8 workers, and fail closed when none start

`kMaxWorkers` was 3. A bench of the frame's pooled job shape (132 items x 0.165 ms) runs 3.89 lanes
of work at 3 workers, 7.53 at 8, 8.76 at 15 and 8.50 at 23 on a 24-thread host: past 8 the item
count is the wall. The cap is now 8, still min(hardware threads - 1, cap), on web and native.
A thread spawn the platform refuses leaves the pool that many short with one
`TN_JOB_WORKERS_START_FAILED` line; a pool with no worker at all throws that error by name instead
of running on one thread unnamed (owner: worker threads forced on, fail closed).

Proof: stress lab, develop head JS, paired rounds: 30.30 vs 27.40 and 26.20 vs 23.96 fps (+9 to
+11%), long tasks down. Brave reports 15 hardware threads there, so 8 workers. Red-green:
`workers_cover_the_machine` fails at cap 3 ("24 hardware threads -> 3 workers"), passes at 8;
9/9 job pool and matrix walk native tests, and the pool cases under single-thread and threaded
Wasm in node. Threaded Wasm 3,742,553 B.

* docs(stress-loop): FT2 kept

* docs(stress-loop): FM5 and FU2+FU3 rejected

* docs(perf): stress-lab learnings for FT2, the FM candidates and three rejections

* perf(runtime-native): stop rebuilding each draw's material half, slab the frame uniforms per program, cache binding probes

Three main-pass plan cuts from the stress-lab hunt (FM1-FM3), stacked:
- FM1: `RenderDatabase::refresh` keeps a draw's material half (params, node graph, attribute list) on
  its record and rebuilds it only when the material's uniform signature or the geometry revision
  moves; `DrawItem::nodes`/`attributes` point into the record instead of being copied each frame.
  Review fixes: a non-float colour or custom attribute (a converted copy that `needsUpdate`
  replaces without a geometry revision) is re-read every frame, and an onBeforeRender callback
  clears the record's signature so the next frame refreshes in full.
- FM2: the frame's light and camera block is written once per program per plan, and the uniform
  arena uploads only the 256-byte blocks whose bytes changed (one write when half or more did).
- FM3: a program's four per-draw binding probes are computed once when it is built, and a texture
  with no render target skips the `weak_ptr` lock.

Proof: stress lab, develop head JS, alternating arms in one lock window: 27.40 / 28.51 fps against
27.51 / 26.62 (+3.3%), render p50 21.38 / 21.37 ms against 22.03 / 22.21. Fresh visual judge
NEUTRAL on both frames. Red-green: `draw_refresh_guard_holes` fails before the review fixes
(repainted colour 195 -> 195; the callback's blue kept) and passes after; FM1-FM3's own work-count
cases (`steady_state_draw_refresh_work`, `frame_slab_is_per_program`,
`program_probes_are_resolved_once`) prove identical pixels between arms; 83/83 renderer and
render-database native cases. Threaded Wasm 3,743,698 B.

* docs(stress-loop,perf): FM1-FM3 stack kept

* docs(stress-loop): FU4 rejected

* perf(runtime-native): instance a shadow page's casters per group, index each level's pages by caster

Two virtual shadow map cuts from the stress-lab hunt (round 3), stacked:
- FM8: a page's selected casters that share a depth program, pipeline, geometry and depth-stage
  material values draw as one instanced row per (page, group) instead of one row each. A skinned
  group reads its palette base and bind matrices per instance from a `shadowSkin` store and reuses
  the palette the main pass already wrote, so no palette is copied per page. Members are exactly
  the page's selection; a single caster, a morph target, a node-graph position stage, a GPU-joint
  rig, an already instanced draw, mirrored winding and graph textures keep their own row.
  `TN_SHADOW_INSTANCE_GROUPS=0` restores one row per caster.
- FS9: a granted level indexes its casters once by page range (`PageAtlas::pageRange`) instead of
  every page scanning the whole draw list: 19,018 selection probes per frame become 380 in the
  page-jobs scene. `TN_SHADOW_PAGE_SCAN=flat` restores the scan.

Proof, stress lab, develop head JS, alternating arms in one lock window: FM8+FS9 31.19 / 31.00 fps
against FM8 alone 29.33 / 28.21; frame p95 58.0 / 57.1 against 60.6 / 63.2; long tasks 1071 / 1135
against 2484 / 2372. The earlier window: FM8 29.6 / 30.6 and FS9 28.45 / 28.24 against develop
26.37 / 28.30, render p50 19.5 against 21.5 ms. Fresh visual judges NEUTRAL for FS9, FM8 and the
stack. Native: `virtual_shadow_pages_instance_skinned_casters` (receiving pixels byte-identical,
palette writes unchanged, rows <= pages x groups), `virtual_shadow_page_jobs` (plan byte-identical
serial vs pooled and scan vs index; probes bounded by casters x granted levels + selected casters),
`page_range` cases; 105/107 related cases pass, the two others are `material_props` (no tsx in the
build worktree) and `renderer_overlay_over_frame` (fails in parallel runs with both levers off too,
passes alone). Threaded Wasm 3,761,254 B.

* docs(stress-loop,perf): FM8+FS9 kept, FU4 rejected

* docs(skills): build-on-sandbox points performance work at the stress scene

* feat(dashboard): Stress lab shows every report metric and compares engine arms side by side

Rows carry an engine arm (threenative-wasm, threenative-threejs, three-vanilla, unreal; old rows read as
threenative-wasm) and a measure decision for plain measurements. The tab gains an engine comparison table
(latest row per engine, every metric) and the history shows all 19 metrics.

* docs(stress-loop): round-4 window: FS11+FS12 measured (46 vs 34.5 fps, judge pending), FS13 no paired gain

* feat(dashboard): Stress lab engine comparison reads only plain ?stress rows

Rows carry a variant (the URL flags beyond ?stress, default stress); the plainShadow rows #6, #7, #32-35 are
retagged. Row #36: ThreeNative on engine threejs, 31.9 fps at 1280x720 (its mainDraws/triangles read 1/0: the
report's draw counters do not read the three.js renderer yet).

* docs(stress-loop): window 2: FS11+FS12 reproduces (44.6/45.4 vs 35.1/35.9 fps), FP2 +5% no burst change, FC1 no gain

* perf(native-engine): shadow caster records per draw and numeric virtual shadow page ids (FS11+FS12)

The per-light caster maps keyed by item.key become one record per draw position, and the virtual
shadow page keys become numeric ids (pageId(level, x, y)) with an unordered_set for protected pages.
A skinned rig's main-pass copy (castShadow=false) shared its shadow copy's key, so each rig emitted
two caster states per frame and the settle counter advanced twice per frame.

Stress lab, 1280x720, RTX 2080, two lock windows with alternating arms: 34.4-35.9 fps -> 44.6-46.0
fps, GPU p95 62 -> 34 ms, frame p95 51 -> 28 ms, main draws 150 -> 60 at equal triangles (7.07 M).
Visual judge NEUTRAL (shadow outliers 0 of 4 per arm). Native tests 115 of 116 pass;
material_props fails the same way on develop (tsx is missing in a worktree).

* docs(stress-loop): FS11+FS12 kept on develop (0974fd3), the Wasm arm moves to 45.4 fps

* docs(perf): stress-lab learnings for FS11+FS12 (keep, mover-page mechanism), FS13 (reject), FP2 (parked)

* feat(dashboard): stress lab comparison marks best/worst per metric and deltas vs the Wasm arm

* docs(stress-loop): rows #45-#49; mark the 05:02Z window rows loadedHost (host load 17-36, pre-FS11 Wasm on :5733)

* docs(prd): stress-lab performance batch PRD-770..774 (fair comparison, CPU attribution, crowd GPU joints, VSM per-page instance culling, render plan off the main thread)

* docs(prd-774): JobPool submit/join review — regex safe, merge with its first caller

* style: biome-format the stress-loop ledger and local Claude settings (pre-push lint red on develop)

* style: biome-format the stress-loop ledger and local Claude settings (pre-push lint red on develop)

* docs(stress-loop): fair window q2 rows (develop Wasm ?stress CPU p95 18.7 ms, GPU p95 33.9 ms; plainShadow 13.0/6.2; vanilla 13.2/2.7)

* docs(stress-loop): rows #53-#64; s770 window void (stray stress page held the GPU 07:30-09:20Z), clean s770b window with split main/shadow triangles and an uncapped pass

* perf(runtime-native): link mimalloc into the threaded web engine (FB1)

dlmalloc takes one global lock in a -pthread build, so the job pool's workers
and the main thread queued on every allocation. A fair uncapped stress-lab
window (ABBA, load peak 4.66) cut the CPU frame p50 from 14.75 to 13.76 ms and
from 14.65 to 13.73 ms. The single-thread build keeps dlmalloc.

* feat(stress-loop): fair engine comparison with split main and shadow counts (PRD-770)

- stress:loop fair: ABBA in one clean browser, 1280x720 DPR 1, 80 s runs,
  measure and build locks, load gate, stray-page close and GPU idle guard,
  vsync plus uncapped pass; arms pool by URL and every run is saved first.
- record refuses a row without build identity, adapter or a quiet host.
- web host field 73 splits shadow triangles from the main pass; core files
  main and shadow apart for the owned engine.
- the comparison ranks only rows whose counts, shadow mode and main/shadow
  triangles agree within 2%; the ledger writer formats through biome.

* docs(stress-loop): rows #65-#66, FB1 mimalloc fair window (uncapped CPU frame p50 14.64/14.18 -> 13.60/13.63 ms)

* docs(perf): FB1 second fair window (rows #65-#66)

* perf(runtime-native): a virtual shadow page draws only the instances that reach it (FG1, PRD-773)

Each granted page row of an instanced static caster now carries only the
instances whose bounds reach that page (per-level index by page cell,
counting sort, compacted row), after Unreal's per-page draw commands.
Stress lab ?stress: GPU p95 34.03/33.98 -> 7.54/7.33 ms in two pairs,
?stress&still CPU frame p50 within 1.5%. Refresh-reason counters
(pan, rebuild, invalidation, pages, instances) feed TN_FRAME_BUDGET.
Native: 162 cases green; judge round 2 NEUTRAL.

* docs(prd-773): tick the paired A/B box with fair-window rows #67-#70

* docs(stress-loop): rows #67-#70, FG1 fair window (vsync GPU p95 33.7/33.9 -> 7.7/7.6 ms, CPU frame p50 within 1.3%)

* feat(profiling): render-phase CPU stages in TN_FRAME_BUDGET (PRD-771)

Every frame-budget window now splits the main-thread frame into named
stages: JS update, JS before and after the engine call, Wasm plan (with
virtual-page and shadow-list parts), scene walk, encode, submit, post,
render targets and named remainders. The web host publishes its totals in
one array read in place (tnw_web_frame 78), so a steady frame pays no
host call per stage; a module without it reports the stages as absent,
never zero. Meters on vs off: mean +0.05 ms over 5 pairs.
Stress lab ?stress p50 14.70 ms = plan 4.00 + scene 2.83 + JS before
2.65 + update 2.26 + engineJs 0.81 + targets 0.72 + rest; the p95 tail
is 71% shadow refresh.

* docs(perf): FG1 raises CPU frame p95 about 1.3 ms on granting frames (rows #67-#74)

* docs(stress-loop): rows #71-#74, stack window (FB1+PRD-770+FG1): GPU p95 34.0 -> 7.6 ms vsync, CPU p50 -1.7% uncapped, CPU p95 +6-8%

* fix(web-host): move the shadow-triangle field to 85, clear of PRD-771's stage totals

PRD-770 and PRD-771 both claimed tnw_web_frame field 73 (shadow triangles and
the post-effect stage); the threaded Wasm did not compile with both. The
census spec now pins field 85 for the frame split.

* docs(stress-loop): rows #75-#76, stack2 window (head 9745050): CPU p50 -4.9% both pairs, GPU p95 7.5 vs develop 6.7/31.9, CPU p95 16.35 -> 17.07

* fix(stress-loop): record Unreal rows without draw or triangle counts (PRD-770)

Unreal cannot count its own draws and triangles, so record refused every
Unreal row on mainDraws: null. Only engine unreal may leave mainDraws and
mainTrianglesM null; every owned-engine row stays fail-closed. A row without
main triangles shows as not comparable: main triangles not measured.

* docs(stress-loop): rows #77-#84, add-on 5 window (skinned sun-map cull): shadow triangles 2.06 -> 1.29 M = vanilla, but plainShadow CPU frame p50 +11.9%/+10.6%, GPU flat; rejected as a speed lever

* fix(stress-fair): keep every run file of an ABBA arm (PRD-770)

measurePass named run files by order.indexOf(arm), so ABBA gave 0,1,1,0 and
each arm's second run overwrote its first. saveRun now takes the arm's next
free run number.

* docs(learnings): add-on 5 skinned sun-map cull rejected (+11% CPU, GPU flat)

* fix(stress-loop): dashboard columns show the kept arm, not an old or rejected row (PRD-770)

The comparison anchored on the latest row named exactly 'stress', so it
showed develop row #59 from before FG1 (GPU p95 34 ms) while the branch
stack read 7.5 ms. A column now shows the latest kept arm of the scene
(arm= tags ignored), else its latest row that was not rejected, and the
header names the arm and build.

* fix(stress-loop): dashboard shows the kept stack arm with fair-comparison rules (PRD-770)

Land the branch's stress-loop and stress-fair scripts on develop so the
:4751 dashboard renders them: columns anchor on the kept arm (rows #77/#79
= stack head 7b212502b66b) instead of develop row #59, not-comparable rows
say why, Unreal rows record without counts. Ledger: stack head rows marked
keep, add-on 5 rows marked reject.

* fix(stress-loop): the apples-to-apples plain sun map table comes first (PRD-770)

Vanilla three.js has one plain sun map, so the virtual shadow table can
never rank it. The plain table is now the first comparison, and the virtual
table says it is ThreeNative only.

* perf(renderer): unchanged instanced draws skip the per-frame copy and compare (TR1 + TR1b, PRD-774)

Stable per-draw slots, and a stamp per instanced draw (store epoch, writes,
version, count, BufferStore::id) that commits only after the upload
succeeds. A still scene copies and uploads 0 instance bytes.

Stress lab on the stack (rows #85-#88): uncapped CPU frame p50 -4.9% and
-7.6%, p95 18.13 -> 17.64 ms, GPU p95 7.52 -> 7.39 ms. Opus judge NEUTRAL.
Red-green: static_instances_are_not_recopied. Learnings also record FG1b
(index kept across frames) as rejected: p95 inside noise.

* perf(three-native): image pixels cross into the engine as bytes (FL1, PRD-770)

toEngine forwarded only the eight SCALARS typed arrays, so a
Uint8ClampedArray (getImageData pixels) was boxed with Array.from and
written as float64. The stress lab's 4096x2048 sky took 1.56 s of a
1.98 s load. Stress-lab loadMs 1550 -> 542 and 1490 -> 501 ms (rows
#95-#96), below vanilla three's 548.5 ms. Red-green spec.

* fix(core): the post-pipeline composite quad is not the main pass (FT1, PRD-770)

On engine "threejs" a WorldEnvironment output pipeline draws the world as a
render nested inside RenderPipeline's composite quad, and RenderPassBudget
named the outermost call (the quad) main: the stress lab read 1 draw and
0 M triangles. A call whose scene is three's QuadMesh is now nested, and
the first world call at world depth 0 is main. Red-green spec.

* fix(core): ProjectionStability watch lists hold one scan, not every scan (FT2, PRD-770)

record() cleared the dedupe sets but appended to #materials and
#geometries on every re-record, so holds() walked a list that grew by
the scene's material and geometry count each frame (50 entries for 2
after 25 records). The lists now reset with the walk. Hunter
microbench: holds() 0.145 ms after 1 record stays ~0.03 ms after 200.
Red-green spec; 12 importer specs 231/231.

* fix(core): a frame-budget window counts only its own long tasks (FK1, PRD-770)

#taskCount and #longestTaskMs were never reset at a window boundary, so
every window reported the load phase's long tasks again. The stress lab
summed them over its window ring: 66 = 6 load tasks x 11 windows, while
the browser observer saw 0 long tasks in steady state. Vanilla's report
resets per window, so the fair table compared different metrics.
afterFirstFrameMs stays a run-level startup figure. Red-green spec.

* docs(stress-loop): rows #85-#88, TR1b on the stack: uncapped CPU p50 -4.9%/-7.6%, p95 18.13 -> 17.64, GPU p95 7.52 -> 7.39; KEEP

* docs(stress-loop): rows #89-#92, FG1b on the stack: CPU p95 17.79 -> 18.04 vsync, 18.95 -> 18.83 uncapped, p50 flat; REJECT

* docs(stress-loop): rows #95-#96, FL1 sky texture as bytes: loadMs 1550 -> 542, 1490 -> 501 (-65%), below vanilla 548.5; KEEP
…595)

- renderer_link.cpp: MaterialNodes is a pointer since #594 (native-engine compile).
- native coverage record re-measured on the fixed tree (source digest 2ed6fa…).
- build.spec: the fixture ships the threaded Wasm pair the loader now requires.
- stress-fair.spec: register temp dirs for cleanup (temp-dir guard, test-unit 4/4).
- strict typecheck narrowing in four specs (frame-budget-stages, projection-stability,
  stress-fair, stress-loop); every assertion keeps its meaning.

Local proof: laptop typecheck exit 0, budgets exit 0, 7 failed spec files 136/136,
Wasm renderer-link target builds. template-nonvisual reds were an npm propagation
window for tslib 2.9.1 (published 20:20Z), not a code change.
Ports Unreal 5.8.3's GPU page management in three phases ordered by measured
CPU ceiling: one GPU-culled shadow pass per light (BuildPerPageDrawCommands),
caster changes pushed by the scene (CacheManager change set), and the page
table and physical pool on the GPU (PhysicalPageManagement). Folds PRD-457,
524, 652 and 773; maps the three VSM subsystem rows to PRD-775.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Stress lab: ThreeNative vs Unreal 5.8.3, same scene, orbit second 10

ThreeNative vs Unreal

  • Left: ThreeNative Wasm on WebGPU. The engine is the one on develop since Stress lab to 120 fps: fair comparison, VSM culling, crowd GPU joints, CPU attribution, plan off the main thread #594 (stack3 arm f7670365a, Wasm 4fc094ccd199). 1280x720, RTX 2080, shot 2026-10-30 about 22:20Z.
  • Right: Unreal 5.8.3 tnmatched. Lumen GI is off, with SSR reflections and virtual shadow maps. Same scene, camera and sun. Shot 09:31Z.
  • ⚠️ Unreal has no sky.jpg. It uses its template SkyAtmosphere, SkyLight and VolumetricCloud. So the sky and the fill light are not matched yet.
  • Visible gaps: Unreal roofs are warmer and brighter (orange vs red), its trees are lighter green, its horizon haze is stronger, and its fire embers are larger. No visual judge has run on this pair yet.

Black sky, before and after FL1

sky before/after

The earlier side-by-side came from the old develop, where the sky texture bytes (Uint8ClampedArray) were refused, so the sky drew black and had no fill. FL1 fixed this, and the fix is now on develop. The perf rows since row #95 include the sky.

The Windows desktop leg of push run 41129808033 passed its work, then lost its
receipt to one `spawnSync gh ETIMEDOUT`: a timed-out read carries a signal, and
the retry loop threw on any signal. The coverage receipt and ci-required failed
behind it on every run that met a slow API read. A timeout is now retried on the
same endpoint with the same backoff; permission errors still fail at once and the
last attempt still fails closed.

Red: the new spec case threw ETIMEDOUT. Green: 28/28 ci-attempt-receipts, 16/16 ci-native-chain.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-633 sky, judge round 1: the templates draw Unreal's atmosphere sky instead of the photo

What changed: starter, action-rpg, minimal, racing, runner and shooter now draw Unreal's default sky (atmosphere, clouds, height-fog colour from the sky, sun tinted by the air). The photo stays as SKY_OPTIONS.sky: "photo". Branch prd/633-sky-atmosphere (13f7ab434..cb2a1819e) (coordinator lands it).

Columns: TN BEFORE (photo) | TN AFTER (atmosphere) | UNREAL 5.8.3, same pose and sun, Wasm engine, headed WebGPU (adapter nvidia/turing). 2 runs per arm, byte-identical (mean abs diff 0.0).

Numbers (L2, 60 regions): mean deltaE 15.74 -> 3.52, sky regions 12.42 -> 0.40, passes 0/60 -> 7/60.

Judge (fresh Opus, read-only):

  • ✅ Sky colour, clouds and the blue horizon band: CLOSE to Unreal at all three suns. Low-sun warmth: met.
  • ✅ sun15 and sun04: IMPROVEMENT on 7 of 8 rows.
  • ❌ sun60 interior and backlit: REGRESSION. Shadow sides read navy, and the chrome ball's lower half reflects black. Cause: the baked environment had a black lower half. Unreal fills it with Lumen's floor bounce, and the photo faked it.
  • ⚠️ Kits: NEUTRAL (racing IMPROVEMENT). No artifacts. The shooter AFTER capture was blank and is under investigation.
  • Overall: NEUTRAL, so round 2 follows. Fix in 0013059eb: the environment's lower half is now the atmosphere's sunlit ground (Unreal's GROUND_ALBEDO term).

sun60
sun15
sun04
kits

…tail

Desktop web/native parity failed on push run 41129808033 with 'ui 3.13 ms at p95
over 11 frame(s)': the gate read only the last TN_FRAME_BUDGET window, and a window
that ends 11 frames after its boundary has its worst frame as its p95. The gate now
skips the warm-up window, judges the worst window with at least 120 ui samples
(that run had 243), and fails as TN_FRAME_BUDGET_PHASE_UNDERSAMPLED when no window
is big enough. The 2.0 ms bound is unchanged.

Red: 2 of 4 new cases failed. Green: 4/4, plus 11/11 hosted-software-callers.
The perf reader writes its verdict (TN_PERF_WINDOWS_MISSING and the like) to stdout and leaves
stderr empty, so the arm64 pair step printed 'node failed:' with no reason (run 41204117871, the
launch ran 182 s against the reader's 180 s deadline). The failure text now carries the exit
status, the signal and the tails of stderr and stdout.
… the page started

A software adapter ends its startup wait at the 15 s bake deadline and draws the world without the
environment's fill. minimal has no loading cover, so nothing else waits: the threejs arm was
photographed black-shadowed (score 0.168 vs 0.12, CI run 41204117871) while the owned engine, which
skips world draws until its bake lands, had the fill. Locally on SwiftShader the bake took 56 s.

core now logs TN_ENVIRONMENT_BAKE:started/finished around the PMREM bake; engine-parity waits until
every started bake finished before it reads the pose.
…gain

be1995e added the reflectionTrace option and took starter/src/render/postprocessing.ts to 205
lines; looks.spec caps a render file under 200. The doc comments say the same in fewer lines.
looks.spec: red at 205, 21/21 at 198.
At key 0.36 the crate's contact coverage on the software lane fell to 0.0154-0.0166 (floor 0.02;
0.0255 at key 0.18). The low tier draws no ambient occlusion, so the starter now lays a stacked-disc
blob under the crate that fades with its height (render/contactShadow.ts). Measured 0.0499-0.0501.
Re-pins the starter scaffold hash.
… the crate blob

Measured by the byte-stable test (it read 265ae437 against the blob commit's a07932e0).
…orld frame

The hand-off counted draws the frame gate accepted, so on a slow software GPU the reveal (and
revealed/phase ready) came while the canvas still showed the loading cover. The web host now answers
field 86: the first world frame drawn after the hold came off has finished on the GPU. Core holds the
hand-off until it says so (still bounded by STARTUP_STABLE_WINDOW_MS).
…tree

The pre-push gate read two biome errors (licensing.spec format, an assignment in a warmup.spec
arrow) and a stale port map: the main checkout regenerated it beside an untracked PRD file.
…ridge root export, fixture path

- starter postprocessing.ts typed reflectionTrace through WorldEnvironment.apply; the core boundary check rejects a TracedReflections import in render source (red in CI, passes now).
- template-runtime-cost fake context gains beforeRender, which the crate contact blob calls (red->green, 10/10).
- blender-bridge exports '.' to match main; publint --strict failed in CI (red->green on a HEAD copy).
- blender-bridge convert.spec resolves its fixture from the file, not the cwd (20/20 with Blender).
- starter scaffold hash re-pinned, measured by the byte-stable test.
The fixed-step loop's maxPhysicsDeltaTime clamp (24c5fe5) advances 1/20 s per frame at most, so on a
2 fps adapter 300 ticks took 100 frames and about 55 s instead of 60 frames and 32 s. The linux-arm64
starter frame pair (llvmpipe) then ran out of its 180 s launch before the third window.
A window now spans reportEvery ticks or as much wall time, whichever comes first; at 20 fps and above
nothing changes. Red-green: frame-budget.spec 'closes on wall time ...' (31 -> 32/32); core budget,
game and stage specs 351/351.
… clock spans its 2 s window

- spans-coverage.spec typed the engine totals as Record<string, number>, so tsc read walkVisits as possibly undefined (CI typecheck red on 3850832).
- skinned-crowd-readiness.spec put 258 renders 16 ms apart (4.1 s of wall time) inside 120 ticks (2 s), a simulation behind the clock; since 3850832 such a window also closes on wall time. The renders now share the window's 2 s; assertions unchanged. 64/64 with the frame-budget and spans specs; root tsc clean.
3850832 let a window close on wall time when the simulation fell behind. The skinned-crowd scene
marks a window ready only when it spans reportEvery ticks (examples/skinned-crowd/src/scenes/Crowd.ts:111),
so native desktop parity crowd-rate never became ready, and Windows desktop core went red, on 3850832.
The crowd spec's fake clock returns with it. The linux-arm64 starter pair (2 fps llvmpipe + the 1/20 s
clamp) needs another fix.
…ner choice)

On linux-arm64 llvmpipe the starter draws about 2 fps. The fixed-step loop's 1/20 s clamp (24c5fe5)
makes a 300-tick window 100 frames there, so startup plus two windows needs about 200 s; the perf launch
limit was 180 s (spawn 240 s). Limits are now 300 s and 360 s; the measured QuickJS/V8 frame p50 pair is
unchanged. The owner chose this over reverting the clamp or a physics-only clamp. Spec 6/6.
…rame field (6086840)

web_host.cpp field 86 changed the measured source digest; pnpm --filter @threenative/runtime-native native:coverage
regenerated it (every engine ctest, render compat included). check-native-coverage.spec 7/7.
… so a chrome sphere reflects the sky

The owner saw the harness chrome sphere reflect a near-black sky with a hard sun dot, where Unreal 5.8.3
shows blue sky and clouds. Root cause: SphereReflectionCapture called beforeRender() with no frame, so no
FRAME node ran its updateBefore. The harness takes the capture in scene enter, before the first frame, and
the GPU sky draws its look-up tables in updateBefore. The faces captured before the first frame read the
never-drawn (cleared) sky targets: black above the horizon, with only the late +Z face showing sky.

A face is a render of the scene, as three's CubeCamera.update is, so the capture now runs the scene's
frame nodes first, once per frame (Wasm: beforeRender with the renderer's frame; V8 player: the same
TempNode update). Unreal orders it the same way: the sky look-up tables render before the sky light
capture reads them (UE 5.8.3: Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp:2850,
:2868).

The harness capture log now reports the capture's zenith radiance, so a capture that missed the sky reads
about zero: L9 sun60 after the fix logs zenith [0.394, 0.461, 0.701] (blue).

Red-green: browser-renderer.spec "runs a scene's frame nodes before a reflection capture reads its faces"
failed before the fix (the sky node never ran) and passes after.
…t missed the line)

9e6afeb meant to type the engine totals as { walkVisits: number }, but its sed addressed line 275 and the
cast is on 276, so CI typecheck stayed red on 59bba00. pnpm typecheck (the CI command) exits 0 now.
…per cube face

The patched PMREMGenerator fenced every cube face of a small environment: its fence budget
scaled with the output size, so the 64 x 32 atmosphere sky (PRD-633) fenced 42 times, and
each fence waits behind the frame queued before it (about 3 s on SwiftShader). The bake took
114 s on four cores instead of 2.4 s, so engine-parity never saw TN_ENVIRONMENT_BAKE:finished
inside 150 s. The budget now has a floor, the heaviest face of a 256 cube. Scheduling only: no
pixel changes. Re-pin the sixteen scaffold trees (they ship core's three patch) and the lockfile
patch hash.
… same bytes out

On QuickJS arm64 the starter blocked its JS thread about 13.5 s in Play.load() and the playtest
mailbox gave up at 15 s. Three changes in the six kits that share clouds.ts and gpuSky.ts:
- valueNoise and worleyInverted compute the per-axis cell, weight and wrapped index once per axis
  position, and Worley skips cells whose box is already farther than the best point (a minimum does
  not depend on visit order). Every float expression is kept: the cloud textures hash the same.
- samplePattern and sampleVolume drop their per-call closures (the CPU cloud bake's inner loop).
- the composited CPU sky bake is cached by sun, size, ground and clouds, so the second setupSky of a
  scene (load, then enter) skips the ray march; a moved sun still bakes.
Measured under node --jitless: first setupSky 7662 -> about 4600 ms, second 2252 -> about 220 ms,
cloudTextures 5600 -> about 2900 ms. Re-pin the six kit trees.
eac89e9 changed three.webgpu.js, three.webgpu.nodes.js and src PMREMGenerator.js but did not
extend three@0.185.1-prd269-upgrade.patch, so an install patched by any earlier release could not
be upgraded and three-patch-upgrade.spec.ts failed 30 cases. Append one exact blob-to-blob hunk
per file (754d967..1d93cf3, 46b9864..aef965e, ace19db..15a57ea).

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

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

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

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

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

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

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

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

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

(cherry picked from commit ee02cafd8790adc10dd5c303e6a45aee005d4185)
…ed to the game

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

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

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

Red: the engine-cost case expected 45 on the old ci.yml. Green: ci-*, kit-contract and engine-parity
specs 712/712.
…ok gate sees its cover frame

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

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

Red-green: the carried-receipt case failed with both IDENTITY errors, now passes; a receipt from a
later attempt is rejected. The cover layout read 0.9613 at bottom 660, passes at 640. Specs:
ci-integration-receipts, ci-native-chain, ci-native-qualification, ci-integration-scope,
ci-efficiency, ci-attempt-receipts 248/248; playwright-config 19/19.
@jonit-dev
jonit-dev merged commit 5b72e5a into main Nov 1, 2026
297 of 299 checks passed
jonit-dev added a commit that referenced this pull request Nov 1, 2026
… too

b0e1b9e covered only legs outside the joins. A red leg inside a join reruns that join, whose
listing then holds GitHub's copies of every carried leg: new id, later run_attempt, same start, end
and runner (PR #595 run 41262008820: 124718810548 -> 124750441136). The integration check read those
copies as "ran again in a later attempt", and the native check demanded one attempt on every
receipt, so both still failed and only a whole rerun qualified.

mergeAttemptListings keeps the first record of a job that the same start, end and runner show to be
a copy, so a carried leg still matches its receipt and a real rerun still counts as a rerun. Native
judges each leg by its latest attempt, its receipt must name that attempt, and completion downloads
the receipts of every attempt in the run.

Red: the carried-leg case failed CI_NATIVE_JOB_IDENTITY, and the integration listing without the
merge fails "ran again in a later attempt". A latest-attempt red and a real rerun stay rejected. The
chain case for a stale job listing still rejects, now under BUILD_IDENTITY. Unit specs only: no live
multi-attempt run has exercised this yet.
jonit-dev added a commit that referenced this pull request Nov 1, 2026
PR #595 push run 41279573156 template-nonvisual (collectathon), green on PR run 41279577367 at the
same SHA: the walk landed on a bridge census frame in collectathon-collect, and its
__attributeNames re-reads (599 of 1085) pushed TN_BRIDGE_WARNING to 0.029 against the 1/50 limit.
It runs only while the scene settles, so it is not the game's steady cost. It now runs under the
observer mark, as de9e266 did for the reveal measurement. The merge and every game read stay
billed. Projected share with the reads removed: about 0.013 (486 reads at 3.99 us on a 151 ms frame).

Red: no children read saw the mark. Green: auto-batch, startup-offscreen-bytes, bridge-reads specs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant