Repository navigation
Conversation
…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
…n sun map; #97 keep (dashboard anchor)
…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.
Stress lab: ThreeNative vs Unreal 5.8.3, same scene, orbit second 10
Black sky, before and after FL1The earlier side-by-side came from the old develop, where the sky texture bytes ( |
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.
PRD-633 sky, judge round 1: the templates draw Unreal's atmosphere sky instead of the photoWhat 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 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):
|
…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.
…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.
… 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.
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.






What is this PR about?
Promotes
developtomain. It carries the stress lab 120 fps work (#594) and PRD-769.Why does it matter?
maintakes only this ordinary full-checkeddevelop -> mainPR. Merge it with a merge commit, not squash or rebase, after the full CI board is green.