diff --git a/.agents/skills/native-performance-loop/SKILL.md b/.agents/skills/native-performance-loop/SKILL.md index 6875cfeb64..5c3d1d041e 100644 --- a/.agents/skills/native-performance-loop/SKILL.md +++ b/.agents/skills/native-performance-loop/SKILL.md @@ -1,6 +1,6 @@ --- name: native-performance-loop -description: Run an autonomous ThreeNative native-performance optimization loop using existing harnesses, quick synthetic load tests, and representative real games. Profile bottlenecks, make focused changes, accept or reject measured experiments, and maintain an HTML benchmark dashboard. Use for sustained native runtime optimization, frame pacing, startup, memory, or thermal efficiency campaigns. +description: Run an autonomous ThreeNative native-performance optimization loop using existing harnesses, quick synthetic load tests, and representative real games. Profile bottlenecks, make focused changes, accept or reject measured experiments, and maintain an HTML benchmark dashboard. Use for sustained native runtime optimization, frame pacing, startup, memory, or thermal efficiency campaigns, including the Tempest (rain) campaign where the web Wasm engine must beat stock three.js with the same image. --- # Native performance loop @@ -41,6 +41,15 @@ the developer to supervise each iteration. Creating or editing this skill does n Keep separate goals for frame tails, startup, memory, and energy. Never optimize a blended score that can conceal one game's regression. Run the unchanged baseline before editing. +## KPIs and named campaigns + +- Score every campaign on [engine-kpis.md](references/engine-kpis.md) as well as on its primary + scalar. Keep a lever only if it moves at least one engine KPI and the sentinel game does not + regress. A lever that helps one game only is `provisional`. +- For the Tempest sample (the `rain` template, web Wasm engine against stock three), read + [tempest.md](references/tempest.md). It sets goals T1 to T3, the image-identity rule, the arms, + the capture recipe and the known state. + ## The experiment loop Repeat without asking for a new task after each result: diff --git a/.agents/skills/native-performance-loop/agents/openai.yaml b/.agents/skills/native-performance-loop/agents/openai.yaml index 1dab60d5cb..dad0901f3e 100644 --- a/.agents/skills/native-performance-loop/agents/openai.yaml +++ b/.agents/skills/native-performance-loop/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Native Performance Loop" - short_description: "Optimize native games with measured keep/reject loops" + short_description: "Optimize native games and Tempest with measured keep/reject loops" default_prompt: "Use $native-performance-loop to improve native game performance, choose representative workloads, keep verified gains, and maintain an HTML benchmark dashboard." diff --git a/.agents/skills/native-performance-loop/references/engine-kpis.md b/.agents/skills/native-performance-loop/references/engine-kpis.md new file mode 100644 index 0000000000..b909474d37 --- /dev/null +++ b/.agents/skills/native-performance-loop/references/engine-kpis.md @@ -0,0 +1,61 @@ +# Engine KPIs that pay in every game + +These KPIs are the scoreboard of every campaign, whatever its workload. Each one measures an +engine cost that every game pays when it draws. A lever that moves one of them helps all +templates. A lever that moves only one game's frame time is suspect. + +Record each KPI for both arms (the engine under test and its control, such as stock three on the +web or the incumbent build) at the frozen pose or route. Where a meter is +missing, add the smallest meter with a check as a separate harness change, then re-baseline. Do +not estimate a value. + +## GPU (rank first when the game is GPU-bound) + +| KPI | Definition | Target | Meter | +| --- | --- | --- | --- | +| G1 GPU cost ratio | Engine GPU ms ÷ control GPU ms (stock three on the web), same pose, size and tier | ≤ 1.0, then lower | playtest `assert.performance` window `gpuMs`, both arms | +| G2 Shader cost per material | Instructions, texture samples and loop trip counts in the emitted WGSL, against three's WGSL for the same node graph | ≤ three on every program | WGSL dump from both engines; diff per program | +| G3 Full-screen passes per frame | Passes that touch every pixel, plus MSAA resolves and target-to-target copies | ≤ three; 0 redundant copies | pass list from the render graph; Dawn capture if needed | +| G4 Render-target bytes | Sum of live color, depth and MSAA target memory | ≤ three | renderer target census | +| G5 Load/store waste | Clears and stores of targets that the next pass overwrites or never reads | 0 | pass descriptors (loadOp/storeOp) | + +## CPU and the JS/Wasm boundary + +| KPI | Definition | Target | Meter | +| --- | --- | --- | --- | +| C1 Render CPU ms | CPU time from `render()` entry to submit | ≤ three | window `cpuMs` | +| C2 Boundary calls per frame | JS→Wasm and Wasm→JS calls in one steady frame | falls as levers land | call counter in `packages/three-native` glue | +| C3 Steady GPU object churn | Bind groups, buffers, textures and pipelines created per steady frame | 0 | create counters in the renderer | +| C4 Upload bytes per frame | `writeBuffer`/`writeTexture` bytes in a steady frame | only data that changed | upload counter | + +## Startup and hitches + +| KPI | Definition | Target | Meter | +| --- | --- | --- | --- | +| S1 Pipeline count and compile time | Distinct render pipelines at the pose, and `compileMs` / `compileSettledMs` | ≤ three | `pipelineCensus`, compile fields | +| S2 First-use hitch | Worst frame in the first 5 s after the pose is ready | ≤ 2x budget | presented intervals after the ready mark | +| S3 Time to first frame | Navigation start to the first presented world frame | ≤ three | loading scenario marks | + +## Pacing and memory + +| KPI | Definition | Target | Meter | +| --- | --- | --- | --- | +| P1 Presented interval | p50, p95 and p99 of the real presented interval (not rAF and not fps) | p95 ≤ budget | presentation samples | +| P2 Frames in flight | Frames submitted and not yet complete | ≤ 2 (backpressure) | `framesOnGpu` in `web_host.cpp` | +| M1 Steady memory growth | JS heap and Wasm memory growth per minute at the pose | 0 after warmup | `performance.memory`, `WebAssembly.Memory` byte length | + +## Guard KPIs (must not move) + +| KPI | Definition | Limit | +| --- | --- | --- | +| V1 Visual parity | Mean absolute diff against the control at the pose, 0–255 | ≤ 0.01 and judge NEUTRAL or IMPROVEMENT | +| V2 Behavior | The workload's playtest set and the sentinel game's set | No new red | +| V3 Workload identity | Draw calls, triangles and active stages | Same as the baseline, unless the lever deletes provably redundant work and the judge passes | + +## Reading the scoreboard + +- Report each KPI as baseline, incumbent and absolute delta, with a percent only for a positive + baseline. Do not blend KPIs into one score. +- Show a KPI that is not yet metered as `unavailable: `. +- A drop in G3, G5, C3 or C4 with no GPU or CPU ms change is still a kept maintenance win only if + it is proven on the sentinel. Label it as such. Do not label it a speedup. diff --git a/.agents/skills/native-performance-loop/references/tempest.md b/.agents/skills/native-performance-loop/references/tempest.md new file mode 100644 index 0000000000..8c4112c96e --- /dev/null +++ b/.agents/skills/native-performance-loop/references/tempest.md @@ -0,0 +1,167 @@ +# Tempest campaign + +Read this file when the campaign workload is the Tempest sample: the `rain` template +(`packages/create-threenative/templates/rain`), drawn by the web Wasm engine against stock three.js. +Tempest is per-pixel heavy (a raymarched sky and clouds, a reflection pass, 4x MSAA, bloom and a +grade). It is the worst case for engine render cost, so a lever that pays here pays in every game +that draws full-screen passes, materials or render targets. + +The skill's loop, acceptance rules and dashboard contract apply. This file adds only what differs. +The web lane is the primary lane here. That is an exception to the skill's native-first rule: the +Wasm engine is the engine under test on the web, and T3 carries the result to the native player. + +## Contract + +1. **Goal order.** + - **T1:** Wasm engine GPU ms is at or below stock three on the same pose, resolution and tier. + - **T2:** 60 fps on the reference GPU. GPU ms ≤ 16.7 and presented interval p95 ≤ 16.7 ms + at 1920x1080, `high` tier. + - **T3:** the same on the native desktop V8 player. + + T1 comes first because it is an owner rule: native must beat JS. +2. **Primary scalar.** GPU ms per frame, median of steady windows, lower is better. Do not + use `fps` or `minFps` from the Wasm engine as the primary scalar. The rAF backpressure + (`tnw_web_frame(11)`, two frames on the GPU) skips renders, so both values overstate the engine. + Read CPU ms, GPU ms and the presented interval. +3. **Allowed edit surface.** + - Engine only: `packages/runtime-native/` (C++, WGSL codegen, the renderer, `web_host.cpp`) and + `packages/three-native/` (JS glue). + - The template's `src/render/` and `tools/tempest-*.frag` are the look. Do not change them + for speed. A template-side saving (cloud steps, reflection resolution, MSAA, bloom resolution) + is a measured *proposal* for the owner. It is not a kept lever. + - Do not patch three.js. +4. **Image identity.** The Wasm frame must match stock three on the pose (mean absolute diff + ≤ 0.01 on 0–255, plus no region the judge marks as different). Every kept lever passes a fresh + read-only visual judge with BEFORE/AFTER captures and two runs per arm. Both engines are + deterministic run to run, so two runs show stability, not two independent samples. +5. **Generality gate.** A lever is kept only if it moves at least one KPI in + [engine-kpis.md](engine-kpis.md) and does not regress a second template (the sentinel). Use + `starter`, or a template whose bottleneck differs from Tempest. A lever that only helps + Tempest's shaders is `provisional`, never `keep`. + +## Start or resume + +1. Do the "Start or resume" steps of the skill. Use the campaign directory + `/artifacts/native-performance-loop/tempest-/`. Copy `render.mjs` and + `check-render.mjs` from the newest campaign there instead of writing a renderer. Create + `index.html` before the first run. +2. Read the arms and lanes sections below for the arms, the capture recipe and the frozen pose. + Then read [engine-kpis.md](engine-kpis.md) and record a baseline for every KPI that has a meter. + Mark a KPI without a meter as `unavailable: `, never as zero. +3. Freeze: engine SHA, wasm hash, three version, template hash, pose, viewport 1920x1080, tier + `high`, warmup, sample windows, and the GPU identity (`TN_GPU_CLASS` nvidia/turing). +4. Run the unchanged pair (legacy arm and Wasm arm) at least three times, alternating order. + Set the noise margin from these runs before you write any code. + +## Finding the next lever + +Tempest cost is per pixel: at 960x540 both engines are about 3.5x faster than at 1080p. So rank +first by GPU work per frame, in this order: + +1. **Pass attribution.** Get GPU ms per pass (scene, reflection, MSAA resolve, bloom chain, post) + on both engines. Use timestamp queries where the adapter grants them. Otherwise use ablation: + turn one stage off on *both* arms and diff the deltas. An ablation is diagnosis only and never + ships. The pass with the largest ours-minus-legacy delta is next. +2. **Codegen diff.** For that pass, dump the WGSL that each engine emits for the same node graph. + Compare instruction count, texture samples, loop bounds, precision and redundant work (a value + lowered twice, a constant recomputed per pixel, a branch that three folds and we do not). +3. **Target traffic.** Count render targets, resolves, copies, clears, load/store ops, formats and + sizes per frame against stock three. +4. **CPU and churn.** Do this only after the GPU gap closes, or when CPU ms exceeds GPU ms. + +Record the predicted saving before you edit. Use one conceptual change per experiment. Each +behavior change needs a red/green unit test in `packages/runtime-native` (`ctest -R `) or +`packages/three-native` (vitest). A performance number is not a red/green test. + +## Confirm and decide + +- Rebuild the web engine (`packages/runtime-native/build/web/tn-native-engine-web.wasm`). Install + it into a fresh Tempest scaffold. Verify that the served wasm is byte-identical to the build. +- Run at least three valid pairs, alternating legacy-first and Wasm-first. Run them against the + incumbent, and against legacy for the T1 ratio. An adapter other than turing, or exit 75 after + its retries, makes the pair `invalid`. +- Run the visual judge, then the sentinel template, then the rain playtest set (16 scenarios; the + known red is `lightning`/`thunderVoices` on both engines). +- Keep only if the median improvement clears the margin, the judge returns NEUTRAL or + IMPROVEMENT, the sentinel does not regress, and the tests are green. Commit each kept lever on + the campaign branch with its test. +- Every third experiment, rerun the original baseline pair to detect drift. + +## Stop and report + +Stop at the skill's stop conditions, or when engine levers stop paying. In that +case, report: + +- the per-pass GPU table (ours and legacy); +- the remaining gap to T2; +- each template-side option with its measured saving and the judge's description of how the + image changes. Apply none of them. + +Finish with the dashboard link, the KPI table (baseline, incumbent and delta per KPI), the +accepted and rejected counts, and one next action that someone can resume. + +## Arms, lanes and frozen scenarios + +Paths are relative to the owning ThreeNative checkout. Check the current CLI help before you use +a flag. These are routing pointers. + +### Arms + +| Arm | How to make it | What it proves | +| --- | --- | --- | +| Wasm engine (candidate) | Scaffold `rain` with the local framework. The default engine is native. | The engine under test | +| Legacy (control) | The same scaffold with `engine: "legacy"` in `threenative.config.ts` | Stock three.js WebGPURenderer on the same template revision | +| develop (optional) | Scaffold from `origin/develop` in its own worktree | A third view when the template itself changed | + +Scaffold the same way that `scripts/verify-template-playtests.ts` does: `createProject({ install: +true, packageSources: await packageLocalFramework(root), target, template: "rain" })` from +`packages/create-threenative/src/index.js` and `scripts/visual-gate.js`. Use a fresh temporary +root for each arm. Serve each arm with `pnpm exec vite --host 127.0.0.1 --port +--strictPort` from its project directory. + +Confirm each arm by what it loads, not by its config. The Wasm arm imports the +`threenative:web-engine` module. The legacy arm imports `three_webgpu.js` from `.vite/deps`. + +### The web lane (primary) + +- Use the playtest runner's own Xvfb. Unset `TN_PLAYTEST_HOST_DISPLAY`. Never use the owner's + display or the laptop display. +- Pass `--browser-recipe webgpu --headed`. Without `--headed`, Chromium can fall back to + SwiftShader. +- Every run must report the reference adapter in `adapter.info`. On João's machine this is the + RTX 2080: vendor nvidia, architecture turing. Any other adapter makes the run `invalid`. +- Exit 75 means that the capture lock is busy. Retry it. Set `CAPTURE_LOCK_TIMEOUT_MS` to wait + longer. Other agents share the GPU, so do not run two arms at the same time. +- With `assert.performance`, the runner adds `--disable-frame-rate-limit` and + `--disable-gpu-vsync`. GPU ms is then the uncapped cost. The presented interval of a 60 Hz claim + needs a vsync-on run. + +### The desktop lane (T3) + +Build the V8 player (`pnpm native:build`). Run the scaffold with `pnpm build --target desktop` and +`THREENATIVE_ENGINE_PLAYER` set to the player. Use the playtest runner's `--target desktop` with +the template's `native-playtests/` (`boot`, `lightning`). Read frame windows with +`node packages/playtest/dist/runner/cli.js perf --file `. Use the legacy browser arm as the +comparison, and label it as a cross-runtime comparison. + +### Frozen scenarios + +| Scenario | Use | +| --- | --- | +| `playtests/performance.playtest.json` | Primary timing: 1920x1080, `high` tier, 60 warmup frames, a walk, then 600 ticks | +| A pose scenario (viewport 1904x960, warmupFrames 30, waitTicks 300, one `pose` screenshot) | Visual judge and parity diff. Save it in the campaign directory and hash it. | +| `playtests/storm.playtest.json`, `weather.playtest.json`, `tiers.playtest.json` | Second Tempest load levels: heavy storm and each quality tier | +| The full `playtests/` set | Behavior gate before keep | + +### Known state (2026-10-16, engine 54c226867) + +These numbers start the history. They are not a baseline for a new campaign: measure your own. + +- GPU ms at 1920x1080: Wasm 113.6, legacy 43.5 (3 pairs). CPU ms is equal on both arms. +- At 960x540, both arms are about 3.5x faster, so the cost is per pixel. +- `reflections: false` saves about 19 ms on Wasm and 5 ms on legacy. About 55 ms of the gap has + no named pass yet. +- Pose parity against stock three: full-frame mean diff 0.006 after the mat2 order fix. +- The rain playtest set passes 15 of 16 on Wasm. `lightning`/`thunderVoices` fails on both engines. +- Stock three itself is near 30 fps on the reference GPU, so T2 means beating stock three by more + than 2.5x. diff --git a/.gitattributes b/.gitattributes index 8fbd03359f..285440b7f7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,7 @@ packages/runtime-native/patches/*.patch -text # Unified-diff context prefixes precede the upstream bundle's tab indentation. patches/vite@8.2.0.patch whitespace=-space-before-tab +packages/three-native/tests/compatibility/goldens/** linguist-generated +packages/three-native/tests/compatibility/fixtures/** linguist-generated +packages/three-native/api/*.json linguist-generated +packages/three-native/generated/** linguist-generated diff --git a/.github/actions/native-engine-wasm/action.yml b/.github/actions/native-engine-wasm/action.yml new file mode 100644 index 0000000000..1f8d6e290c --- /dev/null +++ b/.github/actions/native-engine-wasm/action.yml @@ -0,0 +1,69 @@ +name: Build the product web Wasm engine, or restore the last build of the same sources +description: >- + A game on the default `engine: "native"` runs in the browser on packages/runtime-native/build/web/ + tn-native-engine-web.{mjs,wasm}. Nothing else builds it, `pnpm pack` drops a missing `files` entry + without an error, and a web build without it stops at TN_WASM_ENGINE_MISSING. Run this after + `pnpm install` and before anything that builds a web game or packs runtime-native. + +inputs: + emsdk-version: + description: The emsdk tag; keep it equal to TN_EMSDK_VERSION in ci.yml's native-engine job. + required: false + default: "6.0.11" + +runs: + using: composite + steps: + # The emsdk key is the one the native-engine job saves, so a warm cache serves both. + - name: Restore the emsdk + id: emsdk + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/emsdk + key: native-engine-emsdk-${{ runner.os }}-${{ runner.arch }}-${{ inputs.emsdk-version }} + # The key names what the engine is built from and nothing else: the C++ sources, the headers, + # the CMake files, the dependency lock and the toolchain version. A key that names too little + # serves a stale engine, so an input that could change the bytes belongs here. + - name: Restore the built web engine + id: wasm + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/packages/runtime-native/build/web + key: native-engine-web-wasm-v1-${{ runner.os }}-${{ runner.arch }}-${{ inputs.emsdk-version }}-${{ hashFiles('packages/runtime-native/src/engine/**', 'packages/runtime-native/include/**', 'packages/runtime-native/cmake/**', 'packages/runtime-native/CMakeLists.txt', 'packages/runtime-native/CMakePresets.json', 'packages/runtime-native/native-deps.lock.json', 'packages/runtime-native/scripts/download-deps.mjs') }} + - name: Build the web engine + if: steps.wasm.outputs.cache-hit != 'true' + shell: bash + working-directory: packages/runtime-native + env: + TN_EMSDK_VERSION: ${{ inputs.emsdk-version }} + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y ninja-build + if [ '${{ steps.emsdk.outputs.cache-hit }}' != true ]; then + git clone --depth 1 --branch "$TN_EMSDK_VERSION" https://github.com/emscripten-core/emsdk.git "$GITHUB_WORKSPACE/.cache/emsdk" + "$GITHUB_WORKSPACE/.cache/emsdk/emsdk" install "$TN_EMSDK_VERSION" + fi + "$GITHUB_WORKSPACE/.cache/emsdk/emsdk" activate "$TN_EMSDK_VERSION" + # The web engine reads three pinned sources: stb (images), cgltf (glTF) and libwebp. + for dependency in stb cgltf webp-source; do + node scripts/download-deps.mjs --only "$dependency" + done + source "$GITHUB_WORKSPACE/.cache/emsdk/emsdk_env.sh" + cmake --preset wasm + cmake --build --preset wasm --parallel "$(nproc)" + # Fail closed: a partial cache entry or a build that wrote nothing must not reach a pack. + - name: Check the web engine exists + shell: bash + working-directory: packages/runtime-native + run: | + set -euo pipefail + for file in build/web/tn-native-engine-web.mjs build/web/tn-native-engine-web.wasm; do + test -s "$file" || { echo "TN_WEB_ENGINE_MISSING: $file was not built" >&2; exit 1; } + done + wasm_bytes=$(stat -c %s build/web/tn-native-engine-web.wasm) + allowed_bytes=4194304 + if [ "$wasm_bytes" -gt "$allowed_bytes" ]; then + echo "TN_WEB_ENGINE_OVER_BUDGET: measured $wasm_bytes bytes, allowed $allowed_bytes bytes" >&2 + exit 1 + fi + echo "web engine ready: $wasm_bytes byte wasm (allowed $allowed_bytes bytes)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce33e243c1..7aca034688 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,7 @@ jobs: # at promotion, so this job is skipped rather than spending a runner on inert prose. A reused # tree skips it too — every gate it would run here already passed in the run that tested this # exact tree, and `!= 'prose'` would otherwise have bought a second full lint. - if: (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' + if: (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' || needs.scope.outputs.selection == 'native-engine' runs-on: ubuntu-24.04 timeout-minutes: 10 env: @@ -512,6 +512,19 @@ jobs: if: always() shell: bash run: ccache --show-stats --verbose + # PRD-573 v1.11: the engine player's 2x over the legacy host stands on counters, each pinned by one case: + # wrapper visits (step 1), the native matrix walk (2), host projection (3), three's math as JS (4a), transform + # views (4b) and the render database's slot records. They need the V8 adapter and the renderer, which the + # core-only native-engine job never builds; this job already carries V8, Dawn and Mesa's Vulkan. + - name: Build the engine player and run its 2x mechanism cases + working-directory: packages/runtime-native + run: | + set -euo pipefail + cmake --preset tn-linux -B build/tn-engine -DTN_ENGINE_ONLY=ON -DTN_ENGINE_PLAYER=ON + cmake --build build/tn-engine --target tn-native-engine-player-v8 --target tn-native-engine-v8-test \ + --target tn-native-engine-render-database-test --parallel "$(nproc)" + ctest --test-dir build/tn-engine --no-tests=error --output-on-failure -R \ + '^native_engine_(v8_(collect_visits|native_matrix_world|native_projection_report|crossing_histogram|js_math|js_math_views|js_math_members)|player_(crossing_budget|host_projection)|renderer_(steady_frame_hashes_no_record|records_survive_siblings)|batched_vs_unbatched)$' - name: Report the compiler cache hit rate if: always() shell: bash @@ -1004,7 +1017,7 @@ jobs: # `workspace-dist-*` entry it publishes is what every pull request into develop restores. if: ${{ (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') || needs.scope.outputs.selection == 'ci' || needs.scope.outputs.selection == 'warm' }} runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} - timeout-minutes: 20 + timeout-minutes: 30 env: TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} steps: @@ -1017,6 +1030,9 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile + # Before the pack inside workspace-dist: runtime-native lists the web engine in `files`, and + # `pnpm pack` drops a file that is not there yet. + - uses: ./.github/actions/native-engine-wasm - uses: ./.github/actions/workspace-dist - run: pnpm --filter abyss-framework build - name: Enforce entity registry boundaries @@ -1041,6 +1057,7 @@ jobs: path: | artifacts/workspace-packages packages/*/dist + packages/runtime-native/build/web retention-days: 1 if-no-files-found: error @@ -1167,6 +1184,89 @@ jobs: selection_plan: ${{ needs.scope.outputs.plan }} candidate_sha: ${{ needs.scope.outputs.candidate_sha }} + # PRD-573 slice 0b. A pull request that touches only the native engine's own tree + # (`NATIVE_ENGINE_PATHS` in scripts/ci-change-scope.mjs) skips the 75-minute native-platforms + # matrix and is proven here instead; a full board runs it too once the diff touches that tree or + # the run qualifies a candidate. The engine core is headless, so this job needs no display, no + # JS engine and none of test-native's third-party downloads. Until `NativeEngineCore.cmake` + # exists on the branch the job reports a notice and passes, so the required verdict never waits + # on a build that has nothing to build. It owns its ccache namespace (a shared prefix is how + # sibling lanes restored each other's objects) and a cached emsdk pinned by tag and SDK version. + native-engine: + needs: scope + if: ${{ (needs.scope.outputs.selection == 'native-engine' || needs.scope.outputs.selection == 'full') && fromJSON(needs.scope.outputs.plan).jobs['native-engine'].required }} + runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }} + timeout-minutes: 30 + env: + TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }} + CCACHE_DIR: ${{ github.workspace }}/.cache/ccache + CCACHE_MAXSIZE: 500M + CCACHE_COMPRESS: "1" + CMAKE_C_COMPILER_LAUNCHER: ccache + CMAKE_CXX_COMPILER_LAUNCHER: ccache + TN_EMSDK_VERSION: 6.0.11 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.scope.outputs.candidate_sha || github.sha }} + - name: Look for the engine core build + id: engine + shell: bash + run: | + set -euo pipefail + if [ -f packages/runtime-native/cmake/NativeEngineCore.cmake ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + echo "::notice::packages/runtime-native/cmake/NativeEngineCore.cmake does not exist on this commit; nothing to build" + fi + - name: Restore the native-engine compiler cache + if: steps.engine.outputs.present == 'true' + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/ccache + key: native-ccache-ci-native-engine-${{ runner.os }}-${{ runner.arch }}-gcc-${{ hashFiles('packages/runtime-native/cmake/NativeEngine*.cmake') }}-${{ github.run_id }} + restore-keys: native-ccache-ci-native-engine-${{ runner.os }}-${{ runner.arch }}-gcc-${{ hashFiles('packages/runtime-native/cmake/NativeEngine*.cmake') }}- + - name: Restore the emsdk + id: emsdk + if: steps.engine.outputs.present == 'true' + uses: actions/cache@v4 + with: + path: ${{ github.workspace }}/.cache/emsdk + key: native-engine-emsdk-${{ runner.os }}-${{ runner.arch }}-${{ env.TN_EMSDK_VERSION }} + - name: Install the engine toolchain + if: steps.engine.outputs.present == 'true' + shell: bash + run: | + set -euo pipefail + sudo apt-get update && sudo apt-get install -y ccache ninja-build + if [ '${{ steps.emsdk.outputs.cache-hit }}' != true ]; then + git clone --depth 1 --branch "$TN_EMSDK_VERSION" https://github.com/emscripten-core/emsdk.git "$GITHUB_WORKSPACE/.cache/emsdk" + "$GITHUB_WORKSPACE/.cache/emsdk/emsdk" install "$TN_EMSDK_VERSION" + fi + "$GITHUB_WORKSPACE/.cache/emsdk/emsdk" activate "$TN_EMSDK_VERSION" + - name: Build and test the engine core natively + if: steps.engine.outputs.present == 'true' + working-directory: packages/runtime-native + shell: bash + run: | + set -euo pipefail + ccache --zero-stats + cmake -S . -B build/native-engine -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DTN_ENGINE_CORE_ONLY=ON + cmake --build build/native-engine --parallel "$(nproc)" + ctest --test-dir build/native-engine -L native-engine --no-tests=error --output-on-failure + ccache --show-stats + - name: Build and test the engine core as Wasm under node + if: steps.engine.outputs.present == 'true' + working-directory: packages/runtime-native + shell: bash + run: | + set -euo pipefail + source "$GITHUB_WORKSPACE/.cache/emsdk/emsdk_env.sh" + emcmake cmake -S . -B build/native-engine-wasm -G Ninja -DCMAKE_BUILD_TYPE=Release -DTN_ENGINE_CORE_ONLY=ON + cmake --build build/native-engine-wasm --parallel "$(nproc)" + ctest --test-dir build/native-engine-wasm -L native-engine --no-tests=error --output-on-failure + integration: needs: scope if: needs.scope.result == 'success' && fromJSON(needs.scope.outputs.plan).jobs.integration.required @@ -1184,7 +1284,7 @@ jobs: # Skipped for Markdown-only PRs: the nightly develop run and promotions still scan the full # git history, so an inert prose PR does not spend a runner here. Skipped for a reused tree for # the same reason every other gate is: the run that tested this exact tree already scanned it. - if: (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' + if: (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'template') || needs.scope.outputs.selection == 'instructions' || needs.scope.outputs.selection == 'ci' || needs.scope.outputs.selection == 'native-engine' # PR-only until 2026-09-01 (owner call): a push to main skipped the whole job, so the board # read a skip where the secrets scan should have been. Pushes now scan the repository's full # git history — gitleaks' default git mode — which is strictly stronger than the incremental @@ -1250,7 +1350,7 @@ jobs: # The only required context after protected-develop cutover. Explicit exemptions are not passes. # Legacy main contexts stay in place until the owner changes protection after full qualification. ci-required: - needs: [scope, integration, native-platforms, typecheck, lint, test, test-unit, test-native, test-browser, test-playtest, golden-path-template, template-nonvisual, golden-path, benchmark, build, build-artifacts, budgets, performance-contracts, supply-chain] + needs: [scope, integration, native-platforms, native-engine, typecheck, lint, test, test-unit, test-native, test-browser, test-playtest, golden-path-template, template-nonvisual, golden-path, benchmark, build, build-artifacts, budgets, performance-contracts, supply-chain] if: ${{ always() && !github.event.pull_request.draft }} runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }} timeout-minutes: 5 @@ -1297,6 +1397,7 @@ jobs: - scope - integration - native-platforms + - native-engine - typecheck - lint - test diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index c2ab6e7e9a..5ca9fa2e67 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -84,7 +84,7 @@ jobs: decals ^packages/playtest/.*$|^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^packages/runtime-native/.*$|^scripts/xvfb\.sh$|^\.github/actions/workspace-dist/.*$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^pnpm-lock\.yaml$ fluid-particles ^packages/core/(src|__tests__)/(fluid|gpu-readback|particles)[^/]*$|^packages/(physics|playtest)/.*$|^examples/prd476-fluid-particles/.*$|^scripts/fluid-collision-proof\.ts$|^scripts/__tests__/fluid-collision-(proof|view)\.spec\.ts$|^package\.json$|^pnpm-(lock|workspace)\.yaml$|^tsconfig[^/]*\.json$|^patches/.*$|^\.github/actions/pnpm/.*$|^scripts/workspace-packages\.ts$|^scripts/verify-fluid-(collision|consumers)\.ts$|^\.github/actions/playwright-chromium/.*$|^scripts/xvfb\.sh$|^scripts/observe-fluid-ci\.mjs$|^scripts/__tests__/observe-fluid-ci\.spec\.ts$ fluid-native ^packages/core/(src|__tests__)/(fluid|gpu-readback|particles)[^/]*$|^packages/(physics|playtest)/.*$|^examples/prd476-fluid-particles/.*$|^scripts/fluid-collision-proof\.ts$|^scripts/__tests__/fluid-collision-(proof|view)\.spec\.ts$|^package\.json$|^pnpm-(lock|workspace)\.yaml$|^tsconfig[^/]*\.json$|^patches/.*$|^\.github/actions/pnpm/.*$|^scripts/workspace-packages\.ts$|^packages/runtime-native/.*$|^scripts/verify-fluid-collision-native\.ts$|^scripts/__tests__/fluid-collision-native-proof\.spec\.ts$|^\.github/actions/workspace-dist/.*$ - fog ^packages/create-threenative/templates/starter/src/render/volumetricFog[A-Za-z]*\.ts$|^packages/create-threenative/templates/starter/src/render/postprocessing\.ts$|^packages/create-threenative/templates/starter/src/scenes/Play\.ts$|^packages/create-threenative/template-assets/worldEnvironment\.ts$|^packages/create-threenative/__tests__/vq-(volumetric-fog|fog-fixture)\.spec\.ts$|^packages/core/src/renderer\.ts$|^packages/core/src/render/chain\.ts$|^examples/abyss-framework/vq-fog/.*$|^examples/abyss-framework/playtests/vq-volumetric-fog\.playtest\.json$|^scripts/verify-volumetric-fog\.ts$|^scripts/__tests__/(verify-volumetric-fog|vq-fog-native-proof)\.spec\.ts$|^packages/runtime-native/include/mystral/webgpu_compat\.h$|^packages/runtime-native/src/webgpu/bindings_presentation\.cpp$|^packages/runtime-native/tests/surface_texture_status_test\.cpp$|^packages/runtime-native/tests/canvas_presentation_test\.cpp$ + fog ^packages/create-threenative/templates/starter/src/render/volumetricFog[A-Za-z]*\.ts$|^packages/create-threenative/templates/starter/src/render/postprocessing\.ts$|^packages/create-threenative/templates/starter/src/scenes/Play\.ts$|^packages/create-threenative/template-assets/worldEnvironment\.ts$|^packages/create-threenative/__tests__/vq-(volumetric-fog|fog-fixture)\.spec\.ts$|^packages/core/src/renderer\.ts$|^packages/core/src/render/chain\.ts$|^examples/abyss-framework/vq-fog/.*$|^examples/abyss-framework/playtests/vq-volumetric-fog\.playtest\.json$|^scripts/verify-volumetric-fog\.ts$|^scripts/__tests__/(verify-volumetric-fog|vq-fog-native-proof)\.spec\.ts$|^packages/runtime-native/include/mystral/webgpu_compat\.h$|^packages/runtime-native/src/webgpu/bindings_presentation\.cpp$|^packages/runtime-native/src/webgpu/presentation\.cpp$|^packages/runtime-native/include/mystral/webgpu/presentation\.h$|^packages/runtime-native/tests/surface_texture_status_test\.cpp$|^packages/runtime-native/tests/canvas_presentation_test\.cpp$ ik ^examples/integrations/ik/.*$ native-assets ^packages/create-threenative/src/build\.ts$|^packages/create-threenative/src/buildReport\.ts$|^packages/create-threenative/__tests__/vq-native-[^/]*\.spec\.ts$|^packages/assets/src/(compile|report)\.ts$|^packages/core/src/assets\.ts$|^packages/runtime-native/scripts/bundle\.mjs$|^packages/runtime-native/scripts/package-desktop\.mjs$|^packages/runtime-native/scripts/native-build\.mjs$|^packages/runtime-native/scripts/download-deps\.mjs$|^examples/abyss-framework/vq-assets/.*$|^examples/abyss-framework/playtests/vq-native-asset-capabilities\.playtest\.json$|^scripts/verify-native-asset-capabilities\.ts$|^scripts/native-asset-capture-proof\.ts$|^scripts/__tests__/native-asset-capture-proof\.spec\.ts$ temporal ^packages/core/src/render/chain\.ts$|^packages/core/src/render/velocity\.ts$|^packages/core/__tests__/temporal-chain\.spec\.ts$|^packages/create-threenative/templates/starter/src/render/temporalAA[^/]*\.ts$|^packages/create-threenative/templates/starter/src/render/temporalResolve[^/]*\.ts$|^packages/create-threenative/templates/starter/src/render/temporalCurrent[^/]*\.ts$|^packages/create-threenative/templates/starter/src/render/temporalRejectionCounter\.ts$|^packages/create-threenative/__tests__/temporal[^/]*\.spec\.ts$|^packages/runtime-native/conformance/scenes/shared/temporal-aa[^/]*\.js$|^packages/runtime-native/conformance/scenes/shared/temporal-velocity-probe\.ts$|^packages/runtime-native/conformance/scenes/shared/temporal-resolve-probe\.ts$|^packages/runtime-native/__tests__/temporal-velocity-probe\.spec\.ts$|^examples/abyss-framework/temporal[^/]*$|^examples/abyss-framework/playtests/temporal-[^/]*\.playtest\.json$|^scripts/verify-temporal-[^/]*\.ts$|^scripts/temporal-aa-[^/]*\.ts$|^scripts/__tests__/temporal-aa-[^/]*\.spec\.ts$ diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml index f7b630adcd..b66b016165 100644 --- a/.github/workflows/native-release.yml +++ b/.github/workflows/native-release.yml @@ -416,6 +416,9 @@ jobs: executable: mystral asset: threenative-runtime-linux-x64 tools_asset: threenative-tools-linux-x64 + player: tn-native-engine-player-v8 + player_asset: threenative-engine-player-linux-x64 + player_ui: "ON" - key: darwin-arm64 published: false runner: macos-15 @@ -423,6 +426,9 @@ jobs: executable: mystral asset: threenative-runtime-darwin-arm64 tools_asset: threenative-tools-darwin-arm64 + player: tn-native-engine-player-v8 + player_asset: threenative-engine-player-darwin-arm64 + player_ui: "OFF" - key: win32-x64 published: true runner: windows-2025 @@ -430,6 +436,9 @@ jobs: executable: mystral.exe asset: threenative-runtime-win32-x64.exe tools_asset: threenative-tools-win32-x64.exe + player: tn-native-engine-player-v8.exe + player_asset: threenative-engine-player-win32-x64.exe + player_ui: "OFF" runs-on: ${{ matrix.runner }} timeout-minutes: 60 steps: @@ -483,8 +492,17 @@ jobs: set "VCPKG_ROOT=%VCPKG_INSTALLATION_ROOT%" where cl pnpm --filter @threenative/runtime-native native:build + cd packages/runtime-native + cmake --preset ${{ matrix.preset }} -B build/tn-engine-ui -DTN_ENGINE_ONLY=ON -DTN_ENGINE_PLAYER=ON -DTN_ENGINE_PLAYER_UI_OVERLAY=${{ matrix.player_ui }} + cmake --build build/tn-engine-ui --config Release --target tn-native-engine-player-v8 - if: runner.os != 'Windows' run: pnpm --filter @threenative/runtime-native native:build + - name: Build Release engine player + if: runner.os != 'Windows' + working-directory: packages/runtime-native + run: | + cmake --preset ${{ matrix.preset }} -B build/tn-engine-ui -DTN_ENGINE_ONLY=ON -DTN_ENGINE_PLAYER=ON -DTN_ENGINE_PLAYER_UI_OVERLAY=${{ matrix.player_ui }} + cmake --build build/tn-engine-ui --config Release --target tn-native-engine-player-v8 # `VsDevCmd.bat` configures the process it runs in, and a `cmd` step's environment dies with # the step. So this ran without INCLUDE, LIB or a usable `cl`, and the first thing inside it # that *configures* a build directory — the physics contract lane — got "the C compiler is @@ -517,6 +535,8 @@ jobs: ASSET_NAME: ${{ matrix.asset }} EXECUTABLE_NAME: ${{ matrix.executable }} PRESET: ${{ matrix.preset }} + PLAYER_NAME: ${{ matrix.player }} + PLAYER_ASSET_NAME: ${{ matrix.player_asset }} TOOLS_ASSET_NAME: ${{ matrix.tools_asset }} run: | node --input-type=module <<'NODE' @@ -527,6 +547,10 @@ jobs: // (`src/cli/tool_dispatch.cpp:52`). It is a published release asset in its own right, so a // consumer that installed only the runtime can no longer reach `build tool helper is // missing` / exit 127 on `threenative build --target desktop`. + const playerSource = join("packages", "runtime-native", "build", "tn-engine-ui", process.env.PLAYER_NAME); + const playerOutput = join("release", process.env.PLAYER_ASSET_NAME); + copyFileSync(playerSource, playerOutput); + if (statSync(playerOutput).size === 0) throw new Error(`${playerOutput} is empty.`); const toolsName = process.env.EXECUTABLE_NAME.replace(/^mystral/u, "mystral-tools"); const stage = [ [process.env.EXECUTABLE_NAME, process.env.ASSET_NAME], @@ -550,6 +574,7 @@ jobs: path: | release/${{ matrix.asset }} release/${{ matrix.tools_asset }} + release/${{ matrix.player_asset }} if-no-files-found: error - uses: actions/upload-artifact@v7 if: always() @@ -895,6 +920,7 @@ jobs: # A real Linux consumer needs it too, so it belongs inside the proof, not around it. sudo apt-get install -y mesa-vulkan-drivers libwebkit2gtk-4.1-0 - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/native-engine-wasm - name: Pack the exact scaffold consumer packages run: | mkdir -p "$RUNNER_TEMP/packages" @@ -904,6 +930,8 @@ jobs: pnpm --filter "$package_name" --if-present run build pnpm --filter "$package_name" pack --pack-destination "$RUNNER_TEMP/packages" done < "$RUNNER_TEMP/threenative-package-specs" + - name: Verify packed default-engine desktop and web consumer + run: nice -n 19 pnpm tsx scripts/verify-one-template-desktop.ts platformer --archives "$RUNNER_TEMP/packages" - name: Scaffold from local tarballs uses: ./.github/actions/scaffold-from-tarballs with: diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 1a02a3dfcf..7ec059664b 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -47,6 +47,11 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile + # The release packs runtime-native, whose `files` list the built web engine. pnpm pack drops a + # missing entry silently, so the engine is built first and publish:check fails if it is absent. + - uses: ./.github/actions/native-engine-wasm + # Also listed in runtime-native `files` and downloaded, not tracked. + - run: node packages/runtime-native/scripts/download-deps.mjs --only gradle-wrapper # CI already ran the generic gates on this exact tree. Query that immutable result instead of # rebuilding the workspace and running the same typecheck, lint and unit suite a second time. # A manual dry run remains useful before a commit reaches main, so only a publishing run @@ -88,6 +93,9 @@ jobs: cache: pnpm registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile + - uses: ./.github/actions/native-engine-wasm + # Also listed in runtime-native `files` and downloaded, not tracked. + - run: node packages/runtime-native/scripts/download-deps.mjs --only gradle-wrapper - run: pnpm build - name: Install native desktop runtime dependencies for the guarded verifier run: | @@ -148,6 +156,7 @@ jobs: # @threenative/raw-unreal # @threenative/runtime-native # @threenative/terrain + # @threenative/three-native # @threenative/ueformat # @threenative/ui # create-threenative diff --git a/AGENTS.md b/AGENTS.md index 5545a897f7..72100fa0e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro 2. **Kill switch.** An abstraction costing more code than plain Three.js is deleted; `scripts/count-loc.ts` scores every repetition. 3. **Never own the look.** Geometry, material, colour, texture, curve and timing come from the game; it must be able to change the appearance completely without editing package code. `GPUParticles3D` is the shape. 4. **Borrow vocabulary**: Godot for nodes (the only node source), Three.js for rendering, Rapier for physics, Tailwind for UI, in camelCase. **A package exists only when it carries a dependency the others must not inherit.** -5. **Closed, outranking rule 1**: an IR, a scene format, an editor, a preset/genre system, a code-first ECS, a bespoke CLI vocabulary. **Web-only is unfinished**: a helper admitted for being unportable ships native proof (a conformance case or a `--target` playtest) in the same commit, and no result claims a platform it did not run on. Contract: `packages/runtime-native/AGENTS.md`. +5. **Closed, outranking rule 1**: an authored IR, a second renderer, a scene format, an editor, a preset/genre system, a code-first ECS, a bespoke CLI vocabulary. **Web-only is unfinished**: a helper admitted for being unportable ships native proof (a conformance case or a `--target` playtest) in the same commit, and no result claims a platform it did not run on. Contract: `packages/runtime-native/AGENTS.md`. **One exception, opt-in**: the owned C++ engine (renderer plus internal shader IR) behind the Three.js API; it is the default engine for web and desktop since 2026-10-15, and `engine: "legacy"` is the named override (`docs/architecture/NATIVE-ENGINE-DECISION.md`). ## Commands diff --git a/CHANGELOG.md b/CHANGELOG.md index 605207d2f2..fcdc0d448c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Breaking +- `@threenative/playtest/runner#advanceFixedStep` now advances and settles each + driven world frame individually, so browser HUD work runs between frames. Its + optional fourth `settle` argument defaults to `true`; existing three-argument + callers remain valid. Resource polling can pass `false` to keep its deadline. + - The unreleased terrain editor's `mountTerrainEditor` view factory receives `(host, controller)`. Its `IEditorView` implements `setDocument(document, revision)` so metadata edits can update the scene without dispatching terrain evaluation; diff --git a/CLAUDE.md b/CLAUDE.md index cfc4212ef4..225e1fb622 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro 2. **Kill switch.** An abstraction costing more code than plain Three.js is deleted; `scripts/count-loc.ts` scores every repetition. 3. **Never own the look.** Geometry, material, colour, texture, curve and timing come from the game; it must be able to change the appearance completely without editing package code. `GPUParticles3D` is the shape. 4. **Borrow vocabulary**: Godot for nodes (the only node source), Three.js for rendering, Rapier for physics, Tailwind for UI, in camelCase. **A package exists only when it carries a dependency the others must not inherit.** -5. **Closed, outranking rule 1**: an IR, a scene format, an editor, a preset/genre system, a code-first ECS, a bespoke CLI vocabulary. **Web-only is unfinished**: a helper admitted for being unportable ships native proof (a conformance case or a `--target` playtest) in the same commit, and no result claims a platform it did not run on. Contract: `packages/runtime-native/AGENTS.md`. +5. **Closed, outranking rule 1**: an authored IR, a second renderer, a scene format, an editor, a preset/genre system, a code-first ECS, a bespoke CLI vocabulary. **Web-only is unfinished**: a helper admitted for being unportable ships native proof (a conformance case or a `--target` playtest) in the same commit, and no result claims a platform it did not run on. Contract: `packages/runtime-native/AGENTS.md`. **One exception, opt-in**: the owned C++ engine (renderer plus internal shader IR) behind the Three.js API; it is the default engine for web and desktop since 2026-10-15, and `engine: "legacy"` is the named override (`docs/architecture/NATIVE-ENGINE-DECISION.md`). ## Commands diff --git a/PERFORMANCE-LEARNINGS.md b/PERFORMANCE-LEARNINGS.md new file mode 100644 index 0000000000..bbd6cbaa74 --- /dev/null +++ b/PERFORMANCE-LEARNINGS.md @@ -0,0 +1,170 @@ +# Performance learnings + +This file is the central record of performance levers tried in this repository, and of the method lessons they +taught. Read it before you choose a lever. Add one entry for each closed lever (keep, reject or inconclusive) +before you start the next one, and an insight from a failure only when it is valuable. Update an existing entry +instead of adding a near-duplicate, and merge or trim entries when the file grows redundant. Keep the conclusion of +every rejected lever so that nobody retries it without a new reason. The `performance-optimization-loop` skill follows this file. Older per-PRD records are in each +PRD's "Tried and rejected" or "Decisions" section, for example +[PRD-610](docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md). + +## Method lessons + +- **Low native IPC was thread placement, not V8 or engine code.** On Xvfb the GPU worker busy-waits inside the NVIDIA present (`__vdso_clock_gettime`, 55% of the player's process cycles) and shares cores with the game thread. The same Midway binary read 32M to 50M cycles per frame at 20.5M instructions as host load changed. An external watcher that gave the game thread its own core moved main-thread CPU 7.22 / 9.41 / 9.77 → 6.11 / 6.64 / 7.74 ms (IPC 0.66 / 0.57 / 0.70 → 0.82 / 0.83 / 0.93) with identical instructions (2026-10-15, campaign `artifacts/native-performance-loop/prd610-placement-20261015`). Before you judge a lever by CPU ms, check where the game thread and the present worker run; a lever smaller than the placement noise cannot be resolved by more pairs. + +- **Profile the owning worker before optimizing the main thread.** The PRD-610 exact-window native process recording attributes dominant worker samples to Vulkan presentation/NVIDIA-driver ancestry, including clock reads; main-thread sampling would miss this owner. Retain recorder scope, sample losses and resolved ancestry. CPU samples inside present do not establish whether GPU backlog, driver polling or a virtual display causes the cost; render-pass GPU timestamps also exclude queue waiting and final swapchain presentation. Use a separate discriminator before choosing a fix. + +- **Measure full-loop completion separately from render-body cost:** [PRD-610 AC-19](docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md) records the unqualified first diagnostic and its missing body-cost attribution. Midway publishes state every six updates, so recorded state-transition timestamps are publication witnesses, not exact simulation-event times. Native-only HUD/player coordinates are not cross-arm equality witnesses; use existing shared state and scene-camera observations while retaining exact-state limitations. + +- **Include cache storage in the measured candidate.** The experimental Object3D compose cache compares bitwise TRS/pivot inputs and still verifies published matrix bytes, preserving escaped raw writes. It adds 240 bytes/node (440 → 680), allocated without an environment switch. Baseline edge cases pass before implementation; the candidate passes 80 focused native checks plus startup. This establishes readiness only: unchanged-transform work may shrink, but larger nodes and input comparisons can offset it. Timing, parity and a fresh visual judge are still required before promotion (PRD-610 AC-20). + +- **LTO can expose generated-header ODR bugs.** The runtime and math script headers generated different external-inline `runtime_scripts::find` definitions. With global GCC IPO, both audio startup fixtures failed despite identical, complete generated inputs. Giving each generated lookup internal linkage (`static inline`) makes both fixtures pass on IPO and rebuilt non-IPO arms. SDL's existing tray `objcopy` rewrite also requires `SDL3-static` to remain non-LTO. Each corrected arm passes 37 CPU checks plus 27 V8/player checks and warning-free audio startup; this is correctness readiness, not a measured speed win. + +- **Count redundant setters before guarding them.** `TN_MATERIAL_DIAGNOSTICS=1` now adds cumulative opacity-write buckets and record/graph rebuild counts plus thread CPU to existing `TN_PLAYER_RENDER_HOLD` boundaries. Default off; setter/version semantics remain unchanged. Four focused ctests pass after missing-member compiler RED. Difference the host-tick markers around the exact 1,000-tick window; cache-miss-body CPU is an upper bound within those bodies, not complete rebuild cost or opacity-only attribution. The first diagnostic acquisition rejected the invalidation guard budget (see the native opacity entry below). + +- **Size a lever before you code it.** Measure its ceiling with a counter or a phase timer inside the measured pose + windows (`TN_PLAYER_CROSSINGS=1`, `TN_RENDER_PREPARE_SPLIT=1`, a temporary env counter). Then project calls per + frame times cost per call. On 2026-10-14, two levers chosen from profile shares were flat in A/B. +- **A profile window must exclude loading.** A 100 s call-graph capture of the Midway player included load-time + garbage collections, so it gave the V8 GC prologue 4.9% of samples. Inside the pose windows there was one GC per + 34 frames. `TN_PLAYTEST_CPU_PROFILE_DIR` now records only the existing ACK-controlled 1,000-frame pose windows; + sampled windows and runs are excluded from benchmark qualification. +- **Bound symbol decoding too.** On the native monolith, full inline expansion left `addr2line` at 99% CPU. + `perf report --no-inline` decoded all three recordings in under one second each. Set `DEBUGINFOD_URLS=` for + local-only decoding; use `TN_V8_FLAGS=--perf-basic-prof` before launch when JIT names are needed, and preserve + that player's `/tmp/perf-.map`. Unresolved anonymous samples are not proven JavaScript. +- **Instructions per frame are the low-noise metric on a loaded host.** Milliseconds and cycles moved by 13 to 17% + when only the host load changed (load 18 to 11) on 2026-10-14. Alternate off and on runs, and judge with at least + two pairs. +- **Retired instructions undercount memory-bound work.** Record cycles or thread CPU time beside them. When the two + disagree, the change moved memory traffic, not computation. +- **Serialize timed runs with one lock.** An "idle for 40 s" `pgrep` check starved queued runs for 50 minutes, + because `pgrep -f` matches shells whose command text only contains the script name. +- **A capture timeout is not a game result.** The shared capture queue races for admission and is not FIFO; its default 120 s wait can expire behind a healthy four-minute playtest. Doctor passed after `visible-resume-base-1` exited 75 with zero windows. Existing `CAPTURE_LOCK_TIMEOUT_MS=900000` permits a bounded 15-minute wait; preserve failed attempts and do not retry again on the same queue-timeout cause. +- **Ratio levers live in the engine.** A fix in code that both arms run (for example `@threenative/core`) lowers the + absolute cost but does not move the native or web Wasm ratio against three.js. + +## Levers + +### 2026-10-15 — Game thread pinned to the CPPC top-ranked core — REJECT +- Where: PRD-610 AC-20, commit `6878c53ff` (superseded by `61c7c1d06` and `c4be636dc`, same `ThreadPlacement` in `player/run.cpp`). +- Result: `q-place-auto-1` 10.65 / 11.07 / 15.56 ms against `q-place-off-1` 8.67 / 9.26 / 10.98 ms (IPC 0.46); load 12 to 16. +- Cause: preferred-core scheduling packs every busy task onto the top-ranked cores first; a foreign Chrome renderer ran on CPU 3 at 82%, and a pinned thread cannot leave. Pick the last-ranked core, or give the game thread a whole L3 domain so it can move. +- Artifact: `~/.cache/tmp-tn/screens/q-place-{auto,off}-1/`; campaign experiment E1. + +### 2026-10-15 — Game thread pinned to the CPPC last-ranked core pair — INCONCLUSIVE +- Where: PRD-610 AC-20, commit `61c7c1d06`; placement is always on and needs no setting (commit `72b606bb2`). +- Ceiling: the external-pinning diagnostic moved main-thread CPU 7.22 / 9.41 / 9.77 → 6.11 / 6.64 / 7.74 ms at identical instructions. +- Result: series 1, 3 pairs: median -21 / -33 / -32%, 8 of 9 pose-pairs faster, but one baseline at load 10.7 set margins of 130 / 105 / 35%. Series 2 (load-gated): 2 valid pairs, both faster at every pose (-24 / -5 / -11% and -38 / -34 / -11%), instructions identical, all pass; the third candidate run ended at load 32.7 and then 11.7, and baseline spread set margins of 14 / 29 / 33%. +- Cause: the direction is consistent, but other lanes on this shared host move the baseline by 10-30%, more than the gain at two poses. +- Insight: a placement lever needs a quiet host (other lanes paused) or the real-display lane to qualify; more pairs on a loaded host do not converge. +- Artifact: `~/.cache/tmp-tn/screens/q-place-off-*`, `q-last-auto-*`, `q-e2s2-*`; campaign `artifacts/native-performance-loop/prd610-placement-20261015/`; dashboard cycle `native-main-core-placement`. + +### 2026-10-15 — Game thread on a whole L3 domain (CCD split) — REJECT +- Where: PRD-610 AC-20, commit `c4be636dc` (`ThreadPlacement`, auto takes the last-ranked core's `cache/index3` domain). +- Result: `q-l3-off-1/2` 7.81-7.92 / 8.39-8.46 / 10.01-10.08 ms (baseline within 1%); `q-l3-auto-1r` 9.58 / 8.01 / 8.39 ms: takeoff +23%, cockpit -5%, rear gunner -16%. Two more candidate runs ended above load 8 and were excluded. +- Cause: a 12-CPU domain lets the scheduler put the game thread on a CPU that other lanes' work also uses, so the gain depends on what else runs on that CCD. A pinned core pair (E2) is the shape to test. +- Artifact: `~/.cache/tmp-tn/screens/q-l3-*`; campaign experiment E3. + +### 2026-10-15 — `__GL_YIELD=USLEEP` to stop the NVIDIA present spin — REJECT +- Where: PRD-610 AC-20, environment variable only. +- Result: `q-yield-rt0-1` 8.37 / 8.95 / 11.98 ms, IPC 0.58 to 0.61, process-tree CPU not lower; one run, second cancelled. +- Cause: the variable does not change the Vulkan present wait on this driver (615.78.08). Move the spinning thread away from the game thread instead. + +### 2026-10-15 — Vector3.x V8 Fast API wrapper — REJECT readiness +- Where: PRD-610 AC-20; exact retained V8 13.1.201.22. CFunction requires kThrow while the public scalar setter is constructible; preserve that observable contract. +- Result: constructible JS-wrapper compatibility checks pass, but the required optimized numeric window remains fast0/slow1 after a distinct constant-arity/captured-intrinsic retry. An optimized invalid-handle check reaches CFunction and preserves its exception, which is insufficient proof of normal dispatch. No timed acquisition or runtime improvement claimed. +- Insight: API eligibility and one reachable fast error path do not establish a useful property-assignment fast path. Stop at failed dispatch proof; do not change constructor/validation semantics or keep widening the wrapper. Visible JS function source was an explicitly accepted reflection difference. +- Artifact: exact failed patch `/tmp/prd610-fast-wrapper-constant-trial.patch`, logs `/tmp/prd610-fast-wrapper-*`. Adapter prototype restored; retained constructor characterization and restored focused check PASS 1/1. No integration, CPU/FPS/parity claim; AC-20 remains open. + +### 2026-10-15 — Object3D compose cache — REJECT / NO-GO +- Where: PRD-610 AC-20, trial `1766f64ac`; preserve source/results on local `perf/prd610-compose-trial` before cache-only restoration. +- Result: one exploratory pair; instructions −0.13/−0.36/−0.51%, insufficient for +240 bytes/node (440 → 680, +54.5%). CPU is INCONCLUSIVE because all baseline window loads exceed the predeclared ≤12 guard; raw CPU changes are not attributed savings. Full table is in PRD-610. Both PASS 3/3 on RTX 2080, exact 1,000-count windows, identical corresponding tick ranges; dashboard 184. +- Insight: an empty capture queue admission gate never passed (20 observations/19 minutes, no acquisitions). Revised CPU-only quiet admission then joined the existing serializer; both arms passed admission, but baseline load rose inside measurement. Preflight quiet does not guarantee quiet measured windows. Preserve guards and report instructions separately; no second pair or promotion. +- Artifact: `~/.cache/tmp-tn/screens/compose-load-{base,on}-1/`, original PNGs unchanged; matching frozen hashes and nine nonbinary files. No existing compose cycle entry exists, so no duplicate cycle is invented. No CPU causality, qualified parity or visual-judge claim. + +### 2026-10-15 — Standard GCC IPO — INCONCLUSIVE +- Where: PRD-610 AC-20; corrected generator/source commit `18839e526`; optional standard CMake IPO, strict FP retained, default dependencies unchanged, SDL non-LTO for objcopy. +- Result: one exploratory pair, instructions −3.85/−3.43/−2.55%; CPU +40.65/+26.22/−8.45%. Both PASS 3/3 on RTX 2080 with exact 1,000-frame/update/render windows; attachment produces a one-tick offset between arms. Full CPU/instruction/cycle/IPC/load table is in PRD-610. Dashboard 182 runs. +- Cause: CPU acceptance fails in two poses; higher candidate host load and changed IPC leave attribution unresolved. Instruction reduction does not establish a qualified CPU improvement. No second pair or integration; IPO remains optional and off by default. +- Insight: KEEP the generator internal-linkage correctness fix independently of IPO timing. Preserve the original failed IPO artifact and startup logs; do not conflate its ODR failure with the corrected trial's performance verdict. +- Artifact: `~/.cache/tmp-tn/screens/ipo-fixed-{base,on}-1/`; `build/tn-engine-ui-ipo/trial-fixed-identity.json`; cycle `native-gcc-ipo` closes INCONCLUSIVE at the final wrapper terminal time. + +### 2026-10-15 — Guard redundant opacity writes to avoid native cache invalidation — REJECT (budget) +- Where: PRD-610 AC-20; diagnostic `d8384443f`, `TN_MATERIAL_DIAGNOSTICS=1 TN_RENDER_THREAD=0`. No guard implemented; setters and versions unchanged. +- Result: `perf-opacity-window-1` exits 0, PASS 3/3 assertions on RTX 2080. Each of three HOLD pairs brackets exactly 1,000 host ticks, matching 1,000 frames/updates/renders. Takeoff / cockpit / rear gunner: 94,804 / 100,000 / 100,000 same-value opacity writes, but only 10 / 58 / 12 record misses; record cache-miss-body CPU is 0.0000426 / 0.0001719 / 0.0000579 ms/frame. Graph misses and measured graph CPU are zero; thread timing supported. +- Decision: redundant writes do not establish expensive invalidation. The observed cache-miss bodies are too cheap to budget a useful guard gain. Timers are separate, exclude later materialization, and do not attribute work to opacity; no complete-rebuild-cost or parity claim. +- Artifact: `~/.cache/tmp-tn/screens/perf-opacity-window-1/{run.log,host-load.txt,diagnostic-identity.json}`; dashboard row is diagnostic and excluded, cycle `native-opacity-invalidation` closed reject. Frozen baseline/game/assets preserved; AC-20 remains open. + +### 2026-10-15 — Isolated pointer-compressed V8 13.1.201.22 build — REJECT +- Where: PRD-610 AC-20; isolated `player-v8pc`, compression/shared-cage/31-bit-Smi macros, sandbox off, no ThinLTO/PGO. This rejects this build; compiler and configuration differences prevent attributing the result to compression alone. +- Result: baseline → candidate, takeoff / cockpit / rear gunner: CPU 8.897 → 12.210 / 8.194 → 10.747 / 12.242 → 12.875 ms/frame; instructions 20.449 → 20.871 / 22.762 → 23.144 / 29.996 → 30.299 M/frame; IPC 0.577 → 0.420 / 0.704 → 0.529 / 0.607 → 0.575. CPU worsens 37.23 / 31.16 / 5.17%; instructions rise 2.07 / 1.68 / 1.01%. +- Decision: stop after the first pair because every pose worsens CPU and instructions; no second pair needed to reject this candidate. Both runs exit 0, pass 3/3 assertions on RTX 2080, and supply three exact 1,000-frame/update/render windows each. Adapter checks passed 27/27. This is exploratory, with no qualified parity or acceptance claim. +- Artifact: `~/.cache/tmp-tn/screens/v8pc-resume-{base,on}-1/`; auto-published excluded dashboard rows; cycle `native-v8-pointer-compression`. Matching headers/archive and build logs remain under `packages/runtime-native/build/{v8pc-deps,tn-engine-ui-v8pc}`. Default baseline and archive preserved; the optional CMake dependency seam leaves defaults unchanged. Dashboard publication took priority; the cycle ledger records 27.56 minutes (05:38:24–06:05:58 UTC), within the 30-minute target. + +### 2026-10-15 — Web binding arena and fixed-arity wrappers — REJECT +- Where: PRD-612 AC-3, candidate `3d08db039` in `perf/prd610-promote`; frozen web game copies. +- Ceiling: the 23–26% engine-JS-glue owner includes all wrappers; this candidate only removes overflow allocation/bookkeeping and generic counter dispatch. Baseline already had a 64 KB arena. +- Result: one exact-window exploratory pair passes four assertions on NVIDIA. Takeoff / cockpit / rear-gunner instructions fall only 0.019 / 0.075 / 0.761%; CPU changes −7.12 / −7.42 / +11.02% with 27-minute separation and load drift. No all-pose CPU win or qualified parity. Network-failed and interrupted attempts are excluded. +- Insight: removing small machinery inside a large owner does not remove that owner. Size the specific crossing class or operation before implementing its replacement; no equivalent retry without a larger measured ceiling. +- Artifact: `~/.cache/tmp-tn/screens/glue-resume-{base-1,on-clean-1}/`; dashboard cycle `web-glue-arena-resume`. Candidate remains unpromoted. + +### 2026-10-15 — Exact-window native stack sampling — KEEP (diagnostic) +- Where: PRD-610 AC-15/AC-20, branch `perf/prd610-resume`; existing Linux CPU-window collector and dashboard qualification. +- Ceiling: diagnostic only; removes loading and pose-boundary ambiguity, not game CPU work. +- Result: `perf-rt-exact-off-1` passes runtime assertions and three exact 1,000-update/render windows. Samples: 1,769 / 2,106 / 2,847; zero lost, decoded callchains. `RenderDatabase::project` has 6.10% / 5.41% / 6.06% self cycles. Unresolved shares are 7.11% / 7.96% / 9.25%; no JIT map was enabled. Real sampled run is excluded from CPU qualification, including with a non-`perf-` tag. Focused red/green: eight feature failures, then 179 passing tests; typecheck, build, publint and independent review pass. +- Cause: the recorder has its own FIFO/ACK lifecycle and samples the owned main TID only around the same fixed-step advance as the counters. +- Follow-up: `perf-rt-jit-off-1` enables `TN_V8_FLAGS=--perf-basic-prof`, preserves the owned player map, and passes three assertions plus exact 1,000-frame windows. A self-cycle symbol-prefix split gives engine/host C++ 61.02 / 64.19 / 67.23%, V8 runtime 19.57 / 15.99 / 13.78%, V8 JIT 10.43 / 10.02 / 8.91%, unresolved 1.13 / 1.80 / 1.32% (takeoff / cockpit / rear gunner). The monolith mixes V8 and engine symbols, so these are heuristic owner groups, not independent libraries. Most cycles remain outside V8; compression alone cannot be budgeted as fixing the whole IPC gap. +- Artifact: `~/.cache/tmp-tn/screens/perf-rt-exact-off-1/profiles/`, dashboard cycle `native-exact-window-profile`, and saved source patch in the original handover scratchpad (`exact-window-profile.patch`). No speed winner promoted. + +### 2026-10-15 — PRD-613 Stage 1 encode thread, resumed screen — INCONCLUSIVE +- Where: PRD-613, branch `perf/prd610-resume`, candidate `a57423817`; `TN_RENDER_THREAD` remains default off. +- Ceiling: Stage 0 sized encode migration at about 0.2 / 0.6 / 1.0 ms main-thread CPU per frame. +- Result: fresh alternating `rt-resume-off-1` / `rt-resume-on-1`, three exact 1,000-update/render windows, runtime assertions PASS. Takeoff / cockpit / rear gunner CPU: 7.743 / 8.277 / 12.049 ms off versus 9.557 / 11.929 / 14.158 ms on; instructions: 20.482 / 22.748 / 29.980 M off versus 20.060 / 21.095 / 27.036 M on. Host load varied; no qualified CPU win, parity or visual verdict. +- Cause: encode moved off main, but the CPU/instruction disagreement is not yet attributed. Diagnose it before another equivalent timing attempt. +- Insight: relocating work can lower main-thread instructions without lowering its CPU time; instruction-only selection cannot qualify a thread migration against the CPU target. +- Artifact: candidate `a57423817`; raw `~/.cache/tmp-tn/screens/rt-resume-{off,on}-1/` and dashboard cycle `prd613-s1-render-thread-resume`. Current binary/game identities are in the screen host-load records; package provenance remains stale. + +### 2026-10-14 — V8 GC prologue `promote()` skips the full wrapper walk while the ownership epoch is unmoved — REJECT +- Where: PRD-610, branch `perf/prd610-promote` (reverted). +- Ceiling: profile share 4.9% of samples. It was wrong (see method lessons). +- Result: second off/on pair flat (takeoff 8.58 / 8.70 ms, cockpit 10.44 / 10.44 ms, rear gunner 11.55 / 10.73 ms); + instructions unchanged. Counter run: 249 GC prologues in 8,400 frames, 26,447 wrappers. +- Cause: GCs are rare in the pose windows, so the walk costs about 0.04 ms per frame. +- Artifact: patch kept outside the repository by the session; the change is a 20-line edit in + `packages/runtime-native/src/adapters/v8/adapter.cpp`. + +### 2026-10-14 — Crossing path reuses decoded numeric and handle arguments — REJECT +- Where: PRD-610, branch `perf/prd610-crossing` (not committed). +- Ceiling: 613 calls per frame × 60 to 120 ns = 0.04 to 0.07 ms per frame. +- Result: A/B flat on two pairs; adapter ctests 75 of 75 passed on and off. +- Cause: the per-call decode is a small part of a crossing. Remove whole classes of crossings instead. +- Measured on the way: 1,725 crossings per frame on Midway; top names per frame `visible` 315, `count` 152, + `getY` 120, `getX` 120, `time` 109, `setEffectiveWeight` 104, `material` 98, `getZ` 87, `opacity` 83, + `getWorldPosition` 58; 34,331 live wrappers. + +### 2026-10-14 — `publishUiState` serializes a changed HUD frame once instead of three times — KEEP +- Where: `@threenative/core` (`ui-state.ts`, `ui-bridge.ts`), branch `perf/prd610-promote`, commit b642e5dfb. +- Ceiling: `JSON.stringify` was about 7% of the native main thread in the call-graph profile (the same profile + that overstated GC, so treat this share as an upper bound). +- Result: red-green spy test 3 calls to 1; 101 UI core specs pass; public surface unchanged. Not timed on Midway. +- Cause: the publisher stringified the store to diff it, again for `lastFrame`, and the bridge stringified the + message a third time. + +### 2026-10-14 — PRD-613 Stage 0: render phase split — KEEP (diagnostic) +- Where: [PRD-613](docs/PRDs/native-engine/PRD-613-native-render-thread.md), commit 4abe3d020. +- Result (loaded host, shares only): the ocean reflection pass is about half the render wall at cockpit and rear + gunner (0.85 / 2.67 / 4.32 ms); its encode costs about 2 ms for 8 items at cockpit, a per-pass fixed cost. +- Cause: one reflection pass per frame re-runs prepare and encode for its own camera. + +### 2026-10-14 — Web Wasm owner profile — KEEP (diagnostic) +- Where: [PRD-612](docs/PRDs/native-engine/PRD-612-web-wasm-engine-beats-threejs-on-midway.md). +- Result: on the browser main thread, Wasm engine code 44 to 48%, engine JS glue 23 to 26%, game JS 12 to 15%, + V8 `(program)` 8%, WebGPU natives 7 to 8%. Animation, the matrix walk and `RenderDatabase::project` are the same + top owners as on native. + +### 2026-10-15 — Visible setter preserves material cache — INCONCLUSIVE +- Where: PRD-612, candidate `8c5f5ff4d`; result `0b4b321ba`. +- Result: instructions decrease in both pairs at all poses; CPU fails every-candidate-below-every-baseline. Four PASS reports with exact windows; dashboard 179 runs. +- Insight: capture serialization does not establish low CPU load. Higher load and lower IPC confound pair 2 CPU attribution; no causal explanation or qualified win. No third retry or integration. +- Artifact: `screens/visible-queue-{base,on}-{1,2}/`; recovery cycle terminal 2026-10-15T06:49:37.800Z. diff --git a/README.md b/README.md index b1e3f64f9d..338f064e5e 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Details in [`create-threenative`](packages/create-threenative/README.md). | `@threenative/runtime-native` | Owned C++ host for desktop and Android | | `@threenative/raw-unreal` | Raw Unreal editor `.uasset` static-mesh loader — UE5 FMeshDescription and UE4.18 FRawMesh, no interchange conversion | | `@threenative/terrain` | Optional headless Strata authoring: validated recipes, baked heightfields and indexed Three.js geometry | +| `@threenative/three-native` | The native engine's Three.js API catalog and the TypeScript sources the engine bundlers read | | `@threenative/ui` | React bindings for HUD and game state | | `@threenative/ueformat` | UEFormat v10 `.uemodel` parser and Three.js loader for meshes exported from Unreal packages | | `threenative-engine-mcp` | Offline MCP capability discovery for authoring agents | diff --git a/benchmark/godot-load-test/load_test.gd b/benchmark/godot-load-test/load_test.gd index 3766f9f41a..344c7163c1 100644 --- a/benchmark/godot-load-test/load_test.gd +++ b/benchmark/godot-load-test/load_test.gd @@ -783,6 +783,27 @@ func _finish_rung() -> void: rung["foxMeasurement"] = _fox_measurement if not _render_check.is_empty(): rung["renderCheck"] = _render_check + # The existing viewport read-back, after the last measured draw; PNG transport is persisted by the runner. + var frame_image: Image = get_viewport().get_texture().get_image().duplicate() + frame_image.convert(Image.FORMAT_RGBA8) + var source: Image = frame_image.duplicate() + frame_image.resize(160, 90, Image.INTERPOLATE_NEAREST) + # Image.resize has no box filter: replace the resized pixels with area-weighted RGBA means. + var scale_x := source.get_width() / 160.0 + var scale_y := source.get_height() / 90.0 + for y in range(90): + var top := y * scale_y + var bottom := (y + 1) * scale_y + for x in range(160): + var left := x * scale_x + var right := (x + 1) * scale_x + var total := Color(0, 0, 0, 0) + for sy in range(floori(top), ceili(bottom)): + for sx in range(floori(left), ceili(right)): + var weight := (minf(bottom, sy + 1) - maxf(top, sy)) * (minf(right, sx + 1) - maxf(left, sx)) + total += source.get_pixel(sx, sy) * weight + frame_image.set_pixel(x, y, total / (scale_x * scale_y)) + rung["framePng"] = Marshalls.raw_to_base64(frame_image.save_png_to_buffer()) _rungs.append(rung) _plan_index += 1 if _plan_index >= _plan.size(): diff --git a/biome.json b/biome.json index 3986db9351..1083d6ec61 100644 --- a/biome.json +++ b/biome.json @@ -35,6 +35,7 @@ "packages/runtime-native/native/css-ui/vendor/**", "examples/native-css-hud/corpus/out/**", "examples/native-css-hud/corpus/out-interaction/**", + "packages/three-native/generated/**", "packages/assets/vendor/basis-encoder/basis_encoder.js", "packages/runtime-native/**/*.json", "packages/terrain/starter/*/recipe.json", @@ -125,7 +126,8 @@ "packages/physics/src/**", "packages/ui/src/**", "packages/create-threenative/src/**", - "packages/playtest/src/**" + "packages/playtest/src/**", + "packages/three-native/src/**" ], "linter": { "rules": { diff --git a/docs/PRDs/native-engine/PRD-612-web-wasm-engine-beats-threejs-on-midway.md b/docs/PRDs/native-engine/PRD-612-web-wasm-engine-beats-threejs-on-midway.md new file mode 100644 index 0000000000..86eb285fae --- /dev/null +++ b/docs/PRDs/native-engine/PRD-612-web-wasm-engine-beats-threejs-on-midway.md @@ -0,0 +1,101 @@ +# PRD-612 — The web Wasm engine beats three.js on Midway + +**Status:** PARTIAL +**Priority:** P1 — the owner bar is 1.5x faster than three.js on Midway in Chromium; the first matched pair reads 0.95x to 1.26x. +**Complexity:** 6 (MEDIUM) +**Owner:** João +**Depends on:** PRD-610 (the matched yardstick, the flight harness and the dashboard). PRD-610 moved its old AC-7 (the web Wasm engine against three.js) here. + +## Context + +On 2026-10-14 the owner set two targets (verbatim): "my goal is native 2-3x faster than web threejs. WASM ThreeNative 1.5 faster than ThreeJS (regular)", and "we need to optimize both native and wasm web in parallel". PRD-610 owns the native target. This PRD owns the web Wasm target. + +The web Wasm arm is the ThreeNative engine compiled to Wasm and run in Chromium (`flight.sh native`). Three.js is the unmodified game in Chromium (`flight.sh legacy`). + +### Matched baseline (pair 1 of 3, 2026-10-14) + +Engine `e3271a19c`. Arms A (`ac15-e3271a19c-a1-legacy`) and C (`ac15-e3271a19c-c1-native`) ran the same 1,000-tick fixed-step scenario. C parity PASS against A and its visual ref. Xvfb, so CPU and instructions only. Host load 3.4 to 5.3. + +| Pose | three.js CPU ms/frame | web Wasm CPU ms/frame | Ratio | Instructions/frame (three.js / web Wasm) | IPC (three.js / web Wasm) | +|---|---|---|---|---|---| +| takeoff | 9.09 | 8.90 | 1.02x | 41.3 M / 38.9 M | 1.07 / 0.99 | +| cockpit | 9.55 | 10.05 | 0.95x | 48.9 M / 43.7 M | 1.19 / 0.99 | +| rear gunner | 14.35 | 11.42 | 1.26x | 77.2 M / 49.8 M | 1.24 / 1.00 | + +The same engine C++ runs on the native desktop player at 20.4 M / 22.7 M / 29.7 M instructions per frame (PRD-610 pair 1). The web Wasm arm runs about 1.7x to 1.9x more instructions for the same frame. The difference is the JS around each Wasm call and Wasm code generation, not WebGPU: an earlier profile put the WebGPU glue at about 1% (memory note "web engine cost is JS around calls"). The native IPC levers of PRD-610 (V8 pointer compression, HUD capture, thread placement) do not apply here: Chromium already ships them. + +To reach 1.5x, the web Wasm main thread must drop to about 6.1 / 6.4 / 9.6 ms per frame, which is 2.8 / 3.7 / 1.9 ms less than now. + +### Owner profile (diagnostic, 2026-10-14) + +One CPU profile of the web Wasm arm at engine e3271a19c, with the Wasm relinked `--profiling-funcs` so frames carry names. The host was loaded, so read the shares, not the milliseconds. Main-thread busy time per frame: + +| Owner | Takeoff | Cockpit | Rear gunner | +| --- | --- | --- | --- | +| Wasm engine code | 4.35 ms (44%) | 6.57 ms (45%) | 6.40 ms (48%) | +| Engine JS glue (binding wrappers, property get/set) | 2.59 ms (26%) | 3.53 ms (24%) | 3.03 ms (23%) | +| Game JS | 1.47 ms (15%) | 1.93 ms (13%) | 1.58 ms (12%) | +| V8 `(program)` | 0.79 ms (8%) | 1.13 ms (8%) | 1.06 ms (8%) | +| Browser natives, mostly WebGPU calls | 0.66 ms (7%) | 1.15 ms (8%) | 1.04 ms (8%) | +| Busy total | 9.88 ms | 14.49 ms | 13.21 ms | + +Inside the Wasm engine at cockpit, the largest owners are `AnimationMixer::update` with its interpolants (about 11% of busy time), the matrix walk (about 8%) and `RenderDatabase::project` (about 8%). These are the same owners as on native, so a "do less work" lever there helps both arms. The engine JS glue is web-only. `wasm-to-js` frames, which include the WebGPU calls from Wasm, cost 1.3 ms at cockpit. `profile-owners.ts` classifies this arm as `unknown`, and crossings per frame are not counted yet, so AC-2 stays open. + +## Solution + +Measure first, then cut the largest measured owner. Each lever is one A/B on the matched yardstick. + +- **Yardstick:** the PRD-610 AC-15 pair. Each pair runs A (three.js, matched CPU), ref (three.js visual ref on A's scenario), B (native desktop) and C (web Wasm), and grades B and C against the same A and ref. Script: `ac15-pair.sh ` in the lane scratchpad; it refuses a pair when engine paths changed since the prepare step. +- **Owner profile:** `MIDWAY_CPU_PROFILE=1 sh scripts/midway-bench/flight.sh native ` writes a browser CPU profile with CLOCK_MONOTONIC `clockBounds`. `scripts/midway-bench/profile-owners.ts` splits it by owner inside the held windows. +- **Levers:** picked from the owner profile by measured size. Candidates to test, not results: fewer JS-to-Wasm crossings per object (batched transforms and draw lists), typed-array views instead of per-call marshalling, and the "do less work" levers that PRD-610 lands in shared engine C++ (they cut Wasm instructions too). +- **Dashboard:** `http://127.0.0.1:8767/midway` shows a web Wasm panel beside the native panel, with the 1.5x target. + +Measurement rules are the PRD-610 rules: Tier 2 is three alternating pairs per pose with parity PASS, keep a lever only when every B beats every A, no single-run millisecond verdict on a loaded host, record the host load, and a fresh visual judge for any change that can alter pixels. + +## Acceptance Criteria + +Boxes live under each phase. + +## Blocked on + +Nothing. + +## Decisions + +- 2026-10-15 (visible/material-cache trial): candidate `8c5f5ff4d` is isolated from rejected arena/counter code; node visibility writes preserve cached labels, with replacement/non-node invalidation retained. Regression red/green and 45 browser tests, package typecheck, browser ESM and frozen game build pass; full CLI declarations lack built dependency declarations. `visible-resume-base-1` exits 75 before acquisition after a 120-second shared capture-lock timeout (queue depth three, other owners active); zero windows, no candidate run or speed comparison. Doctor passes for the browser toolchain. Preserve this failure and close its cycle inconclusive; use the existing 15-minute capture wait for one newly named baseline/candidate pair, then reassess. A repeated timeout with the same cause parks acquisition until the queue changes; AC-3 remains open. +- 2026-10-15 (AC-3 exploratory screen): reject the narrow arena/fixed-arity glue candidate `3d08db039`; the whole glue profile share (23–26%) overstated this subset's ceiling. `glue-resume-base-1` / `glue-resume-on-clean-1` both pass four runtime assertions, NVIDIA/turing WebGPU, and three exact 1,000-update/render windows. Takeoff / cockpit / rear-gunner CPU is 10.283 / 11.928 / 11.307 ms baseline versus 9.551 / 11.043 / 12.553 ms candidate; instructions are 38.781 / 43.599 / 49.907 M versus 38.774 / 43.567 / 49.527 M (reductions below 0.8%). The runs are separated by 27 minutes with host-load drift: CPU attribution is inconclusive and there is no all-pose win. Receipts remain stale, so this is an exploratory screen, not qualification or parity. `glue-resume-on-1` failed during asset fetch (`ERR_NETWORK_CHANGED`, zero measured windows); `glue-resume-on-1r` was explicitly timing-excluded and interrupted. Raw artifacts: `~/.cache/tmp-tn/screens/`; cycle `web-glue-arena-resume` closed reject. AC-3 remains open; removing whole crossing classes is the next measured hypothesis. +- 2026-10-14 (owner): the web Wasm arm runs in every matched pair, beside the native arm, and both show on the dashboard. Native and web Wasm optimization run in parallel. +- 2026-10-14 (lead): the target is 1.5x (owner), not the 2x that PRD-610's old AC-7 carried. + +### Visible/material-cache recovery — INCONCLUSIVE CPU (2026-10-15) + +Candidate `8c5f5ff4d`; baseline `midway-native-prof`, candidate `midway-native-visible`. Original `visible-resume-base-1` exited 75 before measurement on a 120-second capture-lock timeout; preserved as infrastructure failure, not game failure. Recovery used the existing `CAPTURE_LOCK=1 CAPTURE_LOCK_TIMEOUT_MS=900000` and unchanged `web-screen.sh`, scenario and frozen game content. No third timing retry or integration. + +| Pair / pose | CPU ms/frame baseline → candidate | CPU change | Instructions M/frame baseline → candidate | Instruction change | IPC baseline → candidate | Host load1 baseline → candidate | +| --- | --- | --- | --- | --- | --- | --- | +| 1 takeoff | 9.377 → 9.147 | −2.45% | 38.676 → 37.765 | −2.35% | 1.022 → 1.008 | 5.09 → 6.29 | +| 1 cockpit | 10.458 → 10.206 | −2.41% | 43.028 → 42.733 | −0.68% | 1.015 → 1.026 | 3.95 → 6.10 | +| 1 rear | 13.474 → 10.594 | −21.37% | 49.679 → 48.630 | −2.11% | 0.907 → 1.112 | 4.78 → 5.10 | +| 2 takeoff | 8.914 → 13.356 | +49.83% | 38.922 → 37.850 | −2.75% | 0.994 → 0.703 | 5.07 → 17.37 | +| 2 cockpit | 10.943 → 16.066 | +46.81% | 43.769 → 40.690 | −7.04% | 0.927 → 0.629 | 3.39 → 18.10 | +| 2 rear | 13.238 → 17.423 | +31.62% | 49.858 → 48.834 | −2.05% | 0.888 → 0.695 | 4.14 → 20.44 | + +Every candidate beats every baseline on instructions in all three poses; CPU fails that rule in all three. Pair 2 candidate's higher load and lower IPC confound CPU attribution; neither the CPU regression nor pair 1 rear improvement establishes a causal host-load explanation. Instruction reduction repeats, but is not a qualified performance win. All four actual reports PASS on NVIDIA/turing WebGPU, fixed-step, 3,948 reported frames, three runtime assertions plus diagnostics passing, and zero console/network/runtime diagnostic errors. Every pose has exactly 1,000 frames/updates/renders and tick difference, with matching tick ranges. No qualified parity or fresh visual-judge claim. + +Raw sources: `~/.cache/tmp-tn/screens/visible-queue-{base,on}-{1,2}/run.log`; all four completed logs auto-published with timing exclusions. Live dashboard API had 179 runs at extraction. Cycle `web-visible-material-cache-queue` closes INCONCLUSIVE at the last wrapper terminal timestamp, 2026-10-15T06:49:37.800Z. AC-1 through AC-5 remain open. + +## Execution Phases + +### Phase 1 — Matched baseline and owner profile + +- [ ] **AC-1 Matched baseline.** Three qualified pairs give the web Wasm vs three.js CPU ratio, instructions and IPC per pose, parity PASS. Pair 1 is in Context; pairs 2 and 3 are open. proof: `ac15-pair.sh 2` and `ac15-pair.sh 3`, dashboard web Wasm panel QUALIFIED +- [ ] **AC-2 Owner profile.** One CPU profile of the web Wasm arm splits the held windows into game JS, engine JS glue, Wasm engine code, WebGPU calls and browser work, with each owner's share and the crossings per frame. proof: `MIDWAY_CPU_PROFILE=1 sh scripts/midway-bench/flight.sh native ` then `scripts/midway-bench/profile-owners.ts` + +### Phase 2 — Levers + +- [ ] **AC-3 First lever.** The largest AC-2 owner gets a lever. Three pairs: every B beats every A on CPU per frame and instructions per frame at all three poses, parity PASS. proof: three `ac15-pair.sh` pairs per arm +- [ ] **AC-4 Further levers.** Repeat AC-3 until the projected ratio reaches 1.5x, or record a re-plan under Decisions when the AC-2 owners cannot reach it. proof: three `ac15-pair.sh` pairs per lever + +### Phase 3 — Final + +- [ ] **AC-5 Final.** The web Wasm arm is at least 1.5x three.js CPU per frame at takeoff, cockpit and rear gunner, on three qualified pairs, parity PASS, visual judge NEUTRAL or better. proof: three `ac15-pair.sh` pairs at the final commit, visual-judge subagent verdict diff --git a/docs/PRDs/native-engine/PRD-613-native-render-thread.md b/docs/PRDs/native-engine/PRD-613-native-render-thread.md new file mode 100644 index 0000000000..1d93d827cf --- /dev/null +++ b/docs/PRDs/native-engine/PRD-613-native-render-thread.md @@ -0,0 +1,108 @@ +# PRD-613 — Native render thread: cull, project and encode leave the main thread + +**Status:** PARTIAL +**Priority:** P1 — the largest measured lever for PRD-610's 2x to 3x bar: render prepare and encode cost 1.65 / 2.89 / 4.41 ms of the native main thread per frame on Midway, and no box below is done. +**Complexity:** 7 (HIGH) +**Owner:** João +**Depends on:** PRD-610 (the matched yardstick, AC-15; this PRD delivers PRD-610 AC-16) + +Complexity: 7 → HIGH (11+ implementation files: 3, new render-thread system: +2, concurrency and object lifetime across threads: +2); risk override: none. + +## Context + +PRD-610 sets the owner bar: the native desktop player is 2x to 3x faster than three.js in Chromium on Midway. The matched yardstick (PRD-610 AC-15) measures main-thread CPU per frame in 1,000-tick fixed-step windows at three poses. Pair 1 at engine `e3271a19c` (parity PASS, Xvfb, CPU and instructions only): + +| Pose | three.js | native main thread | of which render (`renderCpuMs`) | render wall: prepare / encode+submit | +|---|---|---|---|---| +| takeoff | 9.09 ms | 7.31 ms | 1.65 ms | 1.33 / 0.41 ms | +| cockpit | 9.55 ms | 9.07 ms | 2.89 ms | 3.49 / 1.18 ms | +| rear gunner | 14.35 ms | 10.95 ms | 4.41 ms | 4.54 / 1.67 ms | + +Chromium splits this work across the renderer main thread, the compositor and the GPU process. The native player runs it all on one thread. Present already runs on the `GpuThread` (old PRD-610 AC-9, −19% main-thread CPU). + +A main-thread `perf record` (cycles:u, IPC 0.64, L1d miss rate 6.9%) puts `RenderDatabase::project` at 6.7%, `ProjectedCull::apply` 3.7%, `boundingSphereOf` 1.7%, `findRecord` 1.6%, `renderPrepared` 1.1% and `GeometryCache::sync` 1.0% of samples. Placement screens (one CCD, no SMT sharing) and the HUD snapshot mode did not move cycles, so the cost is the main thread's own work, not interference. + +Package rule (`packages/runtime-native/AGENTS.md`): the JavaScript runtime that owns `THREE.Scene` also owns the renderer, and the `Object3D` tree is never mirrored across threads. Heavy systems use batched transfer surfaces. A per-frame packet of draw data is a batched transfer surface, not a mirrored tree. + +Known hazards: encode syncs a live `BufferStore` and clears its update ranges (`renderer/renderer.cpp`, `renderer/geometry_cache.cpp`); `DrawItem.material` points to database-owned params (`renderer/render_database.cpp`); `cmake/CheckNoBlockingWaits.cmake` allows only the `GpuThread` join. + +### Stage 0 split (2026-10-14) + +`TN_RENDER_PREPARE_SPLIT=1` (diagnostic, off by default) adds the render phases to `TN_PLAYER_FRAME`. Two screens (`s0-split-2`, `s0-split-3`) ran on a loaded host (load 9 to 27 from other sessions), so absolute times read about 1.5x to 2x high; the shares and counts hold. In-window medians, wall ms per frame, takeoff / cockpit / rear gunner: + +| Phase inside `database.render` | takeoff | cockpit | rear gunner | +|---|---|---|---| +| GPU-joint prep | 0.10 | 0.09 | 0.09 | +| Scene prepare (`RenderDatabase::prepare`: matrix 0.04 to 0.14, project 1.50 to 1.82, batch, other) | 1.85 | 2.25 | 3.05 | +| **Ocean reflection pass** (`renderReflections`) | 0.85 | 2.67 | 4.32 | +| of which reflection prepare / encode | 0.43 / 0.30 | 0.57 / **1.96** | 1.44 / 2.24 | +| Main encode and submit | 0.87 | 2.09 | 3.13 | +| Draw items main / reflection | 43 / 5 | 158 / 8 | 352 / 202 | +| Geometry bytes uploaded per frame | 0 | 576,000 | 576,000 | + +Findings: + +- The ocean's planar reflection (`WaterSurface3D`, `reflection: {resolutionScale: .5, layers: 1 << REFLECTED_LAYER}` in the game) is the largest render phase at cockpit and rear gunner: about half of the render wall. The layer mask reaches the native reflection camera (`three-native/src/reflector.ts` runs the `getVirtualCamera` wrapper once against the engine camera). +- At cockpit the reflection pass encodes 8 draw items in about 2 ms (loaded). That cost is per pass, not per object. A call-graph profile names it before any lever is built. +- Batched members are 26 / 27 / 13 of 43 / 158 / 352 draws, so the plain-opaque compact lane that stage 2 targets is a small share at cockpit and rear gunner. +- Consequence for the plan: the reflection pass cannot take the inline fallback. It moves to the render thread with the main pass, and the per-pass reflection cost is a lever in its own right. + +## Solution + +A second `GpuThread` instance is the render thread (created beside the existing one in `player/run.cpp`). It adds no new class, condition variable or join, so `cmake/CheckNoBlockingWaits.cmake` needs no waiver. + +- **Baton rule.** After the main thread waits on the render thread and then the `GpuThread`, both are idle. Only in that window does main touch the `Renderer`, `GpuResources`, `GeometryCache`, `PipelineCache` and the queue: geometry and canvas-texture sync, handle resolve, the retire drain. Then it posts packet N. While the render thread runs, main touches none of them. One frame is in flight in total. +- **Frame packet, no mirrored tree.** Main keeps the traversal, LOD, skeleton update, shadow-camera update, `onBeforeRender` callbacks and `ProjectedCull::apply` (about 0.3 ms; it writes JS-visible `visible`). The draw data that main already writes (`items_`, `Record::Draw`, skinned palettes, batch stores, lights, camera state, shadow frustums) becomes two ping-pong sets selected by frame parity, so stage 1 copies nothing. Stage 2 adds a flat per-frame `MeshRef` array (about 190 B per visible plain opaque mesh) with per-frame deduped `GeometryInfo` and `MaterialSnap` tables. The packet has no parent or child links and no JS-owned pointers, and nothing mutates it after the post. +- **Render thread job.** Apply deltas (output, sample count, size, overlay), then the reflection passes (their prepare runs on main with the scene prepare; their encode runs here), then resolve (cull, batch, sort, fog and environment), encode, prepare the blit, post present to the `GpuThread`, and mail `lastFrame_`, diagnostics and `renderThreadCpuMs` back. Encode exceptions are caught and rethrown at the next handoff. +- **Lifetime.** One mutex guards `GpuResources::Shared` (`destroy`, `submit`, completion). A `shared_ptr` holds CPU references and is retired by a final `GpuThread` job; its destructor only queues objects for main to drop, because the last reference to an `Object3D`-adjacent object must die on main. +- **Inline fallback.** The first frame, resize, held renders, `tn.renderTarget`, `readTarget`, `readFrame` and `compileAsync` wait for both threads and take today's inline path. A diagnostic counts fallbacks; Midway must show zero in steady state. +- **Switch.** `TN_RENDER_THREAD=0|1`, default on once Phase 3 passes. +- **Hooks.** `renderCpuMs` becomes main-thread only (the KPI). `renderEncodeMs`, `renderThreadCpuMs` and `renderer.info.render` arrive from the mailbox one frame late, as `gpuThreadCpuMs` does today. Screenshots and readbacks post after encode N on the render thread, so they still read frame N. + +Estimated main-thread ms removed (takeoff / cockpit / rear gunner), not measured: stage 1 about 0.2 / 0.6 / 1.0; stage 2 about 1.0 / 1.9 / 2.7 cumulative, after a 0.3 to 0.45 ms packet cost. Takeoff is the risk: the matrix walk stays on main. Stage 0 measures it before stage 2 is built. Stage 3 (shadows, skinning, `GpuJointScene` on the render side, −0.1 to −0.3 ms) runs only if stage 2 misses its target, as a follow-up. + +Measurement: PRD-610 tier ladder. Tier 0 is a ctest. Tier 1 is one matched native screen (about 4 min). Tier 2 is three alternating pairs with parity PASS and the visual judge. Each stage opens and closes a cycle in the dashboard ledger (`scripts/midway-bench/cycle.ts`). + +## Acceptance Criteria + +Boxes live under each phase. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| Render thread | `player/run.cpp` frame loop → render thread → `GpuThread` | `database.render` on the main thread is removed from the frame loop | Phase 1 to 3 | + +## Decisions + +- 2026-10-14 (owner): "this shit that's days of work, you have to extract into a separate prd". PRD-610 AC-16 moves here; PRD-610 keeps AC-16 as the consumer box, proved by this PRD. +- 2026-10-15 (resumed evaluation): Stage 1 is implemented at `a57423817`, default off; its acceptance boxes remain open. Fresh exploratory screen `rt-resume-off-1` passes three assertions on NVIDIA RTX 2080, with exactly 1,000 updates/renders per window: main-thread CPU 7.743 / 8.277 / 12.049 ms and instructions 20.482 / 22.748 / 29.980 M per frame (takeoff / cockpit / rear gunner). Binary SHA-256 `2ce82ac2aabbeb195592b3d3b499b113208886d7e2d7292b9de6d87c81247713`, game SHA-256 `e69f72523ba34517466240deb354df5cbae4c5566d981320d3ef984f55fcb709`; raw screen records are under `~/.cache/tmp-tn/screens/`. The old package receipt names the baseline binary, so these screens are not qualified provenance. Candidate comparison, parity, visual review and TSan remain unverified. Read-only review also identified Android presenter/surface destruction before the render-thread fence, including lifecycle suspension; fix and verify that ordering before claiming platform readiness. +- 2026-10-15 (Stage 1 screen closed INCONCLUSIVE): `rt-resume-on-1` also passes all three assertions and exact 1,000-update/render windows. CPU is 9.557 / 11.929 / 14.158 ms (+23.43% / +44.12% / +17.51%); instructions are 20.060 / 21.095 / 27.036 M (-2.06% / -7.27% / -9.82%). The switch is active: render-thread CPU medians are 0.283 / 0.792 / 1.016 ms; cumulative fallback count stays at one, so no additional steady-window fallback occurred. Host load varied, and the cause of the CPU/instruction disagreement remains unproven. Dashboard cycle `prd613-s1-render-thread-resume` is closed; no winner promoted. Diagnose the disagreement before repeating timing. Parity, visual review, TSan and qualified provenance remain open. +- 2026-10-15 (attribution and document checks): main-thread IPC fell from 0.634 / 0.657 / 0.591 to 0.502 / 0.428 / 0.464; game JS CPU medians rose from 4.764 / 4.982 / 6.186 to 6.078 / 7.302 / 7.950 ms. `GpuThread::wait` parks on a condition variable, so busy polling is not the cause. Existing `SCREEN_TASKSET` controls only a shared process-tree mask, not separate main/HUD/render-worker placement; prior coarse placement screens do not isolate the new worker. Documentation validation passes: `pnpm check:docs`, the six required prose-lane suites (264 tests), `pnpm prd:progress` (1/8 boxes), and `git diff --check`. No native rebuild or TSan run is claimed. + +## Execution Phases + +### Phase 1 — Measure, then encode on the render thread + +**Status:** IN PROGRESS +**Files:** `player/run.cpp`, `renderer/gpu_resources.{h,cpp}`, `renderer/render_database.{h,cpp}`, `renderer/renderer.cpp`, `tests/native-engine/render_thread_order_test.cpp` + +- [x] **Stage 0 split.** One matched run logs the prepare split per pose (matrix walk, project, batch, other), the plain-opaque share of draws and the geometry bytes uploaded per frame, and the numbers land in this PRD. proof: matched native screen with `database.profiling` on — screens `s0-split-2` and `s0-split-3` (`TN_RENDER_PREPARE_SPLIT=1`), table in Context; loaded host, so shares only; the batched-member count stands in for the plain-opaque share. +- [ ] **Order ctest.** Frame N encodes only frame N's packet; a live mutation of transform, material colour, attribute bytes or a uniform node after the post does not reach frame N; at most one frame in flight; a test hook that aliases live data goes red. proof: `ctest -R native_engine_render_thread_order`, plus a ThreadSanitizer build of that test +- [ ] **Stage 1 lever.** With `TN_RENDER_THREAD=1`, main-thread CPU per frame drops at all three poses against `TN_RENDER_THREAD=0`, every on-run below every off-run, parity PASS. proof: two alternating matched native screens per arm, `cmp.py` + +### Phase 2 — Cull, batch and sort on the render thread + +**Status:** NOT STARTED +**Files:** `renderer/render_database.{h,cpp}` (`project`, the compact lane, `addBatchMesh`, `batch`), `renderer/renderer.cpp`, `tests/native-engine/render_packet_parity_test.cpp` + +- [ ] **Packet parity ctest.** Draw items built from the packet on the render side equal the inline items, in order, on the existing `prepare` corpus. proof: `ctest -R native_engine_render_packet_parity` +- [ ] **Stage 2 lever.** Main-thread CPU per frame drops by at least 1.0 ms at takeoff, cockpit and rear gunner against `TN_RENDER_THREAD=0`, parity PASS. proof: two alternating matched native screens per arm, `cmp.py` + +### Phase 3 — Qualification + +**Status:** NOT STARTED + +- [ ] **Steady state has no fallback.** The fallback diagnostic reads zero after the first frame on all three Midway poses. proof: matched native run, `TN_PLAYER_FRAME` fallback counter +- [ ] **Three pairs.** Main-thread CPU per frame falls by at least 1.0 ms at all three poses, three alternating pairs, parity PASS in every run. proof: `ac15-pair.sh` with `TN_RENDER_THREAD=1` against the PRD-610 baseline pairs +- [ ] **Pixels unchanged.** A fixed-step capture of A1, A2 (off, off) and B (on) per pose gives diff(A, B) no larger than diff(A1, A2), and the visual judge returns NEUTRAL or better. proof: capture pair per pose, visual-judge subagent verdict diff --git a/docs/PRDs/native-engine/PRD-620-the-native-engine-runs-tsl-compute.md b/docs/PRDs/native-engine/PRD-620-the-native-engine-runs-tsl-compute.md new file mode 100644 index 0000000000..974cb53476 --- /dev/null +++ b/docs/PRDs/native-engine/PRD-620-the-native-engine-runs-tsl-compute.md @@ -0,0 +1,49 @@ +# PRD-620 — The native engine runs TSL compute + +**Status:** IN PROGRESS +**Priority:** P0 — PRD-617 is a v1 release blocker, and the starter and snow templates cannot leave their legacy pins until AC-1 to AC-4 pass. +**Complexity:** 7 (HIGH) +**Owner:** PRD-617 lanes (Codex arm on `prd617/post-bindings`, GPU proof by the coordinator's worker) +**Depends on:** PRD-617 + +## Context +On 2026-10-15 the starter booted on the native engine desktop player and then failed with `TN_TSL instancedArray: expected a finite number`. `packages/runtime-native/src/engine/` has no general compute path. The only compute is the GPUParticles kernel. The starter uses TSL compute in two places: core `SoftBody3D` (pennant cloth: `instancedArray(TypedArray, type)`, `Fn(...)().compute(n)`, `renderer.compute`, `renderer.getArrayBufferAsync`) and `temporalRejectionCounter` (`.compute(n)`, `atomicAdd`/`atomicStore`, readback). Snow uses atomics and compute too. PRD-617 round 3 already lowers atomic statements to WGSL that Tint accepts. Dispatch and readback do not exist. + +## Solution +Use the upstream three.js API only (`instancedArray`, `storage`, `Fn().compute(count, workgroupSize)`, `renderer.compute` / `computeAsync`, `renderer.getArrayBufferAsync(attribute)`). Lower the compute node graph through the existing shader graph to a WGSL compute entry point. Allocate read-write storage buffers for `instancedArray` / `StorageBufferAttribute`, bind them to compute and render reads, queue dispatches in renderer order before the frame's render passes, and read back by a mapped staging copy that resolves the returned promise. The same code serves the V8 player and the Wasm web engine. + +## Acceptance Criteria +- [x] AC-1 [local]: `instancedArray(Float32Array, "vec3")` and `instancedArray(count, type)` construct storage on V8 and Wasm. proof: native-engine ctest + vitest. Passed: see Phase 1. +- [x] AC-2 [local]: a `Fn().compute(n)` kernel that writes storage runs through `renderer.compute`, and a render material reads the written values in the same frame. proof: GPU ctest with a pixel or readback assertion. +- [x] AC-3 [local]: `renderer.getArrayBufferAsync` returns the written bytes; atomics (`atomicAdd`, `atomicStore`, `atomicLoad`) give the exact expected counts. proof: GPU ctest. +- [ ] AC-4 [local]: the starter (cloth and temporal rejection) and snow run on native desktop and web with their pins removed; playtests pass; a fresh visual judge returns NEUTRAL or IMPROVEMENT against legacy. proof: template playtests, judge verdict. + +## Execution Phases +#### Phase 1: storage and dispatch +**Status:** DONE +**Files:** `src/engine/shader/graph/*`, `src/engine/renderer/*`, `src/engine/abi/bindings_*.cpp`, `src/adapters/v8/tsl.cpp`, `src/engine/player/core-tsl.mjs`, `packages/three-native/src/*` (Wasm facade). +- [x] Storage constructs and a compute kernel writes it before the frame's render reads it (AC-1, AC-2). proof: native-engine ctest + vitest. + Result 2026-10-15: V8 player `native_engine_player_compute` passes (instancedArray count and typed-array forms, padded vec3, a vertex-stage `positionNode` read, a render-target read and a main-frame read of kernel output, in the same frame as the dispatch); red before the work was `COMPUTE_ERROR TN_TSL instancedArray: expected a finite number`. Wasm facade: `packages/three-native/__tests__/storage.spec.ts` (7 tests) and the browser page `examples/wasm-engine-boot/compute.html`, whose 9 component assertions pass on an NVIDIA Turing adapter. `ctest -L native-engine` 400/400, three-native vitest 161/161. +#### Phase 2: readback and atomics +**Status:** DONE +- [x] `getArrayBufferAsync` returns the written bytes and atomics give exact counts (AC-3). proof: GPU ctest. + Result 2026-10-15: `native_engine_player_compute` reads 256 floats, a padded vec3 buffer and a uint buffer back exactly, and 1000 threads count `[334,1000]` twice (a reset in its own dispatch between rounds). The same bytes come back on the Wasm page. `getArrayBufferAsync` takes no target, an ArrayBuffer target or a ReadbackBuffer, and refuses an offset or count that is not a multiple of 4. The WGSL is accepted by Dawn (desktop player) and by Chrome's Tint (Wasm page). +Recovery verification 2026-10-15, source `687729c5a`: product desktop player rebuilt with physics and UI overlay enabled; Wasm rebuilt (3,567,874 bytes). `native_engine_player_compute`, `native_engine_player_compute_ops`, `native_engine_player_ui_overlay` and `native_engine_player_real_input_ui`: 4/4 passed. Starter/snow gameplay and visual acceptance remain unverified. + +Integer-uniform review repair 2026-10-15: accepted `uniform(value, "int"/"uint")` could neither reassign `.value` nor upload to render materials, and float-backed snapshots lost integers above `2^24`. Red: the real C ABI regression failed creation precision, reassignment and invalid-range rejection. Numeric uniform snapshots now retain doubles until the existing shared layout writer uploads f32/i32/u32 words; f32 setters retain their previous rounding. Green: five CPU ABI cases (uniform values, TSL calls, effect parameters, starter post bindings, authored output), exact uint-max/int-min upload words, rejected fractional/out-of-range/nonfinite integer values without mutation, f32 rounding/negative-zero upload bits, focused TS tests 19/19 and three-native typecheck. Changed compute/render/post sources and V8 adapter test compile in temporary CPU objects; shared CMake products remain untouched. The extended public compute/render reassignment and pixel assertions require the coordinated desktop/Wasm rebuild and remain unrun. + +Coordinated storage repair 2026-10-15: actual `compute_ops` failed with duplicate WGSL storage declarations and zero sprite rotation/scale pixels. Separate position and fragment-varying graph lowerings each declared the same buffer. Storage declarations now share identity within the shader program and reject conflicting element types or atomic access. Red: the public ABI graph regression found two bindings instead of one for both vertex and instance indexing. Green: rebuilt ABI `tsl_call`/`tsl_uniform_value` and shader IR `order`/`types`/`unsupported` pass (5 cases); rebuilt registry dump exits 0; desktop and Wasm web product builds pass. Under the shipped capture lock and Xvfb, `native_engine_player_compute_ops` passes 1/1 in 3.83 seconds, including sprite rotation/scale pixels and typed uniform compute/render reassignment (`/tmp/pr493-storage-compute-ops.log`). This supersedes the focused GPU checks marked unrun above. Rebuilt SHA256: player `5e40a3d5363c56eb32cb3a41feb5e546bac73e37ea34a0c1ea43dd6a2f5db590`; registry `bfad629d4896593d28fe53305d4fc7f8b9b30c0f1ebf17303706f20b63c18be8`; web mjs `a49311cc03b7a610ea51aacedd81dd488bc80a87bff7bbe91fb21e658b6d8f5d`; Wasm `490bfaa43d73f04ec32cd22780aca89c935e74159f7a76369fe0836e1cda57d8`. The coordinator's current starter web movement replay passes, but its performance assertion remains red (38.4 ms versus 33 ms); browser full acceptance and AC-4 remain open. + +#### Phase 3: templates +**Status:** IN PROGRESS +- [ ] BufferAttribute disposal preserves public events, CPU contents and ordered GPU storage lifetime on V8 and Wasm. proof: real ABI callbacks (repeat disposal, removal, target identity), existing Object3D event regressions, queued compute/readback followed by disposal and CPU-seeded reuse, plus both backend lifecycle replays. Plan: reuse the native EventDispatcher and language event bridge; verify GPU release is idempotent and does not invalidate work queued before disposal. Template lifecycle replay remains pending. + CPU result 2026-10-15: public ABI disposal was red (missing callback and method); temporary builds now pass `typed_bytes`, `generic`, `callbacks`, Object3D `hierarchy`/`alias`, and the queue lifetime case (6 cases). Disposal repeats its event, preserves CPU bytes and removes listeners; a weak queue owner rejects competing live owners, permits expired-owner reuse and falls back safely when the attribute outlives the queue. Immediate store invalidation would reset GPU results before an already queued readback, so a live queue routes disposal through its existing ordered release. Catalog/registry/browser coverage passes 22/22. Configured desktop/Wasm builds, six native CPU cases and registry comparison pass. Native compute and compute-ops pass under the shipped capture lock; queued readback returns 207, CPU contents remain 7, and released storage reseeds to 7 before reuse returns 107. The original Wasm compute scenario passes all 10 assertions on NVIDIA/Turing hardware with no diagnostics. The native fixture now counts three rendered-frame callbacks rather than simulation updates. The browser build preserves an authored favicon when no configured replacement resolves (red-to-green regression; two branding tests and package typecheck pass), removing the confirmed favicon 404. Fresh starter teardown and Snow web consumer replays remain pending, so this box stays open. + Web lifecycle result 2026-10-15: the actual starter transition and bounded public Wasm fixture reproduced `GPUDevice.createBindGroup ... resource ... undefined`. Disposal destroyed a buffer while `externalStorage_` retained its handle; `rebuildGroups()` revisited the obsolete cached program. Missing storage now leaves that cached group empty, while an active draw with required missing bindings throws `TN_NATIVE_BINDING_NULL`. Final fixture: reuse the same disposed attribute/material (`32,128,223,255`), dispose it again, then render replacement storage (`128,64,32,255`). All 12 assertions pass on NVIDIA/Turing hardware, software=false; 13 focused native tests and example typecheck pass. No vertex-key change was needed: reuse already passed, and epoch changes already clear the group cache. Wasm SHA256 `21d21cf6f831f5b14d381da7f4ab800b148e9f4aeeafec34f60e4cb8207f77fd`; final report `examples/wasm-engine-boot/artifacts/playtest/pr493-final-disposal-report.json`. The active-null guard has no supported negative fixture. Fresh consumer-package starter teardown and Snow replay remain unrun, so this box stays open. +- [ ] Starter and snow pass their playtests on native desktop and web, judged NEUTRAL or better (AC-4). proof: template playtests + judge. + Recovery failure 2026-10-15: actual Snow web and desktop runs reached `TN_NATIVE_SHADER_INVALID builtin instanceIndex: not available in this stage`. Snow reads both particle storage attributes and `instanceIndex` in its fragment color. The shared fix chooses vertex versus instance storage indices, carries attribute values through vertex-to-fragment varyings, links sprite graphs through the mesh linker, and emits flat integer varyings. Red: CPU public-ABI index/fragment compilation failed and the two facade attribute tests failed. Green: the CPU ABI regression passes (including real BufferAttribute storage through mesh/sprite compilation), storage/browser-tsl vitest 19/19, and three-native typecheck. The extended `native_engine_player_compute_ops` pixel/readback regression, rebuilt desktop/Wasm gameplay and visual acceptance remain unrun; AC-4 stays open. + +2026-10-15: Starter web startup failed with `TN_START_FAILED` at the SoftBody3D node-material guard on the discrete NVIDIA/Turing adapter. The browser facade omitted concrete node-material identity; the native-facade SoftBody regression reproduced the error, then passed after the shared type-flag repair. Focused checks: three-native suite plus core SoftBody, 205 tests passed; three-native/core TypeScript checks passed. Browser startup replay, snow, and the visual judge remain pending; AC-4 stays open. + +Regression portability: the SoftBody suite passed 23/23 from both repository root and `packages/core` (focused package-local Vitest include configuration); its catalog path now resolves from the test module. + +Registry refresh 2026-10-15: rebuilt `tn-native-engine-registry-dump` exposed bounds setters on 20 geometry classes and SpriteNodeMaterial rotationNode/scaleNode; shipped catalog/declaration generators synchronized that executable surface. Browser bounds regression passed; 207 focused tests and three-native/core TypeScript checks passed. Native UI replay failed at the presented-frame assertion (blue share 0); runtime and visual acceptance remain open. diff --git a/docs/PRDs/native-engine/README.md b/docs/PRDs/native-engine/README.md index 37848d9b16..6cb84bb5b7 100644 --- a/docs/PRDs/native-engine/README.md +++ b/docs/PRDs/native-engine/README.md @@ -81,6 +81,8 @@ Full text and rationale: [PRD-497 § Decisions](PRD-497-n00-architecture-decisio | N19 | [PRD-532 — WebAssembly native-core browser port](PRD-532-n19-webassembly-native-core-browser-port.md) (mandatory) | N03, N07–N09, N18 | | N20 | [PRD-533 — Platform qualification, performance and default promotion](PRD-533-n20-platform-qualification-performance-default-promotion.md) | CP1, N00–N16, N18, N19 | | N21 | [PRD-535 — The JS engine is deleted](PRD-535-n21-the-js-engine-is-deleted.md) | N20 + one release, N19 | +| Perf | [PRD-612 — The web Wasm engine beats three.js on Midway](PRD-612-web-wasm-engine-beats-threejs-on-midway.md) (owner bar 1.5x) | N19, PRD-610 | +| Perf | [PRD-613 — Native render thread](PRD-613-native-render-thread.md) (PRD-610 AC-16) | N09, PRD-610 | ```mermaid flowchart LR diff --git a/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md b/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md new file mode 100644 index 0000000000..c92d99b12e --- /dev/null +++ b/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md @@ -0,0 +1,344 @@ +# PRD-573 — Unreal-class performance, in release cycles + +**Status:** PARTIAL — slice 0 (charter) landed in this PR; every other slice and cycle is open. +**Priority:** P1 — the owned engine is not on develop, so no game can opt in and no cycle KPI can be measured (v1 slices 0b–8 open). +**Complexity:** 9 (HIGH) — 10+ files across core, the engine, the web host and CI (+3), new engine modules (+2), threads and shared frame data (+2), native and Wasm release artifacts (+2); risk override: none +**Owner:** João +**Depends on:** None. Engine code is extracted from `origin/feat/native-engine` (F), cited at `68f4b1f7b`. +**Estimate:** v1 2–3 wk; v2 3 wk; v3 5–6 wk; v4 4–6 wk; v5 4+ wk. + +**Update rule:** this is one PRD with one PR (#493). Each slice or cycle step lands as a commit on +that PR and ticks its box, with the result, in the same commit. + +## Goal and route + +**Goal:** frame cost and load time of an established engine: first Godot desktop as the measurable +bar, then Unreal-class. **Route:** one owned C++ engine behind the three.js API, native on Dawn or +wgpu and Wasm (`tn-native-engine-web`) on the web; game code on V8 or browser JS. A game opts in +with `engine: "native"` until 2026-10-15. Since then native is the default for web and desktop and +`engine: "legacy"` is the named override (decisions below). The legacy engine is deleted after one +clean release ([PRD-535](../native-engine/PRD-535-n21-the-js-engine-is-deleted.md)). + +Midway web when the work opened (native Wasm page against three.js): CPU busy per frame 2.32x +faster, frame p50 0.24x, first frame 0.48–0.62x, `enter` 0.96–1.09x, `ready` 0.73–1.05x. Engine +shares: animation about 13%, cull and project about 11%, `Renderer::render` 21%, transforms about 8%. + +## Decisions + +- 2026-10-11 (owner): Perry is parked. PRD-579, PRD-581, PRD-582 and `tools/native-typescript` + stay on F and do not land. +- 2026-10-12 (owner): charter amendment accepted (`docs/architecture/CHARTER.md`, + `NATIVE-ENGINE-DECISION.md`): opt-in engine, legacy default, default switch at 2x or better and + stable, then legacy deleted. +- 2026-10-12 (owner): develop's velocity design wins. The engine's `provision` hook is optional, and + F's velocity patch is not applied. +- 2026-10-12 (owner): one PRD and one PR for the whole epic. Phases are release cycles; v1 phases + are the slices. This waives the 3-phase limit (R5) for this file. +- 2026-10-13 (owner): two benchmark tiers. **Cheap benchmarks** (the engine-load-test R-ladder) are + for fast iteration and regression guards only, and never close a KPI. **Final benchmarks** are + real games, Midway first (`scripts/midway-bench/`, parity gate on): every KPI box, "met" claim + and dashboard headline is judged there. Why: R5 read native 2.8x and web 1.4x, yet on Midway the + desktop player felt like 20 FPS with a 138 ms HUD lag while three.js in the browser ran at 51 + FPS, and the web engine was 0.71x. +- 2026-10-15 (owner): release v1 now. [PRD-610](PRD-610-native-engine-wins-on-real-games.md) ships + PARTIAL and its open boxes stay open. The PR leaves draft and does not merge before the owner says so. +- 2026-10-15 (owner): the C++ engine is the default now. A game that sets no `engine` runs on it + (Wasm engine on web, V8 engine player on desktop), and `engine: "legacy"` is the named override. This + supersedes the "switch at 2x" rule above. The legacy engine stays until one clean release has passed. + Android and iOS still run the legacy host. Measured on release day: the V8 engine bundler refuses seven + of the 13 templates (`TN_NATIVE_ENGINE_UNBOUND`), four more stop at runtime on the engine player, and + the web Wasm engine fails the last two on its parameterless TSL `Fn`. All 13 pin `engine: "legacy"` in + `threenative.config.ts` until slice 8 clears them, so the default changes no template today. The release also + needs the Wasm engine, the desktop bundler and the engine player in the published packages (open boxes in + Phase v1.10). +- 2026-10-15 (owner): the native engine stays the default, and there is no legacy fallback. A legacy + pin (`engine: "legacy"` in `threenative.config.ts`) is temporary debt. Each template loses its pin + when its web and desktop rows both pass on the engine ([PRD-617](PRD-617-every-template-runs-on-the-native-engine.md)). + The engine exposes the upstream Three.js API, and a game uses the same source on every back end. + This refines the previous decision: the default is not reverted while templates remain pinned. +- 2026-10-15 (owner): housekeeping. Delete junk and reduce the file count; keep every behaviour. +- Deferred, filed again only on scorecard evidence: transform compose skip (if + `updateMatrixWorldSelf` is above 2% of the frame), projection reuse and SIMD world matrices (if + transforms, cull and project stay at 10% or more), GPU-written indirect arguments (if CPU cull and + project stay at 8% or more after v3), the `GPUSceneBVH` subpath move (breaking, its own change). + +## Rules for every box + +- **Same-run ratio.** Subject and control in one command and one load window, load average + recorded. A counter gate (calls, compiles, evaluations) beats a timing gate under host load. +- **Tails.** A timing box reports p95, p99 and the hitch count beside p50; a worse p99 fails it. +- **Visual judge** for any change that can alter a pixel, per the root `AGENTS.md`. +- **Red-green** for every behaviour change. A public binding change regenerates + `packages/three-native/api/` in the same commit. +- **Unreal licence.** Read the design, write new code, copy nothing. `UE 5.8.3: ` cites a + design reference (UE `release` `396c9f059`). + +**Stability gate S** (closes every cycle): `pnpm test:templates` and `pnpm test:playtest` green on +web; `pnpm native:verify:desktop` green; an 1800+ frame soak with no crash and no RSS growth. Until +2026-10-15 S also required the default build output to match the base commit byte for byte. The default +is now native, so S instead requires that a game with `engine: "legacy"` still builds and plays. +From v2 S adds the Android emulator journey; from v3, a ThreadSanitizer engine run. + +```mermaid +flowchart LR + s0[0 charter] --> s2b[2b CPU core] + s0b[0b CI scope] --> s2b + s2a[2a catalog] --> s2b --> s3[3 shader + TNPK] --> s5[5 renderer + world] + s4[4 host services] --> s5 --> s6[6 V8 player] --> s7[7 web opt-in] --> s8[8 templates] + s6 --> v2[v2 stop wasted work] --> v3[v3 jobs + GPU scene] --> v4[v4 cull and load] + v3 --> v5[v5 web worker] +``` + +## Cycle v1 — land the engine + +Each slice is one commit series cut from `origin/develop` with Perry files dropped. Engine targets +stay `EXCLUDE_FROM_ALL` and `engine: "legacy"` stayed the default while the slices landed, so every +slice was inert or opt-in. The default flipped on 2026-10-15 (see Decisions). + +#### Phase v1.0: Charter decision (slice 0) +**Status:** DONE +- [x] Charter, `CHARTER-HISTORY.md`, root `AGENTS.md` rule 5 and `NATIVE-ENGINE-DECISION.md` admit one opt-in engine. proof: commit `fc261bbd1` (from PR #494, green: check:docs, `sync:agents --check`, doc-link, primary-docs and ci specs) + +#### Phase v1.0b: CI scope for engine paths (slice 0b) +**Status:** IN PROGRESS (classification and wiring landed; runner proof open) +- [x] `scripts/ci-change-scope.mjs` classifies `packages/runtime-native/{src/engine,src/adapters,tests/native-engine,cmake/NativeEngine*}` (the real engine prefixes on `feat/native-engine`) as `native-engine`, and `ci.yml` runs one `native-engine` job with its own ccache key and a cached emsdk. proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts` and the ci-change-scope spec, plus a `pnpm ci:local --affected` plan dump Result 2026-10-11 (commit from agent run, 18 new red-green cases): `ci-structure` + `ci-needs` 223 passed; owner call: engine-only PRs skip `native-platforms`; mixed, main, merge-group and `--full` keep it. `--base HEAD~1` plan dump selects `native-engine`, validates. +- [ ] The `native-engine` job runs green on a runner, and `ci-integration-scope.mjs` stops selecting heavy lanes for an engine-only diff (it matches the `runtime-native` producer family today). proof: a CI run on an engine-only diff + +#### Phase v1.1: Core fixes that need no engine (slice 1) +**Status:** IN PROGRESS (5 of 6 boxes; develop gates partial) +Cherry-picks of F `c1ef353ca 2007efe12 3d3695197 3b29482a6 504709d17 726df7f5c dad980723 9400d5872`, each with its spec. +- [x] `posedBounds` reads skinned-mesh envelope invariants once per mesh, not per vertex. proof: `pnpm exec vitest run packages/core/__tests__/posed-bounds.spec.ts` +- [x] Foot-plant stride reads each bone's world matrix once per frame and still reads a moved ancestor above the rig. proof: `pnpm exec vitest run packages/core/__tests__/animation.spec.ts` +- [x] An unsized canvas does not re-scale its drawing buffer when the buffer echoes back. proof: `pnpm exec vitest run packages/core/__tests__/game-pixel-ratio.spec.ts` +- [x] A rejecting scene `load()` or `start()` is reported by name instead of stalling the launch. proof: `pnpm exec vitest run packages/core/__tests__/game.spec.ts` +- [x] A counted step spends the pending freeze settle. proof: `pnpm exec vitest run packages/core/__tests__/loop.spec.ts` +- [ ] Develop's gates stay green. Partial 2026-10-11: `pnpm lint` exit 0; the five specs pass (158 tests); `pnpm typecheck` has no `packages/core` errors, only unbuilt-workspace imports (`procedural-animals`, `terrain`); `generated-shooter-input.spec.ts` (3 tests) fails identically without these commits, so it is pre-existing; `pnpm test` and `pnpm test:playtest` not rerun on this branch. proof: `pnpm typecheck && pnpm lint && pnpm test` and `pnpm test:playtest` + +#### Phase v1.2a: API catalog and reference oracle (slice 2a) +**Status:** DONE 2026-10-11 (browser backend, Perry and the native runner excluded; see notes) +- [x] `packages/three-native` catalog, generators, fixtures and goldens land (goldens marked `linguist-generated`). proof: `run-reference.ts --check --no-renders`, catalog specs, `pnpm typecheck` Result: `run-reference.ts --check --no-renders` exit 0 (96 fixtures, 2047 observations, 108 renders skipped); 7 specs / 65 tests green; package typecheck exit 0; `pnpm lint` 0 errors; frozen-lockfile install passes. Excluded: `src/browser-*` and addons (slice 7), `run-native`/`fixture-native` (slice 2b), `native-stubs.spec` and `catalog-capabilities.spec` (need PRD-526/531 text and a `native` capabilities section). Renders (108) not run here. + +#### Phase v1.2b: CPU engine core (slice 2b) +**Status:** IN PROGRESS (core landed; legacy-build box open) +- [x] `engine/{foundation,scene,animation}`, `tn_abi.h` and `cmake/NativeEngineCore.cmake` land behind `TN_ENGINE_CORE_ONLY`, fixtures regenerated against develop. proof: `ctest -L native-engine` native, under ASan/UBSan, and as Wasm under node +- [ ] The legacy build and suite are unchanged. Partial: the default configure has zero engine targets and the source-list guard passes; `pnpm --filter @threenative/runtime-native test` fails the same 29 tests as base `ef18c6b14` (unbuilt host binaries); `pnpm native:build` not run. proof: `pnpm native:build` and `pnpm --filter @threenative/runtime-native test` + Result 2026-10-11: native `-DTN_ENGINE_CORE_ONLY=ON` build + `ctest -L native-engine` 78/78 (rerun by the lead on the epic branch); agent-reported: ASan/UBSan 78/78 plus a 15 s `fuzz_buffers` run with no findings, Wasm under node 53/53 (emsdk 6.0.11). Fixtures regenerated: byte-identical, 108 render goldens not regenerated (need a GPU). Not landed, moves to slice 3: `abi.cpp`, `identity`, `tsl_call` and the `abi_test`/`abi_c11`/`fuzz_abi` tests, `differential.mjs` (they need the shader TSL surface); three animation tests that include renderer or player headers go to slice 5/6. Eight header-only files from later slices ride along so the core links. + +#### Phase v1.3: Shader IR, WGSL and TNPK (slice 3) +**Status:** DONE 2026-10-11 (GPU emit tests and naga open, see result) +- [x] `engine/{shader,assets}` and `packages/assets/src/native-package.ts` land; `assets.nativePackage` defaults off. proof: ctest shader, package and fuzz; Tint and naga validation; assets and config specs + Result 2026-10-11: native `-DTN_ENGINE_CORE_ONLY=ON` `ctest -L native-engine` 126/126 (rerun by the lead on the epic branch; baseline before this slice 78); agent-reported: ASan/UBSan 127/127, Wasm under node 99/99, libFuzzer `fuzz_abi` 272k runs clean, Tint passes 9/9 corpus programs, `packages/assets` 41 files and `create-threenative` config spec 103/103 green, `assets.nativePackage` defaults off. Not run: naga (0.20 rejects the emitted `diagnostic(off, ...)`; test registers for naga >=24), GPU shader-emit tests and the V8 `tsl_js` differential (slice 5/6), `pnpm --filter @threenative/core build` still exits 1 after tsup (cause not investigated; pre-existing status unconfirmed). Excluded: `probe_volume`, `shader_emit_test`, `shader_package_test`, `template_post_packages`, `tsl_js`, browser-backend Wasm tests. + +#### Phase v1.4: Shared host services (slice 4) +**Status:** IN PROGRESS (landed; platform proof open) +- [ ] `src/webgpu/context.cpp` and the `presentation.cpp` split land as a behaviour-neutral refactor, with `ui-layer-host-contract` fixed (`pointer.cpp` `g_resetKeyboard`). proof: `pnpm native:verify:desktop`, the `native-platforms` matrix, Android emulator parity, `pnpm test:playtest` + Partial 2026-10-11 (agent run, not rerun by the lead): `pnpm native:build` 410/410; ctest `build/tn-linux` 49/49 with the NVIDIA per-process ICD shim; `ui-layer-host-contract` 2 tests red-green (stub now defines `g_resetKeyboard`; hit-region check reads `runtime.cpp` and `ui_overlay.cpp`); `pnpm native:verify:desktop` exit 0 (300 frames, 1280x720); runtime-native vitest 1611 passed / 2 failed (`native-sbom` receipts, unconfirmed at base). Lead rerun: `presentation` resize spec 2/2, `ci-structure` + `ci-needs` 223, source-list guard. Not run: Android emulator parity, `native-platforms` matrix, `pnpm test:playtest`, Windows/macOS. Dropped as not neutral for legacy: the Android acquire watchdog (`_Exit(2)` after 2 s, moves to slice 9), `audio_context` playbackRate and interpolation changes, the Dawn timestamp-quantization toggle, `wgpuQueueRelease` in `~Context`, the device-lost handler. Box stays open until the matrix and an Android run pass. + +#### Phase v1.5: Renderer and world (slice 5, 2–3 commits) +**Status:** IN PROGRESS (landed in 3 commits; 6 tests red under `tn-known-red`) +- [ ] `engine/renderer` (resources, presentation, device loss, draw core, materials, graph) and `engine/world` land, with stale fixtures regenerated (`world_tiles`, `gpu_scene`, `render_vsm`, `target_graph`, `update_scaling`). proof: ctest on a software GPU, render fixtures against browser frames, the device-loss test + Result 2026-10-12 (agent run on the NVIDIA lane, not rerun by the lead): `engine/renderer` and `engine/world` landed as 5a/5b/5c; `ctest -L native-engine` 297/297 with the device-loss test and the `render_*` fixtures against browser goldens, ASan/UBSan 297/297, Wasm under node 115/115, `TN_ENGINE_CORE_ONLY` 163/163, default configure has zero engine targets, root specs 298 green. Six tests are red and parked under label `tn-known-red`, so the box stays open: `render_vsm_open` vsm-basic 1.07% and vsm-deformation 2.18% off native against a 1% limit (F recorded those goldens as unverified); `template_post_starter` (develop's starter chain gained a LUT grade and a grain stage the native lowering lacks); `loop_reference_current` (develop's `FixedStepLoop` has no `interpolationAlpha`); `world_tiles_reference_current` (22/625 observations differ since develop changed `world-tiles.ts`); `world_gpu_scene_reference_current` (1/38 steps, develop changed `world-gpu-scene.ts`); `world_cells_reference_current` (generator refuses a new develop spec). Fixing them means porting the engine to develop's newer core, tracked as follow-up boxes below. `update_scaling` 4k to 64k scaling (35.9x against 18x) moved to the `tn-perf` label as an open perf target. Excluded: `world/walk`, skinned-crowd cases, strict-artifact inspect (slice 6); `renderer/chain` (needs `mrtVelocity` from core); PhysicsSync (not built: needs `TN_ENABLE_NATIVE_PHYSICS` and Rapier). +- [ ] The six `tn-known-red` tests go green against develop's current core (tables regenerated, starter LUT and grain stages lowered, VSM within 1%). proof: `ctest -L tn-known-red` all pass and `ctest -L native-engine` stays green + +#### Phase v1.6: V8 player and core seams (slice 6) +**Status:** DONE 2026-10-12 (native-only claims; Android, soak and full suites open) +- [x] `adapters/v8`, `engine/player`, the playtest `--executable` lane and the optional core seams (`render/chain.ts` `provision`, `world-cells.ts`, `profiling/Spans.ts`, the cull engine root from `ebcac38ce`) land, with the 8 develop-drift symbols bound. proof: `native_engine_v8_*` ctest, `native-engine-world-*` playtests, the minimal template on the V8 player + Result 2026-10-12 (agent run on the NVIDIA lane; lead reran the core and root specs: 82 passed): `TN_ENGINE_PLAYER` builds `tn-native-engine-v8`, all targets `EXCLUDE_FROM_ALL`; `ctest -L native-engine` 353/353 (44 `native_engine_v8_*`, `player_imports`, `chain_reference_current`, `strict_artifact_inspect`, Rapier sync x2); `--executable` playtests `world-walk` 510 frames, `world-cycles` 2650, `world-fault` 510 pass; `native-engine-minimal-v8` ran 300 frames, the player moved 3.0 m, frame non-blank (mean tone 133.7, black fraction 0), screenshot `~/Downloads/native-engine-minimal-v8.png`. 16 unbound symbols bound (the 8 named plus `TempNode`, `NodeMaterial`, `RendererUtils`, `fxaa`, `godrays`, `ssgi`, `ssr`, `sharpen` refused by name, `mrt()` accepts `vec4(normalView, 0)`). Web path unchanged: `provision` defaults to `mrtVelocity()`, `render-velocity` 20/20 and `render-camera-cull` 20/20 pass. Three host ctest triangle counts changed from 771/99/771 to 507/87/507 because the renderer culls by default (v8 and cpp agree). `GAME_SPANS` ids shifted +2 to avoid develop's terrain ids, pinned by a new spec. + Known limits, not silent: `scene.fogNode` ignored on native (prints `TN_NATIVE_FOG_NODE_IGNORED`); blend modes recorded, not applied; `context` and `velocity` throw on native, so TRAA and motion blur are unsupported on the engine path until the velocity design lands (the engine's velocity patch was not applied); `readback` fails by name. Not run: Android, Windows, macOS, `pnpm test`, `test:playtest`, `test:templates`, a 1800-frame soak, root `pnpm lint` (4 errors in `examples/procedural-animals/.../quadruped.js`, untouched, base not compared). + +#### Phase v1.7: Web Wasm engine, opt-in (slice 7) +**Status:** PARTIAL +- [ ] `engine/wasm/web_host.cpp`, `three-native/src/browser-*`, `engine: "native"` and `profile:wasm-page` land with `engine: "legacy"` the default. proof: web-engine spec, `wasm-engine-boot` playtests, `pnpm test:templates` unchanged, a web A/B with `--control` + Partial 2026-10-12: merged in 9ba8a8794 (vitest 241/241 in the worker, root tsc 0). Bridge cuts 1-4 ported to the browser back end, with crossings at 38,308 -> 52 per frame. `wasm-engine-boot` and `pnpm test:templates` not run. +- [x] The web engine page is at least 1.3x cheaper than three.js: tn-web p50 ÷ tn-engine-web p50 on main-thread wall time (performance.now(), not a CPU clock), R5@4096, real GPU, same pair guard. Target set by the owner on 2026-10-12. proof: a 3+3 `bench:engines` pair, `artifacts/engine-load-test/prd-573/web/` + Result 2026-10-12: **1.41x** (`web-desk-confirm-2` at 1b1926f55, desktop RTX 2080 nvidia/turing, Xvfb, headed Chrome, load 11-12 so the in-run ratio counts): tn-web p50 7.96, p95 14.00, p99 26.01 ms vs engine-web 5.63, 8.39, 16.38 ms. Path: 0.15x baseline, 0.92x after bridge cuts 1-4, 1.01x after collect, 1.41x after own-property members and allocation-free rotation. Cuts 3 and 4 were judged NEUTRAL, and cuts 5-7 change no pixels. Laptop rerun not done. + +#### Phase v1.8: Template post-node imports (slice 8) +**Status:** NOT STARTED +- [ ] The 14 templates import post nodes only from the tier that enables them, pixel-neutral. proof: `pnpm test:templates`, template-quality spec, a visual judge on BEFORE/AFTER captures + +#### Phase v1.9: The scorecard +**Status:** NOT STARTED +Today: `perf` has p95, p99 and hitches but no memory or host load; `bench:scoreboard` has medians only; the R-ladder runs only on legacy and Godot arms; `run-godot.ts` has no workloads, so Godot ratios exist only on the ladders; `profile:wasm-page --load` means load phases, not host load; no soak command exists. +- [x] `pnpm perf:live` serves a page that re-reads every bench report under `artifacts/engine-load-test` every 2 s and plots p50 per workload and arm, with a red ring for a >30% regression against the series best. proof: `pnpm exec vitest run scripts/__tests__/perf-live.spec.ts` (2 passed) and a browser check against the 505 recorded pilot points, 2026-10-11. Revamped 2026-10-12: cycle tabs v1 to v5, native-vs-legacy KPI tiles against the 2x target, epic progress and gate pills read live from this PRD (`--prd`), `epicOf` spec; the server watches `artifacts/engine-load-test/prd-573` only. Checked in a browser against the 18 first A/B reports. +- [x] `bench:engines` and `bench:scoreboard` rows carry p95, p99, the hitch count, draw calls and pipeline compiles, and fail closed on a missing counter. proof: a red-green case in the engine-load-test report spec Result 2026-10-11: 147 engine-load-test specs green, red first (14 failures); Godot rows are exempt from `pipelineCompiles` only (Godot load test has no counter; `ponytail:` note in `report.ts`), a Godot rung with no draw call still fails. Unverified: the counter has not run in a real three/WebGPU renderer. +- [ ] `perf`, `bench:engines` and `profile:wasm-page` carry `hostLoad` and the core count, and mark absolutes `invalid-absolute` above half the core count. proof: a red-green case per spec with a stubbed load Partial: `bench:engines` and `perf` stamp `hostLoad` and `invalid-absolute` (stubbed-load specs green, 4 red first for `perf`); `profile:wasm-page` is slice 7 and does not exist yet. +- [ ] Memory (partial 2026-10-12, arm run reviewed by the lead: `parseProcStatusMemory` and `parseTotalPssMb` pure parsers fail closed with named errors, `--executable` reads `VmHWM`, 4 specs red then green; the `--logcat` PSS wiring and `profile:wasm-page --memory` are not exercised, no desktop or emulator run yet): `perf --executable` peak RSS (`VmHWM`), `perf --logcat` PSS (`dumpsys meminfo `), `profile:wasm-page --memory` (Wasm memory, JS heap). proof: red-green cases in `perf.spec.ts` and the profile-wasm-page spec, then one desktop run and one emulator run + Desktop run 2026-10-13: the same soak printed peak RSS 288.3 MB (VmHWM). The emulator run (`perf --logcat` PSS) is not run, so the box stays open. +- [x] A soak command (`perf --soak`, `soakVerdict`; 7 specs red then green; perf+perf-surface specs 85 pass, `tsc --noEmit` clean; no real 1800-frame run yet) runs 1800+ frames and fails on a crash or RSS growth. proof: a red-green case with a growing stubbed RSS series, then one run on the legacy desktop host + Result 2026-10-13 at 67510c5ab: `perf --executable build/tn-linux/mystral --host-arg run --host-arg dist/native-smoke.js --soak 1800` on the legacy desktop host (examples/native-smoke, NVIDIA, private Xvfb) exits 0. All 1800 of 1800 frames ran, and RSS went from 272.8 MB after frame 300 to 218.3 MB at the end (-20%, limit +10%). +- [ ] The R-ladder runs on the `native-v8` arm, and the scorecard carries a `godotRatio` column from `bench:scoreboard` R2, R3 and R5 (no Godot row for a workload Godot cannot run). proof: a red-green case in the engine-load-test spec, then the baseline rows in `docs/verification/runtime-perf-state.md` +- [ ] Parity guard and player-side telemetry salvaged from `ne/bench-parity`, `ne/bench-cputime`, `ne/bench-telemetry`, `ne/bench-counters`. Landed: generic `scripts/engine-load-test/parity.ts` (fail-closed facet table, symmetric span families, wall-clock check; 11 specs, no caller yet). Moves to slice 6 because the player files do not exist yet: first-frame hold until the runner attaches (`07c0b6761`), thread CPU time in player frame metrics (`e5d1c9456`), `performance.now` as the player clock (`f7b12972d`), `__tnFrameSpans` (`febc0f9a0`), `--counters` (`9f40997dc`), `runFacets`. Perry wiring dropped. proof: parity spec plus a same-run two-arm bench that passes the guard + +#### Phase v1.10: v1 KPI and gate +**Status:** PARTIAL +- [x] Native steady frame CPU is at least 2x legacy on R5 desktop. First A/B 2026-10-12 (3 alternating rounds, parity guard passed, host loaded so read as ratios): native is slower, 0.49x R2, 0.52x R3, 0.79x R5 by CPU per frame. Cause: bridge crossings (`Adapter::unwrap/wrap`, per-frame wrapper scans), see `artifacts/engine-load-test/prd-573/*-pair.md`. KPI open. After three crossing cuts (slot-cached reads, stack-array arguments, native structure stamp; bridge calls per settled R2 frame 98,307 to 41,229) the ratio is 0.72x R2 and 0.91x R5 (host load 6 to 34, alternating pairs). Under Xvfb both arms spin about 32 ms per frame in Dawn `Present`, so the shared floor caps any ratio near 1.1 to 1.2x on this lane; excluding it, the engine adds about 16 ms over legacy's 3 ms at 4096 cubes. Remaining crossings are the game's own setters (about 10 per cube). Breaking the ceiling needs the native scene graph to draw and reconcile objects the game does not touch, or a lane whose present is not shared CPU. Decision owed before more speed work. proof: `pnpm bench:engines` with `tn-desktop` and `native-v8` on R5 in one invocation, tails and load recorded + Met 2026-10-12: real GPU (RTX 2080, real display) R5 frame p50 2.72x then 2.80x, thread CPU 2.79x then 2.97x; Xvfb thread CPU without present 2.10x. See step 5 under v1.11 and [RESULTS-2026-10-12](../../benchmark/prd573/RESULTS-2026-10-12.md). + Cheap tier only (decision 2026-10-13): this box no longer closes the v1 KPI. The next box does. +- [ ] Final tier: the native desktop player runs Midway at frame p50 and main-thread CPU per frame at least 2x the legacy desktop host on the same scripted flight, parity PASS, HUD round trip under one frame. 2026-10-13: open and losing. The owner played both and found the player at about 20 FPS with a 138 ms HUD lag, against 51 FPS for three.js in the browser. Engine C++ takes 75% of the main thread (cull, matrix walk, animation, render project). proof: `scripts/midway-bench/flight.sh desktop` timing pairs. Work: [PRD-610](PRD-610-native-engine-wins-on-real-games.md) +- [ ] Native steady frame CPU is at least 2x three.js on Midway web, frame p50 no worse. proof: `pnpm profile:wasm-page -- --url --control --cpu-work` +- [ ] Stability gate S is green. proof: the S commands on the release commit. Partial 2026-10-15: `pnpm budgets` exit 0 after a regenerated native coverage record; native ctest 394 of 397 (`native_engine_target_graph` fails only in a build with the web overlay ON, `native_engine_pacing` and `native_engine_player_real_input` pass alone and fail under `-j4` load); `pnpm native:verify:desktop` NOT green here, because the legacy host's screenshot request times out under Xvfb (`startup-stall.png`) and no baseline host exists to prove that environmental. +- [x] The C++ engine is the default for a game that sets no `engine`, and `engine: "legacy"` is the named override (owner decision 2026-10-15). proof: red-green in `packages/create-threenative/__tests__/config.spec.ts` (`validateEngine(undefined)` was "legacy", now "native"), the create-threenative suite 102 files, 1566 tests, `pnpm check:docs`, the prose-lane specs 7 files, 272 tests. Result 2026-10-15: the root and template AGENTS.md name the default and the override, and the build errors name `engine: "legacy"`. +- [x] A scaffolded template builds and plays on the engine player on desktop with the default engine. proof: scaffold `minimal`, `threenative build --target desktop`, then the template's own `play` scenario with `"target": "desktop"`. Result 2026-10-15 (RTX 2080, Xvfb, engine player `tn-native-engine-player-v8` with native physics): pass, 3 of 3 assertions (`components` position changed, `renderChain.tier`, `diagnostics`). Boot runs of the other templates on the same player: `puzzle` passes (180 ticks, no console error); `platformer` and `tower-defense` stop on `MeshStandardMaterial.flatShading` ("not a bound material property"); `racing` stops on `mesh.computeBoundingSphere is not a function`; `rts` logs `TN_V8_FRAME_FAILED: updateRanges.push is not a function` every frame; `starter` fails in the bundler with 18 `TN_NATIVE_ENGINE_UNBOUND`. Two player faults found and fixed on the way: it refused the runner's `--width`, `--height` and `--windowed`, and it reported no `runtime.network` (every desktop playtest on the player failed since develop's network check). +- [ ] No template runs on the native engine yet, so no template changes pixels, and the legacy-versus-native visual judge is not run. All 13 templates pin `engine: "legacy"` with a "PRD-573 slice 8" comment: the web Wasm engine's TSL `Fn` takes no parameters (minimal and puzzle fail every web playtest with `TN_PLAYTEST_BRIDGE_MISSING` and a startup `TypeError`), and the desktop engine refuses or stops on the symbols above. proof: delete a template's pin, then `pnpm test` (web) and a `--target desktop` playtest both pass, then a visual judge on legacy-versus-native captures. A legacy desktop capture is not available on this lane (the legacy host screenshot times out under Xvfb), so the judge needs a lane with a real display. Work: PRD-617, every template runs on the native engine (filed on local `develop`, not yet on `origin/develop`) +- [ ] The release ships the web Wasm engine, the desktop engine bundler and the engine player. proof: `npm pack` of `@threenative/runtime-native` and `@threenative/core` from a clean checkout lists `build/web/tn-native-engine-web.{mjs,wasm}`, the bundler and the player, and a scaffold installed from those tarballs builds with `engine` unset. Today the Wasm files are in the `files` list but no CI job builds them (`TN_WASM_ENGINE_MISSING` names the build command), and the player is a local `THREENATIVE_ENGINE_PLAYER` path. + +#### Phase v1.11: Native reconcile (the route to the v1.10 KPI) + +Design study 2026-10-12 (read-only). The native renderer already batches the source scene (`RenderDatabase::batch`, `projection::decide` ports `scanProjection`), so core's JS mirror repeats work native does, and every object crosses the bridge to feed it. Estimated crossings per settled R2 frame: game setters 12k, `MatrixWorldPass` 16 to 25k, mirror sync 12k, stability walk 0 to 3 per cube. `Adapter::collect` scans about 25k wrappers per frame (11% of CPU). The KPI metric is fixed here before the next A/B: **thread CPU time per frame excluding Dawn `Present`, measured by the same instrument in both arms (`presentCpuMs`), on the headed real-GPU lane, with the inclusive number published next to it.** Present spins about 32 ms per frame under Xvfb in both arms, so the inclusive ratio there is at most 1.09x whatever the engine does. The 2x target is R5 desktop; R2 is reported and is not the KPI (estimate for R2 excluding present: 1.2 to 1.6x). The target is claimed only if the inclusive real-GPU number agrees. If R5 tops out below 2x, the PRD says so; the metric does not move. + +- [x] Step 0: a per-name crossing histogram (flag-gated, `abi.cpp`) and `presentCpuMs` in both arms' frame report; a 1k-cube budget ctest asserts at most 3.5 crossings per cube (red first). proof: `v8_adapter_test.cpp` ctest, `pnpm bench:engines --pair` showing the new column + Result 2026-10-12: histogram sum equals `crossings()` (ctest `crossing_histogram`); `presentCpuMs` and a thread-CPU-without-present column in the pair table. `native_engine_player_crossing_budget` is red as planned at 10.26 per cube (`tn-known-red`). First no-present ratio on R2@4096: 0.33x (legacy 8.07 ms, engine 24.29 ms), host load 8 to 14. +- [x] Step 1: `Adapter::collect` visits only new or changed wrappers. proof: ctest with 25k wrappers and a steady frame gives 0 visits; the churn test at `v8_adapter_test.cpp:335` stays green + Result: ctest `collect_visits` red with the full sweep forced (32,145 visits), green with the epoch check; 1k rung visits 2,276,163 to 37,723 over 360 frames; `ctest -L native-engine` 359/359. R2@4096 no-present ratio 0.36x. +- [x] Step 2: native `Object3D.__tnUpdateMatrixWorld(force)` and a `MatrixWorldPass` seam that calls it when the root has it. proof: vitest with a counting stub (red: reads scale with N) and a ctest with constant crossings for 1k and 4k nodes + Result: ctest `native_matrix_world` equal crossings at 1,001 and 4,001 nodes; 2 vitest cases red with the seam off, `matrix-world.spec.ts` 11/11 with it on. R2 crossings 41,229 to 37,100 per frame (the study estimated 16k to 25k fewer: most remaining crossings are math, `x`, `y`, `copy`, `equals`, which step 4a targets). R2@4096 no-present ratio 0.41x, p50 ratio 0.81x. +- [x] Step 3: a host projection seam in `renderProjection.ts`: when the root has `__tnProjectionReport`, no mirror is built and the native renderer batches the source scene; `renderer.projection: "js"` forces the mirror and the report names which ran. proof: vitest (`root === source`, zero per-object reads), ctest with crossings constant in N, draw-list and pixel parity against the mirror path, a visual judge on BEFORE/AFTER + Result 2026-10-12 (519793ef8..a38bc61fa): vitest `host-projection.spec.ts` (`root === source`, no mirror, override forces it). `player-host-projection.mjs` shows host and mirror pixels identical (mean delta 0) over instance colours, shadows, transparency, render order, hidden objects, skinned rigs, a sprite, an LOD and a line. `native_engine_player_crossing_budget` is green at 0.01 crossings per cube (10.26 at step 0) and leaves `tn-known-red`. `ctest -L native-engine` 370/370. The fresh judge returned PASS on the R5 frames (BEFORE `"js"` forced, AFTER host, 2 runs each). R5@4096 phases legacy/engine ms: projection 2.36/0.57, render 3.80/2.72, total without present 12.87/7.65. Per-round ratios 1.63, 2.26 and 1.90 at host load 12 to 17. R2@4096: 5.99/4.65 ms, ratios 1.07, 1.68, 1.32. Variation kept: the hierarchy-gated report; stamping every call lost. The guard waives drawCalls and triangles (engine 7 draws and 134,463 triangles, legacy 116 and 155,919). A fairness audit of the 21k-triangle gap is open before the KPI claim. +Route to 2x, 2026-10-12. Steps 1 to 3 remove the crossings that core adds. They do not remove the crossings that the game and core math add, and those set the floor. `native-registry.json` binds `Vector2`, `Vector3`, `Quaternion`, `Euler`, `Matrix4`, `Color` and `Box3` as native classes. So every math operation in game and core code (`tmp.copy(a).sub(b)`, `rotation.x = v`, `matrixWorld.equals`) is a V8 API callback of 40 to 80 ns, where legacy pays about 1 ns per JIT-inlined field. A raw-pointer accessor (the old step 4) makes each crossing cheaper, but each write is still a crossing. The Unreal split puts value types in the script language and systems in C++, and the hot state goes in shared memory. `TransformPage` already keeps transforms as stable paged SoA columns of doubles, so the shared views need no data move. The same split is the only fast design for the web Wasm target (slice 7), where views go over linear memory. +R5 CPU budget, 2026-10-12 (perf plus `/proc` stime, R5@4096 1920x1080, RTX 2080 through Xvfb, one run per arm, host load 11 to 26). Present (`writev` of each frame to the Xvfb socket inside `SwapChain::PresentImpl`, plus the NVIDIA wait spin) is 74 to 77 ms per frame in both arms. That is about 80% of main-thread CPU, so the inclusive Xvfb ratio cannot show the engine. Without Present: legacy about 15 ms (three renderer JS 3.0, core projection JS 2.0, math JS 1.2, V8 runtime 3.5, NVIDIA user driver 3.5, Dawn 0.5). Engine about 21 ms (bridge 11.9: `unwrap`, binding thunks, `collect`, `engineReferences`, `share`; native scene 2.7; V8 runtime 1.7; driver 1.5; native renderer 0.15; native animation 0.34). With the bridge and the projection walks gone, the engine floor is about 5 to 8 ms against 15 ms, so 2x on the declared metric is in reach. The engine arm evaluates clips but never builds a bone palette (`Skeleton::update` 0 calls, `SkinnedPalette::write` 0 calls over a run), so the 50 foxes draw unskinned. That is a correctness bug, fixed before the step 5 A/B. Fixed 2026-10-12: the SkinnedMesh binding kept `skeleton`, `bindMatrix` and `bindMatrixInverse` read-only, so core's projection copy dropped them. ctest `native_engine_v8_skeletal` was red before and is green after, and `Skeleton::update` went from 0 calls to 29.5k in an R5 run. Visual judge: IMPROVEMENT, the foxes now match legacy poses and nothing else changed (screenshots on branch `PRD-573-screenshots`). The player also reports the real drawing-buffer size now (`player-imports.mjs` red-green at 640x360). +- [ ] Step 4a: value types are JS in the engine arm. `Vector2/3/4`, `Quaternion`, `Euler`, `Matrix3/4`, `Color`, `Box3` and `Sphere` resolve to three's own classes, not native wrappers. The adapter reads a value argument from a plain JS object, and writes a value result into one. proof: a ctest where 10k `Vector3` operations in a frame give a `crossings()` delta of 0; `pnpm parity` unchanged; `vitest run packages/three-native` + Open: ctest `js_math` gives a crossing delta of 0 over 10k `Vector3` ops (it was never seen red: the proof was green on its first run), and `vitest run packages/three-native` passes 94/94. `pnpm parity` has not run. +- [x] Step 4b: transform members are shared views. `position`, `rotation`, `quaternion`, `scale`, `matrix` and `matrixWorld` of a native `Object3D` are three's JS classes with storage over `Float64Array` views of the `TransformPage` columns (stride and field offsets from C++, the page kept alive by the backing store). Euler and Quaternion sync runs in JS, through three's own `_onChange`. Native `updateMatrix` recomposes and compares, and it bumps `revision` on a change, so a direct memory write is seen. proof: a ctest with 3N component writes giving a `crossings()` delta of 0 and the native matrix equal to three's; the existing sync, `revision` and `matrixWorldNeedsUpdate` cases green; a visual judge on R5 BEFORE/AFTER + Result 2026-10-12 (5ae954d1c..65c00b0e4): ctest `js_math_views` gives a crossing delta of 0 over 3N component writes, with the native matrix bit-equal to `Matrix4.compose` and `revision` +1; `ctest -L native-engine` 367/367. R5 frames with `TN_NATIVE_JS_MATH=0` and `=1` are pixel-identical, and the fresh judge returned PASS on both runs. R2@4096 engine thread CPU without present fell from 21.7 to 12.0 ms (same binary, flag off then on). R2 crossings fell from 37,100 to 16,588 per frame. R5 KPI 0.73x to 0.90x (rounds 0.86, 0.86, 0.97, host load 15 to 41). Known differences from three: a native object that keeps a JS math argument (curve points) holds a copy, so it does not alias the caller's vector, and `Array.isArray(matrix.elements)` is false for views. +- [x] Step 5: the A/B on the declared metric, on the real-GPU lane and on Xvfb, tails and load recorded. proof: `pnpm bench:engines --pair tn-desktop,tn-engine-desktop --rounds 3` on R2, R3 and R5 + Real-GPU lane 2026-10-12, at 2976dad4a. Setup: RTX 2080 on the real KDE display :0 (Xwayland), both arms on one display after fix 14d887e24, and a KWin rule giving both windows the exact requested size. Host load 8 to 12, so trust the ratios. + | Rung | Frame p50, legacy vs engine (ms) | Ratio | Thread CPU without present | p99 (ms) | + | --- | --- | --- | --- | --- | + | R5@4096 1920x1080 | 11.84 vs 4.35 | **2.72x** | 2.79x (rounds 2.76/3.01/2.76) | 19.01 vs 11.50 | + | R3@4096 1280x720 | 11.96 vs 3.74 | **3.20x** | 3.29x | 18.30 vs 12.33 | + | R2@4096 1280x720 | 6.98 vs 2.97 | **2.35x** | 2.49x | 12.49 vs 8.18 | + - Present on a real display is 0.10 to 0.16 ms on both arms. The Xvfb present of about 75 ms was the only thing holding the inclusive number near 1x. + - Engine hitch counts (5 to 12, against 0 to 1) are relative to each arm's own median, so they are a watch item, not a tail regression. Absolute p95 and p99 are lower on the engine at every rung. + - Artifacts: `v111/gpu-R5-desktop6-*`, `v111/gpu-R3-desktop-*`, `v111/gpu-R2-desktop-*`. Run `gpu-R5-desktop` is invalid (its engine arm ran on Xvfb) and is not cited. + All lanes in one place: [RESULTS-2026-10-12](../../benchmark/prd573/RESULTS-2026-10-12.md). + Cuts after the KPI, merged in 5947a9404: + - `88d18f1af`: animation hot fields plus prefetch. Animation fell from 0.81–1.18 to 0.47–0.66 ms, every B run below every A run, ctest `track_hot_fields`. + - `7fe2159fe`: `decide()` casts once per class run. It saves about 0.08 ms in `decide`, with no frame-level claim. + - Real display R5 (`gpu-R5-cut2`, host load ~21): frame p50 18.02 vs 6.43 ms, **2.80x** (2.97x thread CPU). The engine now beats legacy on animation too (0.42 vs 0.89 ms). + - The agent stopped because the rest is irreducible: the NVIDIA driver plus `clock_gettime` take 43% of self time; the fdlibm sin/cos parity math 3%; JS that legacy also runs 5%; and cold-object loads in `ProjectedCull::apply` 3%, where prefetch failed its A/B. ctest native-engine passed 373/373 (agent-run). + Xvfb lane, R5, at 2976dad4a: + - Fairness fixes (8a913846c..63f391d47): projected cubes are one cull unit on both arms, the engine counts its post passes, and the probe reads the stepped frame. Judge: IMPROVEMENT. + - Render cuts (8f3451a3d..2976dad4a), each kept only after it won paired A/Bs: rigs-only `decide`, one world sphere per mesh, scene-order record slots, and a skipped sweep. + - `ctest -L native-engine -LE tn-known-red` 371/371. + - The R5@4096 1920x1080 pair, 5 rounds, waives drawCalls only. Triangles are 155,919 on both arms. + - Thread CPU without present: 12.71 vs 6.05 ms, pooled **2.10x**. Per round: 1.73, 2.35, 2.03, 2.12, 2.12 (median 2.12). + - Inclusive frame p50: 96.35 vs 85.92 ms (1.12x). p95: 122.3 vs 117.0. p99: 137.8 vs 130.9. + - Engine phases, ms: render 1.53, other 1.64, animation 0.87 (legacy 0.68), game 0.79, matrixWorld 0.54, projection 0.44. + - Host load 10 to 21. Artifacts: `artifacts/engine-load-test/prd-573/v111/v1-confirm-*`. + - R3@4096 1280x720, 3 rounds, at 425114540: 13.23 vs 6.38 ms, pooled **2.07x** (rounds 2.15, 2.08, 2.01). Triangles are 155,907 on both arms. Inclusive p50 is 49.86 vs 42.05 ms (1.19x), and p99 is 64.4 vs 52.9 ms. + - R2@4096 1280x720, 3 rounds: 7.97 vs 4.71 ms, pooled 1.69x (rounds 1.71, 1.68, 1.98). Triangles are 98,307 on both arms. Inclusive p50 1.03x, but p99 is worse on the engine (76.7 vs 58.5 ms, 3 hitches). + - Engine `other` (the frame minus the five phases) is higher than legacy on every rung: R2 2.17/1.48 ms, R3 2.17/1.81 ms. It is the next target. Host load was 25 to 27 for R2/R3. Artifacts: `v111/v1-confirm-R2-*`, `v111/v1-confirm-R3-*`. + - The real-GPU lane above agrees, so the 2x claim stands on both lanes. + + +## Cycle v2 — stop wasted work + +#### Phase v2.1: No lock per bone or binding +**Status:** PARTIAL +- [x] `PropertyBinding` applies without a lock; a binding to a released node is a safe no-op. proof: red-green `native_engine_animation_binding_released` (F `e9d4f1f4c`; `ctest -L native-engine` 290/292, reds only the known `render_vsm`, `update_scaling`); lock self share 3.9% to under 0.7%. Lands with slice 2b. +- [ ] `Skeleton::bone` (`animation/skinning/skeleton.h:74`, a lock per bone, twice per bone in the palette build `skeleton.cpp:116-119`) uses a bind-time pointer with an expiry check. proof: red-green `native_engine_skinned_crowd_cpu`, then a `profile:wasm-page` A/B with the `__shared_weak_count::lock` share under 0.7% + +#### Phase v2.2: One view block per frame +**Status:** NOT STARTED +Today `renderer.cpp:2095-2101` writes view, projection and a double `normalMatrix(view * model)` into every draw's block. +- [ ] View, projection and camera constants go into one per-view block; a camera-only change writes no draw block. proof: a red-green bytes-per-frame counter in `native_engine_batched_vs_unbatched` +- [ ] Frames match the per-draw path and pass the visual judge; R5 and Midway frame CPU fall with p99 no worse. proof: the same ctest within tolerance, a judge subagent, `bench:engines` and `profile:wasm-page` A/Bs + +#### Phase v2.3: Hidden rigs update less, with Unreal's safeguards +**Status:** NOT STARTED +`AnimationMixer::update` (`mixer.cpp:724`) evaluates every rig every frame; games call it directly, so the rule lives there. Reference: URO (`SkinnedMeshComponent.cpp:283-370`, 1.0 s window `:1864`, `ShiftBucket` stagger, forced update `:6066-6100`). +- [ ] Main and shadow passes stamp the drawn frame on the rig root. proof: red-green `native_engine_animation_mixer` +- [ ] Hidden rigs evaluate about 1 frame in 4, staggered (at most ceil(N/4) per frame), with clip time and `finished` equal to rate 1. proof: a red-green counter in `native_engine_skinned_crowd_cpu` +- [ ] A rig entering view evaluates at once, a rig with a non-bone child on a bone evaluates every frame, a skip never exceeds the window (start 8 frames or 0.25 s), and `AnimationMixer.updateRate` overrides it. proof: red-green `native_engine_animation_mixer` +- [ ] Conformance cases opt out with rate 1, and the rate passes the visual judge with p99 no worse. proof: `pnpm parity` unchanged; a judge subagent on rigs entering view + +#### Phase v2.4: No draw waits on a compile +**Status:** PARTIAL +Today `getAsync` and a 2-worker pool exist (`pipeline_cache.cpp:191`, `.h:118`), wired through `compileAsync` on native and web; a missed key still compiles in-frame, and the engine reports no synchronous creates. +- [x] On native desktop, `compileAsync` builds missing pipelines on the pool and the next `render` compiles none. proof: red-green `native_engine_renderer_pipeline_compile_async` on Dawn Null, 2026-10-09 (F). Lands with slice 5. +- [ ] On the web, `compileAsync` uses the async entry and the next frame compiles none. proof: `native_engine_wasm_browser_backend` run in a browser (the stubbed case passes) +- [ ] The engine enqueues keys for added content itself, skips a draw whose pipeline is pending, and reports synchronous creates and skipped draws per frame. proof: red-green `native_engine_renderer_pipeline_cache` (synchronous creates after first present = 0) and a `perf.spec.ts` case +- [ ] Skipped draws pass the visual judge, and Midway `ready` beats its base. proof: a judge subagent; `profile:wasm-page --load --gpu-calls` ratio below 1.0 + +#### Phase v2.5: Pipelines ship and persist +**Status:** NOT STARTED +- [ ] The player writes the used key list, the cook step ships it, and a launch with it compiles nothing in-frame. proof: red-green `native_engine_renderer_pipeline_cache`, then `perf --executable` first-frame synchronous creates 0 +- [ ] Dawn's blob cache, hooked in `src/webgpu/context.cpp` and keyed by adapter, driver and generator version, serves a second launch. proof: a fake blob store counting loads and stores, then two launches on a real adapter +- [ ] The engine persists pipelines on wgpu-native through `WGPUPipelineCache`, reusing PRD-368's identity, and a corrupt cache still launches. proof: PRD-368's corrupt-cache contract, then two emulator launches of the Android engine player + +#### Phase v2.6: Android engine player (slice 9), v2 KPI and gate +**Status:** NOT STARTED +- [ ] `android/engine-player` and the `native_engine` dispatch input land. proof: android-native-engine spec and an emulator run +- [ ] Native R5 frame p50 is at least Godot desktop's. proof: `pnpm bench:scoreboard` `godotRatio` at least 1.0 with tails +- [ ] Android first-launch pipeline compiles fall against the v1 build. proof: emulator `perf --logcat` compile counter +- [ ] Stability gate S with the emulator journey is green. proof: the S commands + +## Cycle v3 — every core and the GPU scene + +Six hazards block a render thread: non-atomic `nextObjectId`/`hierarchyRevision` and file-scope scratch (`object3d.cpp:67-77`); a snapshot `shared_ptr` release running a destructor off-thread; renderer state set from JS (`v8_main.cpp:228,575`); `renderReflections` mutating live materials (`render_database.cpp:1405`); single-thread loop glue with no frame-in-flight limit (`run.cpp`); the Android surface-released handshake (`run.cpp:260,525`). A render thread alone caps at 0.68–0.79x of the frame, so jobs come first. + +#### Phase v3.1: Jobs on every core +**Status:** NOT STARTED +- [ ] The frame report splits `renderMs` into prepare and `Renderer::render`, and `perf` prints stages that sum to frame CPU within 5%. proof: a red-green `perf.spec.ts` case +- [ ] Mixers and independent transform subtrees run as jobs, bit-equal to one thread, events dispatched after the join, hazard 1 fixed. proof: `native_engine_skinned_crowd_cpu` and `native_engine_static_transform` with 1 and N workers, under ThreadSanitizer + +#### Phase v3.2: Recorded passes survive camera motion +**Status:** NOT STARTED +Today the bundle key (`renderer.cpp:2702-2722`) moves with depth order (`sortDraws`), plan-order uniform offsets and visible-count batch stores (`render_database.cpp:893`); shadow, normal and velocity passes encode directly (`renderer.cpp:2693,2784,2813`). +- [ ] Rebuilds by cause and direct encodes by pass are counted, and shadow, normal and velocity passes replay bundles pixel-identically. proof: red-green `native_engine_batched_vs_unbatched`; `profile:wasm-page --gpu-calls` ratio below 1.0 +- [ ] State-first opaque order and a persistent uniform slot per record: a camera turn on an opaque scene rebuilds nothing, and the judge passes. proof: red-green `native_engine_batched_vs_unbatched`; a judge subagent; `--gpu-time` ratio at most 1.0 +- [ ] Capacity-stable stores and `drawIndexedIndirect` (`firstInstance` 0): a culling turn rebuilds nothing, `info.render` matches, and CPU falls on Chrome. proof: `native_engine_camera_cull`; `profile:wasm-page --cpu-work` ratio below 1.0 + +#### Phase v3.3: An opt-in render thread +**Status:** NOT STARTED +- [ ] `Renderer::render` reads only a `FrameSnapshot` (owned bones, morphs, instance data; deferred release on the game thread), and JS renderer settings pass through a command queue. proof: `native_engine_batched_vs_unbatched` pixel-identical, a bytes-copied counter +- [ ] `--render-thread` (frame-in-flight limit 1) matches one thread to the pixel and runs 600 frames with resize, device loss and surface loss free of races. proof: the ctest in both modes and under ThreadSanitizer + +#### Phase v3.4: v3 KPI and gate +**Status:** NOT STARTED +- [ ] Native R5 beats Godot desktop by the owner's margin. proof: `pnpm bench:scoreboard` `godotRatio` +- [ ] Frame CPU scales sub-linearly with object count. proof: `bench:engines --arms native-v8 --objects `, jobs against 1 worker +- [ ] Stability gate S with ThreadSanitizer is green. proof: the S commands + +## Cycle v4 — cull and load + +#### Phase v4.1: File the v4 work from the candidates +**Status:** NOT STARTED +TN today: HZB declined ([PRD-489](../done/PRD-489-gpu-scene-occlusion-culling.md), would-cull 0.013); distance cull, merged chunks and dynamic resolution in core only ([PRD-564](../Unreal/performance/PRD-564-the-resolution-scale-changes-without-reallocating-a-target.md)); mesh LOD and streaming in core plus engine CPU decisions (`renderer/lod/model_lod`, `world/cells`, `world/tiles`); terrain LOD in [PRD-566](../done/PRD-566-terrain-lod-morphs-on-the-gpu-and-picks-by-screen-error.md); async loading via `package_loads`; texture streaming only proposed ([PRD-VQ-10](../Unreal/performance/PRD-VQ-10-texture-mip-residency.md)); no frame allocator, target pool or async compute. +- [ ] Engine distance culling, LOD gaps, streaming and a frame allocator are measured on the scorecard and the ones that earn it get boxes here. proof: v1.9 scorecard rows for each candidate +- [ ] Bigger scenes hold the frame rate, and `ready` and first frame improve; S is green. proof: scorecard A/B against the v3 build, then the S commands + +## Cycle v5 — web worker + +The game side reads the DOM directly (23 core files read `document.` or `window.`; audio, pointer lock, gamepads, `localStorage`). A worker keeps frame p50; only Wasm threads can cut it. + +#### Phase v5.1: Worker mode +**Status:** NOT STARTED +- [ ] Core reaches the DOM only through one host-capability layer, and the starter plays through a loopback proxy. proof: a red-green core spec that scans `packages/core/src`; `pnpm test:templates` on the starter +- [ ] Behind a flag and without COOP/COEP, the game and engine run in a worker on an `OffscreenCanvas`, the harness captures it, and main-thread busy falls below 0.5x with p50 no worse. proof: `pnpm test:templates` with the flag; `profile:wasm-page` worker against main-thread; a judge subagent + +#### Phase v5.2: Wasm threads, v5 KPI and gate +**Status:** NOT STARTED +- [ ] A `TN_WEB_THREADS` build costs at most 2% on one thread, reports `threads: off` without isolation, and the job pool cuts frame CPU with it. proof: `profile:wasm-page` A/Bs; `native_engine_wasm_browser_backend` for both isolation states +- [ ] Stability gate S is green on web with and without isolation headers. proof: the S commands + +## Blocked on + +- The v3 margin over Godot is an owner number, set before v3 starts. +- Whether engine-only commits may skip `native-platforms` is an owner call (slice 0b). +- `AnimationMixer.updateRate` adds a non-three property; João confirms the name before v2.3. +- The worker HUD contract (state out once per frame) needs João's confirmation before v5.1. +- Android timing rows need the Pixel 8 attached; the emulator holds counter and memory rows. +- Safari rows need a macOS machine. + +## Owned elsewhere + +PRD-553 on F (JS-to-Wasm calls, upload bytes); +[PRD-519](../native-engine/PRD-519-n12-native-batching-visibility-lod-gpu-scene.md) (batching, LOD, GPU-scene kernel); +[PRD-570](../Unreal/animation/PRD-570-a-character-costs-what-the-camera-sees-of-it.md) (core animation rate); +[PRD-327](./critical/PRD-327-first-use-pipeline-compilation-leaves-the-main-loop.md), +[PRD-540](./critical/PRD-540-the-compile-walk-leaves-the-main-thread.md) and +[PRD-368](../assets/PRD-368-compiled-pipelines-survive-a-relaunch.md) (legacy pipelines); +[PRD-222](./critical/PRD-222-performance-targets-per-platform.md) (frame-rate floors); +[PRD-533](../native-engine/PRD-533-n20-platform-qualification-performance-default-promotion.md) (default promotion). diff --git a/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md b/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md new file mode 100644 index 0000000000..5fa1a44e08 --- /dev/null +++ b/docs/PRDs/performance/PRD-610-native-engine-wins-on-real-games.md @@ -0,0 +1,335 @@ +# PRD-610 — The native engine wins on real games + +**Status:** PARTIAL +**Priority:** P1 — the owner bar is 2x to 3x faster than three.js on Midway; the provisional matched ratio is 1.18x to 1.49x (AC-15 open) and the levers are open (AC-20, AC-16 to AC-19). +**Complexity:** 8 (HIGH) +**Owner:** João +**Depends on:** PRD-573 (the epic; this PRD closes its final-tier v1.10 box and rides PR #493) + +**Release (owner, 2026-10-15):** v1 ships with this PRD PARTIAL. Its open boxes stay open and none is ticked by the release. The bulk-reader experiment (`TN_JS_MATH_BULK_READ`) is reverted: instructions fell 0.77%, timing was inconclusive. + +**Current focus (re-plan 2, 2026-10-14):** finish AC-15 (three qualified pairs), then AC-20 first. The matched yardstick shows that the player runs about half of Chromium's instructions per frame at about 60% of its IPC. The stall gap is now the largest lever; the time-share levers (AC-16 to AC-18) follow on top of it. + +## Context + +On 2026-10-13 the owner played Midway (`midway-open-pacific`) on the native desktop player, on the Wasm engine in Chromium and on three.js in Chromium. The native player felt like about 20 FPS with a laggy HUD. Three.js ran at 51 FPS. Phase 1 removed the main cause: in free-run the player flushed a full core frame on every catch-up tick, up to five per drawn frame. + +From 2026-10-13 to 2026-10-14 a Codex lane of about 80 sub-agents worked the old AC-3 to AC-13. The owner warned that its last iterations moved in the wrong direction. Its full evidence log stays in git history (this file at `8cbef6f2a`). This re-plan keeps its results and drops its proxy targets. + +### Measured reference + +Package `pkg-ac3-calibration-20261014T135316Z-t`, runs `t` and `t2`, 1,000 active frames each, parity PASS, Xvfb: + +| Metric | Native desktop player | Three.js in Chromium | +|---|---|---| +| Main-thread CPU per frame | 7.05 ms (JS and the C++ it calls 4.93, render 1.78, other 0.33) | not measured | +| Main-thread instructions per frame | 18.15 M | not measured | +| Active wall per frame | bound by Xvfb presents | 10.65 ms, Vite dev server | +| HUD round trip p95 | 26 ms (target 16.7 ms) | in-page DOM | + +### The yardstick is not matched + +1. The three.js arm runs on the Vite dev server (`flight.sh`: `pnpm dev`), with the React development build and dev module transforms. The desktop player runs a production bundle. +2. The dashboard ratio divides browser active wall time by native main-thread CPU time. +3. The arms can run different engine commits. `install.sh` packs the browser arms at one time, and the player bundle is built at another. +4. Core's `FixedStepLoop` runs up to five updates per frame. On the wall clock, a slower arm does more simulation per frame. +5. The browser main thread also runs the React HUD. The native HUD runs in a WebKit process that took 2.3 times the player main thread's CPU samples. Main-thread CPU favors native, so process-tree CPU must sit beside it. +6. The player embeds V8 13.1.201.22 (late 2024). The Chromium under test runs a newer V8. + +A production build makes the three.js arm faster, so the matched ratio can be lower than 1.51x. + +### Matched baseline (provisional, 2026-10-14) + +One pair at engine `db22a6230`, private Xvfb, NVIDIA/Turing, lockstep windows of 1,000 frames with one update and one render each, parity PASS. Runs `ac15-db22a6230-{a1-legacy,b1-desktop}` under `~/.cache/tmp-tn/midway-runs/`. The flight post-check refused this pair because of a checker defect (see Decisions), so these numbers are not yet a qualified AC-15 row. + +| Pose | three.js main thread | Player main thread | CPU ratio | Instructions per frame (three.js / player) | IPC (three.js / player) | Process-tree CPU per frame (three.js / player) | +|---|---|---|---|---|---|---| +| Takeoff | 8.85 ms | 7.48 ms | 1.18x | 41.3 M / 20.5 M | 1.06 / 0.64 | 64.9 / 102.2 ms | +| Cockpit | 10.20 ms | 7.84 ms | 1.30x | 51.1 M / 22.7 M | 1.11 / 0.67 | 64.0 / 108.0 ms | +| Rear gunner | 14.58 ms | 9.82 ms | 1.49x | 77.2 M / 29.8 M | 1.18 / 0.70 | 69.9 / 105.3 ms | + +The player executes 2.0x to 2.6x fewer instructions per frame, but each instruction costs about 1.6x more cycles. At Chromium's IPC the same player work would take about 4.5, 4.8 and 5.9 ms (2.0x, 2.1x and 2.5x). Both arms ran at 4.3 to 4.5 GHz user-mode cycles per CPU-second, so frequency does not explain the gap. The player's process tree burns about 100 ms of CPU per frame, mostly in the two WebKit HUD processes, against 65 ms for the whole Chromium tree. + +Known IPC differences: the embedded V8 13.1.201.22 is built with `v8_enable_pointer_compression=false`, `is_official_build=false`, `use_thin_lto=false` and `chrome_pgo_phase=0` (`third_party/v8/gn-args_Linux.txt`); Chromium ships pointer compression, ThinLTO and PGO on a newer V8. The engine C++ is `-O3` Release. Cache and SMT interference from the HUD processes and the GPU thread is not measured yet. + +### Where the player's main thread goes + +V8 CPU profile of package T: 22,277 samples at 200 µs over 1,010 frames. JS-entered time is about 4.4 ms per frame, so 1% is about 0.044 ms. The independent mixer meter (14.1% of main-thread CPU, about 1.0 ms) agrees with the animation row. + +| Owner | Share | About ms per frame | Main items | +|---|---|---|---| +| Core calls into C++ | 38.1% | 1.68 | animation mixer 20.4%, matrix walk 10.1%, projected cull 5.6%, stride and foot plant 2.1% | +| Game JS | 19.1% | 0.84 | Midway code; two per-frame `filter` passes (radio queue, AI group) take 9.9% | +| Game calls into C++ | 9.6% | 0.42 | `updateDamageVisuals` 3.3%, `animateDouglas` 2.3%, three `update` methods 2.8% | +| Engine-owned JS | 12.9% | 0.57 | flight model 3.6%, HUD bridge 2.5%, frame loop 1.8%, audio pose sync 1.6%, player shim 1.5%, animation player 1.3%, playtest hooks 0.6% | +| Shim and harness calls into C++ | 5.2% | 0.23 | `traverse` 1.9%, audio panner and gain 1.0%, playtest observation 0.8%, `parent` 0.7%, uniform `set` 0.6%, attributes 0.3% | +| Root native and the rows below the top 60 | 15.1% | 0.67 | not attributed | + +### Can work leave JS for C++? + +Most JS-entered time is already C++. The core rows (38%) are C++ work that one JS call starts, so moving the caller into C++ saves only the call. That work must shrink or leave the main thread (AC-16, AC-17). + +Engine-owned JS and shim crossings take about 18% (about 0.8 ms). The candidates: + +| Piece | Share | Move | Box | +|---|---|---|---| +| Animation player step: fades, `#syncStride`, `footPlantSpeed`, one `mixer.update` per rig | 3.4% | into the C++ mixer, one call per frame for every player | AC-17 | +| Player shim: `traverse` iterator, `parent` getter, attribute `Proxy`, uniform `set` | 4.9% | keep `parent` and children on the JS side, return `traverse` lists in one call, cache attribute names per geometry version | AC-18 | +| Positional audio and listener pose sync | 2.5% | the native audio backend reads the node's world matrix | AC-18 | +| HUD bridge: `JSON.stringify` per publish, `assertSendable` | 2.5% | validate a message shape once, skip unchanged state | AC-18 | +| Flight model `core/src/flight.ts` | 3.6% | stays JS (see Decisions) | — | +| Frame loop; playtest hooks | 1.8%; 1.4% | stay; both arms pay the playtest hooks | — | + +Game JS (about 1.3 ms with its C++ calls) cannot move without game edits. Two engine levers reach it: a newer embedded V8, and cheaper C++ behind `updateDamageVisuals` and `animateDouglas`. + +### Tried and rejected + +Do not retry these without a new reason. Each result is the A/B against its baseline. + +| Experiment | Result | +|---|---| +| Shared reflection record cache (two tries) | +2.8% instructions | +| Animation on a worker with per-track jobs (old AC-12) | +78% instructions, +136% CPU, 39,144 jobs per window | +| World-dependency revision | +0.27% | +| Squared-threshold cull | +0.15% | +| Empty-subtree cull skip | +31.8% cull CPU | +| Subtree and manual-root prototypes (O, P) | +2.2% to +2.3% | +| Join batch (N) | +0.72% | +| Cap support (U) | +2.6% | +| Bulk reader | −0.77% instructions, timing inconclusive | +| Copy elision | −0.32%, synthetic only | +| Shared keyframe seek | not repeatable | +| ABI `holdsObject` (EXP3.2) | 0% | +| Lazy pose | stopped: mutable track spans and aliases | +| FIFO against Immediate present | neutral | +| View-argument handles (old AC-11) | neutral at about 1,074 calls per frame | +| WPE HUD backend | p95 32 to 41 ms | +| HUD state snapshot | p50/p95 23/29 ms against 16/27 ms | +| CDP endpoint retries (old AC-6) | rAF crosses the endpoints | +| V8 GC prologue `promote()` skips the full wrapper walk while the ownership epoch is unmoved (2026-10-14) | Flat on the second off/on pair; instructions unchanged. Counter run: 249 GC prologues in 8,400 frames (one per 34 frames) over 26,447 wrappers, so about 0.04 ms per frame. The call-graph profile's 4.9% came from load-time GCs inside its window | +| Crossing path reuses decoded numeric and handle arguments (2026-10-14) | Projected 0.04 to 0.07 ms per frame; A/B flat. Measured 1,725 crossings per frame: `visible` 315, `count` 152, `getX`/`getY`/`getZ` 327, `time` 109, `setEffectiveWeight` 104 | + +Kept: one frame per present (AC-1); the GPU thread for submit, blit, acquire and present (main-thread CPU −19%, every B beat every A); Stack 4 (−9.3% instructions); the T calibration (18.15 M instructions per frame); reflection walk reuse (−3.7%). + +Lessons: single-run millisecond verdicts on a loaded host misled. Small render trials moved the total by less than the noise. Fine-grained threading cost more than it saved. The profile is flat, so only structural levers can reach 2x. + +## Solution + +Goal: on matched work, the player's main thread uses at most half the CPU per frame of the three.js renderer main thread (2x). The design budget aims at 3x. + +1. **One honest yardstick (Phase 2).** Production builds of one engine commit. A lockstep window: one update and one render per frame on both arms. Per-thread CPU and instruction counters for each arm's main thread, with process-tree CPU beside them. +2. **Structural levers, largest first (Phase 3).** Render prepare and encode leave the main thread. Animation leaves the main critical path. Engine-owned JS moves into C++ or shrinks. + +```mermaid +sequenceDiagram + participant Main as Main thread (V8) + participant Jobs as Animation jobs (C++) + participant Render as Render thread (C++) + participant GPU as GPU thread + Main->>Main: game update N (JS) + Main->>Jobs: draw-only rigs, frame N + Main->>Main: game-read tracks, matrix walk N + Main->>Render: frame N snapshot + Jobs-->>Render: skinning palettes N + Render->>Render: cull, project, encode N + Render->>GPU: submit and present N + Main->>Main: game update N+1 (JS) +``` + +Budget from 7.05 ms. The three.js denominator moves after AC-14, so AC-15 re-baselines it. + +| Lever | Main-thread cut | Main thread after | +|---|---|---| +| Render thread: cull, project, encode (AC-16) | −1.3 to −1.7 ms | 5.4 to 5.8 ms | +| Animation off the critical path (AC-17) | about −0.65 ms (1.05 to 0.4) | 4.7 to 5.1 ms | +| Engine JS into C++ or smaller (AC-18) | about −0.25 ms (0.45 to 0.2) | 4.5 to 4.9 ms | + +That is 2.2x to 2.4x against 10.65 ms. 3x (3.55 ms or less) needs about 1 ms more: a flat matrix walk (0.44 ms today), a newer V8 for game JS, cheaper game-called C++, and the 15% of samples not yet attributed. A smaller three.js denominator after AC-14 raises the bar for both targets. + +Risks: + +- A render thread adds a pipeline stage. Keep one frame in flight in total, and measure input latency against today. +- A game read of an animated bone before the job join must see frame N. Tracks the game reads (2.0% of evaluated tracks) evaluate on the main thread before the read. +- Each native fast path keeps the TS path for the web, as `__tnUpdateMatrixWorld` does today. A spec or conformance case proves that both paths give the same result. +- Update-rate optimization (URO) changes what a hidden rig shows when it becomes visible. It needs the PRD-573 v2.3 safeguards and João's name for `AnimationMixer.updateRate`. + +## Acceptance Criteria + +The boxes are under the execution phases. Phase 1 holds AC-1 and AC-2 (done). Phase 2 holds AC-14 and AC-15. Phase 3 holds AC-20, AC-16, AC-17, AC-18, AC-5 and AC-19. AC-3, AC-4 and AC-6 to AC-13 are retired (see Decisions). New IDs start at AC-14, so dashboard run names stay unique. + +## Blocked on + +- João plays Midway on the native desktop player on the real display (`:0`) and confirms it feels at least as smooth as three.js in the browser — unblocked by João. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +|---|---|---|---| +| One frame per present | `tn-native-engine-player-v8` free-run loop: `run.cpp` → `core-host.mjs` `flushFrames` | per-tick flush in free-run removed; paced path kept | AC-1 | +| Matched builds | `scripts/midway-bench/flight.sh` browser arms | `pnpm dev` server replaced by a production build; a dev server or an engine-commit mismatch is refused | AC-14 | +| Matched counters | `flight.sh` → `kpis.ts` → `dashboard.ts` headline | wall-against-CPU ratio removed | AC-15 | +| Render thread | `run.cpp` frame → render thread → GPU thread | `database.render` leaves the main thread | AC-16 | +| Animation off the critical path | `AnimationPlayer.update` → C++ mixer step and jobs | per-rig `mixer.update` calls and JS stride on native | AC-17 | +| Engine JS in C++ | `three-native/src/audio.ts`, `core/src/ui-state.ts`, `core/src/ui-bridge.ts`, player shim `core-three.mjs`, `three-native/src/object-surface.ts` | JS pose sync, per-publish validation, per-read crossings | AC-18 | +| HUD under one frame | WebKit overlay snapshot mailbox → frame blend | — | AC-5 | +| Final-tier result | `pnpm perf:live` hero | R5 hero becomes the cheap-tier row | AC-19 | + +## Decisions + +- 2026-10-15 (AC-20 thread placement — opt-in for v1): no placement policy passed the campaign keep rule (E1 top-ranked core REJECT, E3 L3-domain split REJECT, E2 last-ranked core pair INCONCLUSIVE: 2 of 3 valid pairs, both faster at every pose, margins 14 / 29 / 33% on a host with other lanes at load 6 to 33). v1 ships `TN_PLAYER_MAIN_CORE` unset = off. Evidence: `PERFORMANCE-LEARNINGS.md` 2026-10-15 entries. + +- 2026-10-15 (metric definition): the headline is `median(legacyCPU_i / candidateCPU_i)` over matched pairs, while the detail table independently reports each arm's median CPU, so dividing those medians can legitimately differ; the current eligible cohort is `e3271a19c`, desktop provisional and web collection-qualified with root execution review still pending. + +- 2026-10-15 (Wasm symbol provenance — NO-GO): served 4199 Wasm SHA `8e163ed1cbac94f3c9e37b041450f0e69998fa58de86ef35825c0db3e515564b` has no name/custom sections; original profiles retain anonymous function indices. Existing Ninja link plus `--emit-symbol-map` completed in scratch in 10 seconds, exit 0. Served code SHA `0f9141feab6e6347cf210f6f01f7ce923f01f010ae4c09b2e6dd1dafc4002ffb` differs from relink `c953e430bb038c56c3a7c630bbb2fcb7dc6373e2106c8d0f009e4b15ff780d81`; function-index identity is rejected. Receipt: `~/.cache/tmp-tn/ac19-4199-symbol-link/symbol-provenance.json`. No acquisition or acceptance; native 2x/Wasm 1.5x remain unmet. Next: resolve exact symbol provenance; original stop 12:41Z, no further jobs. Distinct unchanged-link recovery reproduced frozen full SHA `8e163…` byte-for-byte in 9 seconds. Matching intermediates are stripped; named intermediates have different indices. Recovery parked after two methods. Proof: `~/.cache/tmp-tn/ac19-4199-retained-link/symbol-provenance.json`. Future cohorts must retain final optimized function maps at build time. + +- 2026-10-15 (next experiment, design only): dirty-subtree transform skipping is NO-GO until mutation admission is proven: the local dirty bit does not propagate to ancestors, so a clean parent cannot establish clean descendants. Escaped/untracked transforms and retained raw aliases require conservative eager ancestor traversal. Owned seams would be `scene/object3d.{h,cpp}`, `animation/property_binding.cpp` (already calls `markLocalTransformDirty`) and the existing V8 transform-binding seam; establish complete setter/alias/animation invalidation before admitting skips. Preserve synchronous world queries, forced updates, manual matrices, hierarchy changes, deferred Bones, virtual/after-update callbacks and reflection-camera behavior. Nearest scene, animation and reflection fixtures must prove changed descendant state remains current; promotion requires three qualified alternating CPU pairs at every pose with no instruction regression, plus a fresh independent NEUTRAL-or-better visual judgment of two BEFORE/two AFTER captures per pose. The measured scene-owner exposure is approximately 20–25% of main CPU, not savings, and cannot alone promise 2x; broader mutation interception means reject this bounded trial. No implementation or acceptance box is completed. + +- 2026-10-15 (animation direct-buffer trial): REJECT the four-line Float64 quaternion result-buffer optimization. Takeoff / cockpit / rear gunner instructions A→B are 20.557616→20.564595 / 22.826892→22.771194 / 30.068881→30.066894 M/frame (+0.03395% / −0.24400% / −0.00661%): tiny, mixed direction, failing the declared all-pose instruction gate. Both arms pass 3/3 assertions on NVIDIA with three exact 1,000-update/render windows. Raw main-thread CPU is 12.399665→9.115791 / 14.073602→7.868920 / 16.554421→10.094087 ms/frame, but CPU is INCONCLUSIVE: window loads A 15.02/17.38/15.71 and B 16.47/13.57/8.30 violate the load guard. Raw runs remain in `~/.cache/tmp-tn/screens/interp-direct-{base,on}-1/`; the dashboard now contains 191 runs; both trial rows remain excluded from qualification. The exact production/test patch is preserved as original scratchpad `interpolant-direct-rejected.patch`; frozen baseline/candidate binaries and their separate host/game receipts remain intact. Only the two owned interpolation files were restored; baseline player rebuild and nearest interpolant/mixer ctests pass 2/2. Before restoration, 25 pinned reference cases (3,763 values) and 45 exact Float64 output/alias evaluations passed. No second pair, runtime visual acceptance or speed claim; AC-17/19/20 remain open. + +- 2026-10-15 (Vector3.x V8 Fast API wrapper — REJECT readiness): exact V8 13.1.201.22 source supports pending-exception propagation and permits allocations by default, but CFunction registration requires a nonconstructible function. Preserve the existing constructible public setter: its characterization passes. A constructible JS wrapper with captured intrinsics preserves the tested descriptor/name/length/prototype, construction/new.target, all-argument forwarding, borrowing, type/error/handle validation, NaN/signed-zero and GC behavior; visible JS source was explicitly accepted. However, the required optimized normal numeric window reports fast0/slow1 both with dynamic argument forwarding and after the distinct constant-arity numeric branch retry. The optimized invalid-handle check does reach CFunction; this does not establish reliable normal dispatch or a runtime win. Stop without further retries; restore owned adapter source/header and prototype tests, retaining only constructor characterization. Rebuilt restored player/test targets and focused test PASS 1/1. Trial patch `/tmp/prd610-fast-wrapper-constant-trial.patch`; logs `/tmp/prd610-fast-wrapper-{red,focused-green,reopt-tests,constant-tests,restored-tests}.log`. No timing, integration or speed/parity claim; AC boxes stay open. + +- 2026-10-15 compose rollback complete: restored only `object3d.h/.cpp` and `scene_hierarchy_test.cpp` from `1766f64ac^`; trial source/results preserved on local `perf/prd610-compose-trial` at `09a05e079`. Rebuilt player/test targets; 14 scene + 2 callback/reflection-reuse + 27 V8/player checks PASS, compile-time node-size check returns 440 bytes. Logs `/tmp/prd610-compose-restored-*`; no GPU acquisition or new performance/parity claim. + +- 2026-10-15 (compose-cache performance candidate — NO-GO): stop after one pair; no second timing pair or promotion. CPU is INCONCLUSIVE under the predeclared load guard: baseline windows all exceed load1 ≤ 12. The exploratory instruction reduction of only 0.13–0.51% is insufficient to justify +240 bytes per Object3D (440 → 680, +54.5%). Neither the raw CPU improvement nor load/IPC differences establishes a causal performance explanation. Preserve trial source/results on local `perf/prd610-compose-trial` before reverting only the cache implementation; AC boxes remain open. + +| Compose pair 1 pose | CPU ms/frame A → B (guard failed) | Instructions M/frame A → B | Instruction change | Cycles M/frame A → B | IPC A → B | Host load1 A → B | +| --- | --- | --- | --- | --- | --- | --- | +| takeoff | 12.500 → 7.895 | 20.470 → 20.442 | −0.13% | 50.020 → 31.749 | 0.409 → 0.644 | 15.44 → 5.79 | +| cockpit | 12.476 → 8.836 | 22.807 → 22.724 | −0.36% | 50.873 → 35.878 | 0.448 → 0.633 | 18.44 → 5.77 | +| rear | 14.545 → 10.897 | 30.202 → 30.049 | −0.51% | 59.882 → 44.520 | 0.504 → 0.675 | 16.00 → 6.11 | + +Both wrappers exit 0, actual reports PASS 3/3, 4,398 reported frames, fixed-step, NVIDIA RTX 2080/turing 615.78.08; all governors performance. All six windows contain exactly 1,000 frames/updates/renders and tick difference; corresponding ranges match 1490–2490 / 2492–3492 / 3494–4494. Admission load1 passes 7.11 → 7.68 for baseline and 7.77 → 6.26 for candidate, each 60 seconds apart; later measured load invalidates CPU interpretation as declared. No capture-manager bypass. Tree CPU A → B: 133.98 → 107.76 / 133.69 → 115.38 / 142.64 → 116.36 ms/frame. Raw sources `~/.cache/tmp-tn/screens/compose-load-{base,on}-1/run.log`, original PNGs under `art/`; excluded dashboard rows, API 184 at extraction. Frozen binary SHA256 matches A `219021b7a564b0c58288d59c13b620c6332a43bec66d8f0859343ca0a7c8495d`, B `c2aad5f85f4f299a6bd6f9bbb0c5e9dceed9ef9904192679d0ebd415a9b826ef`; same nine nonbinary files. No qualified parity or visual-judge claim. No existing compose cycle was found in `cycles.jsonl`; do not invent a duplicate ledger entry. + +- 2026-10-15 (compose-cache acquisition admission re-plan, before data): original `compose-gated` watch exited 75/PARKED after 20 observations over 19 minutes, with a live capture holder throughout; neither arm launched and no timing row was generated. Requiring an empty capture queue before joining its serializer can starve this lane. Revised one-shot `compose-load-{base,on}-1` admission requires two load1 ≤ 8 observations 60 seconds apart, then joins the unchanged capture manager even when occupied. No bypass or other-owner interruption. Maximum CPU watch 20 minutes per arm and 50 minutes overall including queue/execution; reserve at least four execution minutes and cap capture wait at the remaining budget, at most 900000 ms. CPU interpretation requires every actual window load1 ≤ 12, performance governors and exact 1000 frame/update/render counts; otherwise CPU is INCONCLUSIVE and instructions reported separately. No second pair without a decision, no equivalent retry after a parked gate/timeout/budget stop. + +- 2026-10-15 (AC-20 GCC IPO — INCONCLUSIVE CPU): corrected same-source non-IPO and IPO arms (`18839e526`) completed one exploratory pair. Generator ODR repair (`static inline` lookups) is KEEP for correctness independently of performance; SDL remains non-LTO for its existing objcopy rewrite. Standard CMake IPO stays optional, not enabled by default. All three instruction counts improve, but CPU worsens in two poses; no second pair, integration or qualified speed/parity claim. + +| IPO pair 1 pose | CPU ms/frame A → B | CPU change | Instructions M/frame A → B | Instruction change | Cycles M/frame A → B | IPC A → B | Host load1 A → B | +| --- | --- | --- | --- | --- | --- | --- | --- | +| takeoff | 9.712 → 13.660 | +40.65% | 20.435 → 19.648 | −3.85% | 38.444 → 54.006 | 0.532 → 0.364 | 8.00 → 10.70 | +| cockpit | 11.108 → 14.020 | +26.22% | 22.731 → 21.952 | −3.43% | 44.210 → 55.455 | 0.514 → 0.396 | 7.18 → 23.61 | +| rear | 13.594 → 12.445 | −8.45% | 29.990 → 29.226 | −2.55% | 54.244 → 49.597 | 0.553 → 0.589 | 7.90 → 24.98 | + +Both wrappers exit 0, actual reports PASS 3/3 with 4,398 reported frames, fixed-step, NVIDIA RTX 2080/turing (615.78.08), all recorded governors performance. Every window has exactly 1,000 frames/updates/renders and tick difference. Attachment leaves a one-tick arm offset: baseline starts 1490/2492/3494; candidate 1489/2491/3493. Process-tree CPU A → B is 101.73 → 134.37 / 105.87 → 124.06 / 113.36 → 107.52 ms/frame. Higher load and changed IPC confound CPU attribution; no causal contention explanation is established. The every-candidate-below-every-baseline CPU requirement already fails; instruction reductions are exploratory, not qualified. Explicit `TN_MATERIAL_DIAGNOSTICS=0 TN_RENDER_THREAD=0`; game/assets/UI unchanged. Raw reports: `~/.cache/tmp-tn/screens/ipo-fixed-{base,on}-1/run.log`, excluded dashboard publication; API 182 runs at extraction. Build identity: `packages/runtime-native/build/tn-engine-ui-ipo/trial-fixed-identity.json`; failed original candidate/logs preserved. AC-20 and other existing open boxes remain open. + +- 2026-10-15: Web visibility-cache recovery completed two exploratory pairs: four PASS reports on NVIDIA/turing with exact 1,000-frame/update/render windows. Every candidate beats every baseline on instructions at all poses; CPU fails that rule at all poses. Pair 2 candidate has higher host load and lower IPC, leaving CPU attribution unresolved. Dashboard: 179 runs. Cycle closed INCONCLUSIVE; no third retry, integration or qualified parity claim. Detailed table: PRD-612, local result commit `0b4b321ba`. + +- 2026-10-15 (AC-20 pointer-compression prototype): a one-line CMake cache path permits an isolated desktop V8 dependency root; shipped dependencies/defaults remain unchanged. The same V8 13.1.201.22 monolith uses pointer compression, shared cage and 31-bit Smi definitions with matching headers, sandbox off. Isolated Release/GCC/Ninja 600-step build, 27 focused adapter/player import/GC checks and startup smoke pass. An initial broad ctest filter also selected four unbuilt fixtures; that failed attempt is retained, not counted green. Candidate binary SHA256 `e4b637dd4966975ff00e6576a4c2e40579525687a05d12a8096924466ed97491`; staged `~/.cache/tmp-tn/player-v8pc` keeps the baseline game/assets/UI unchanged. Build/test/identity logs are in `packages/runtime-native/build/tn-engine-ui-v8pc/`. No timed A/B, valid packaging receipt, parity or speed qualification yet; AC-20 remains open. + +- 2026-10-15 (owner requested visible progress): ingested five completed raw screens into the existing dashboard with explicit timing-exclusion markers; their logs remain byte-identical. The page now shows the newest five complete exact-window acquisitions above historical ratio headlines, including acquisition completion time, CPU/instructions by pose and unqualified status; sampled rows display diagnostics without speed numbers. Live API and browser show 172 runs, recent rows and unchanged headline selection; dashboard suite 147/147, Biome and diff checks pass, with no browser console errors. This displays current evidence without promoting exploratory screens or ticking performance ACs. + +- 2026-10-15 (steady owner attribution before AC-20 trial): `perf-rt-jit-off-1` passes three runtime assertions and all three exact 1,000-update/render windows with render thread off and `TN_V8_FLAGS=--perf-basic-prof`. The player map is archived beside the recordings. Self-cycle symbol-prefix grouping: engine/host C++ 61.02 / 64.19 / 67.23%, V8 runtime 19.57 / 15.99 / 13.78%, V8 JIT 10.43 / 10.02 / 8.91%, unresolved 1.13 / 1.80 / 1.32% (takeoff / cockpit / rear gunner); zero lost samples. Groups are heuristic because engine and V8 runtime share a monolith. Most cycles remain in engine/host C++; pointer compression is a bounded candidate, not evidence that the whole IPC gap belongs to V8. Diagnostic windows remain excluded from qualification. Raw profiles/map/reports: `~/.cache/tmp-tn/screens/perf-rt-jit-off-1/profiles/`. + +- 2026-10-15 (exact-window stack sampling): the existing Linux CPU-window collector accepts `TN_PLAYTEST_CPU_PROFILE_DIR`, starts `perf record` disabled on the owned main TID with a separate FIFO/ACK pair, enables only around the existing 1,000-tick advance, disables independently on failure, and reaps both children. Existing/empty output fails closed. Sampled windows carry `profile`; the dashboard excludes both their CPU windows and the entire run from qualification. Focused red/green is eight new-feature failures followed by 179 passing tests; playtest typecheck, build/declarations/publint, changed-file Biome (17 warnings), diff checks and independent review pass. Real diagnostic `perf-rt-exact-off-1` passes three assertions and all three exact 1,000-update/render windows, with 1,769 / 2,106 / 2,847 samples, zero lost samples and decoded callchains. Current-source qualification also rejects that real report under a non-`perf-` tag. `RenderDatabase::project` contributes 6.10% / 5.41% / 6.06% self cycles; unresolved shares remain 7.11% / 7.96% / 9.25%. This first validation did not enable V8 JIT maps, so unresolved samples are not assigned to JS. Raw recordings/reports are under `~/.cache/tmp-tn/screens/perf-rt-exact-off-1/profiles/`; `--no-inline` avoids expensive inline expansion during decode. AC-15/AC-20 remain open: this is an instrument, not matched acceptance pairs, complete owner attribution or a speed improvement. + +- 2026-10-14 (lead, re-plan 2; João: "1.5 to 2x seems close enough ... pretty sure we can beat 3x in a week"): the provisional matched ratio is 1.18x, 1.30x and 1.49x. The Solution budget (−2.2 to −2.6 ms from 7.05 ms) reaches about 1.7x to 1.8x at takeoff against the matched 8.85 ms, so it cannot reach 2x alone. The matched counters show a larger lever: the player runs half of Chromium's instructions at about 60% of its IPC. AC-20 (IPC 1.0 or more) comes before AC-16. With AC-20 at Chromium's IPC (about 4.5 ms at takeoff) and the Phase 3 levers on top, 3x (2.95 ms or less at takeoff) is the design target. Two checker defects were fixed on the way: the player drew one automatic frame after each mailbox poll inside the window (`d790e5cfb`, the runner now holds automatic renders), and the window ticks came from the game clock, which led the host's driven tick by 60 (`e3271a19c`, the host now brackets each window with `TN_PLAYER_RENDER_HOLD` markers and the checker reconciles frames per bracket). A debug repro confirmed one held `advance(1000)` per window with no draw inside. +- 2026-10-14 (AC-15 collector repair, incomplete): the first matched flight rejected multiple Chromium renderers. Page-target tracing now maps the actual root frame to its renderer PID; browser-target trace metadata was empty. The subsequent actual probe exposed an incorrect `/proc/comm == CrRendererMain` requirement: Chromium deliberately keeps the Linux leader process name, while RendererMain runs on that leader ([upstream implementation](https://chromium.googlesource.com/chromium/src/+/main/base/threading/platform_thread_linux.cc)). The collector now uses the verified renderer leader TID and retains process-tree/start-time identity checks. A fresh actual Chromium 151 private-Xvfb probe passes; an independent 1.5-second JS loop adds 1.46 CPU-seconds to that renderer and zero to the spare. Parent focused suites pass 193/193, playtest typecheck passes, and changed-file Biome passes with 14 warnings. Window timestamps and a conserved source-map attribution helper are present; actual calibrated Midway owner rows, matched pairs and parity are still unverified. AC-15 remains open. + +- 2026-10-14 (dashboard UI): Sonnet 5.5 High reproduced horizontal page overflow from the wide matched-counter table and intrinsic grid sizing. Reused the existing table scroll container and shrinkable grid tracks; all 135 focused dashboard tests pass. Actual browser checks pass on ports 8765 and 8767 at 1600×900 and 900×900, with clean consoles and pending metrics preserved. Fresh read-only visual judge: IMPROVEMENT at both widths. AC-15 remains open; no speed result is claimed. On 2026-10-15, a fresh nonauthor judge returned IMPROVEMENT for two BEFORE/two AFTER headed NVIDIA/Turing captures at 1440×1000, with no overflow or console errors (`~/.cache/tmp-tn/ac19-dashboard-judge-4bc99e2f2`); new completion diagnostics remain unqualified and CPU headline scores unchanged. + +- 2026-10-14 (verification): stable `pnpm typecheck` passes after two behavior-preserving dashboard typing fixes. Two introduced regex lint errors are fixed; changed-file lint passes with warnings, and the final focused Midway/bundler suites pass 120 tests. The earlier full `pnpm lint` reported 213 errors and 1,492 warnings; remaining global diagnostics are not fully attributed. The unit run was interrupted (exit 130) after two `browser-math.spec.ts` failures. Those assertions still required the old eighth-per-frame GC sweep; commit `9ac452167` explicitly shipped fresh-wrapper checks plus a sixty-fourth-per-frame settled sweep. Only the two stale tests were corrected, retaining immediate fresh checks, bounded settled coverage, dropped-wrapper exclusion and release within two passes; the focused math/collect suites pass 20/20 (2026-10-14). Neither full lint nor the full test suite is claimed green. + +- 2026-10-14 (AC-15 instrumentation, incomplete): Linux collection brackets exact 1,000-update/render windows with FIFO-controlled thread perf counters, schedstat runtime, process-tree CPU, identity checks, load and governors. A real perf plumbing smoke passed after handling this host's NUL-terminated ACK and intentional SIGINT stop; it is not a game benchmark. Exact start/end ticks reconcile native completed host-render records. The parent rerun passes 151 collector/dashboard tests; the rebuilt player passes all 8 profile tests, including both CLI forms, write failures and SIGTERM output. Updated dashboard preview `/api/midway` on port 8767 shows zero qualified pairs and pending ratios, retaining historical runs. Actual matched Midway pairs, parity, visual review and profile attribution remain unrun; AC-15 stays open. Initial repack stopped at the TypeScript 6 declaration builder: tick guards did not narrow through the inferred never-returning arrow. An explicit return from that failing branch fixes the declaration contract; focused playtest build, DTS and publint pass. No packs were accepted from the failed preparation. + +- 2026-10-14 (AC-15 foundation, incomplete): browser bridge driving now places one successful scene update and primary world render inside each measured frame, suppressing queued automatic draws after startup; direct simulation-only driving and native host-owned rendering remain explicit. Parent rerun of source-focused loop/playtest/resource suites passes 68/68. The first native fixture runs launched the older `build/tn-engine` player and failed cull/tick assertions; the already rebuilt `build/tn-engine-ui` player passes the 2,500-driven-frame tick check and the free-run fixture (36 frames, one callback per draw, two projected-cull and mixer calls per frame, CPU instruction records). This is fixture proof, not a repackaged Midway CPU window or visual verdict. OS-counter collection, dashboard ingestion and paired qualification remain open. + +- 2026-10-14 (execution): AC-15 discovery found that browser `fixedStep` advances simulation separately from rendering, while the native driven-clock contract already checks one host update/render per tick. Couple browser updates and renders inside the measured frame scope before collecting 1,000-frame pose windows; advancing before `stepFrame` would omit simulation from its budget. Suppress duplicate automatic draws only after startup readiness. Thread-attached `perf` instruction probes succeeded on this host. No lever verdict has been made. + +- 2026-10-13 (João): benchmarks have two tiers. Cheap benchmarks (the R-ladder) iterate, and final benchmarks (Midway, then other real games) decide. This PRD's speed ACs are final-tier only. +- 2026-10-13 (lead): out of scope here, for a follow-up PRD if they still matter after AC-6: + - the 2D canvas `globalCompositeOperation` (Midway's wear scratches paint dark specks) + - a same-frame press and release on HUD buttons + - the `captureMouse` relative-motion proof + - a boot warning listing absent web APIs + - placard text metrics + - the playtest runner failing to stop the player (exit 2 after its screenshots) + - the `--memory` control fix in `profile:wasm-page` + - Wasm memory of 1.9 GiB + - web startup of 40 s against 17 s + - ne-compat's matrix of other games +- 2026-10-14 (lead, re-plan at João's request: "you have to edit the PRD 610, i'll re execute it"; goal: "a path for 2-3x native speed compared to web threejs"): + - Phase 2 fixes the yardstick before any lever is judged, because the old ratio mixes instruments and builds. The 2026-10-13 out-of-scope list now waits for AC-19, not AC-6. + - Retired unticked boxes, with their evidence in git history at `8cbef6f2a`. AC-3 (1,500 visits) and AC-4 (cull below 2%) were proxy targets, and AC-16 moves the cull off the main thread. AC-10, AC-11 and AC-12 were tried (see Context). AC-6 and AC-13 become AC-19 on the matched yardstick, and AC-8 folds into AC-15 and AC-19. AC-7 (the web Wasm engine against three.js) leaves this PRD: PRD-573 v1.10 does not need it, and a follow-up PRD owns it. + - AC-9 landed (GPU thread, −19% main-thread CPU). Its open byte-identity proof moves to the fixed-step capture pair of AC-16. + - AC-5 stays, because PRD-573 v1.10 needs the HUD round trip under one frame. + - AC-17 is not a retry of the rejected per-track worker. It cuts work first (URO, unread tracks), then uses per-rig jobs for draw-only tracks. + - The flight model (`core/src/flight.ts`, about 0.16 ms) stays JS. It is numeric code that V8 compiles well, and a C++ copy doubles the implementation for 0.1 ms or less. + - Moves land in the existing C++ objects (mixer, audio backend, scene). A new language adds a call boundary, and the boundary is the cost being cut. + - Measurement ladder: tier 0 is a ctest counter. Tier 1 is one lockstep window, instructions per frame. Tier 2 is three alternating pairs per pose, parity PASS, and the visual judge for any lever that can change pixels. Keep a lever only when every B beats every A. Make no single-run millisecond verdict on a loaded host. +- 2026-10-14 (owner: "my goal is native 2-3x faster than web threejs. WASM ThreeNative 1.5 faster than ThreeJS (regular)"; "we need to optimize both native and wasm web in parallel"): every matched pair also runs the web Wasm arm (C, graded on the same A and ref), and the dashboard shows both arms. [PRD-612](../native-engine/PRD-612-web-wasm-engine-beats-threejs-on-midway.md) owns the web Wasm 1.5x target. Pair 1 at `e3271a19c` (both arms parity PASS) reads native 1.24x / 1.05x / 1.31x and web Wasm 1.02x / 0.95x / 1.26x; it is one pair of three, so AC-15 stays open. +- 2026-10-14 (owner: "we need conventions, keep godot naming style"): URO ships as a convention, on by default, with its override on the same object in Godot vocabulary, camelCase: `AnimationMixer.updateRate` (Godot style `update_rate`). This removes the naming blocker for AC-17. + +## Execution Phases + +#### Phase 1: One frame per present +**Status:** DONE +**Files:** `packages/runtime-native/src/engine/player/core-host.mjs`, `packages/runtime-native/src/engine/player/run.cpp`, the player metrics emitter, a new `tests/native-engine/player-*.mjs` ctest. +**Implementation:** gate `flushFrames` on present in free-run. Keep fixed-step simulation ticks if gameplay needs them. Log the frame budget every 300 frames. +**Verification:** the AC-1 ctest red then green. The existing paced, real-input and overlay player ctests stay green. A Midway CPU-per-frame before/after on Xvfb (the prelude A/B gave 90.5 to 39.4 ms) covers AC-2. + +- [x] AC-1 [local]: in free-run with a slow tick, core rAF, the matrix walk and the cull each run exactly once per presented frame. proof: player ctest (`tests/native-engine/`, red at 5 per frame before the fix) — Evidence: ctest `native_engine_player_frame_per_present` (25 ms frame callback, free-run): red on the old `core-host.mjs` at `…,1,1,2,3,4,5,5,5,5,5` callbacks per drawn frame, green at 1 per frame over 36 frames. The matrix walk and the cull run inside core's frame callback, so they follow it. `ctest -L native-engine -R player` 21/21 pass (2026-10-13). +- [x] AC-2 [local]: the player logs `TN_FRAME_BUDGET` every 300 frames during free-run flight. proof: Midway `flight.sh desktop` log with at least 3 in-flight budget lines — Evidence: 2026-10-13, Xvfb. The AC-1 player's `flight.sh desktop` run logged 24 `TN_FRAME_BUDGET` windows, 8 of them in flight (windows 17 to 24), with parity PASS. A 120 s free run with no runner logged 27 budget lines over 2,065 frames. + +#### Phase 2: One honest yardstick +**Status:** IN PROGRESS +**Files:** `scripts/midway-bench/{flight.sh,install.sh,kpis.ts,dashboard.ts,README.md}`, `scripts/__tests__/midway-*.spec.ts`. +**Implementation:** + +- Browser arms build with the game's `build:web` script and serve the output statically. The harness refuses a page that loads `/@vite/client`. +- Each arm records the engine commit it runs: the pack manifest for a browser arm, the bundle for the player. The harness refuses a mismatch. +- Timing windows use the existing fixed-step clock (`MIDWAY_BROWSER_CLOCK=fixed-step`, and runner-driven frames on the player). Each frame then runs one update and one render on both arms. The harness counts both per frame and fails closed on any other count. +- For each window the harness reads the main thread's CPU time from `/proc//task//schedstat` (Chromium: the renderer's `CrRendererMain`; the player: its main thread). It reads instructions with `perf stat -t`, and fails closed without `perf`. It records process-tree CPU, host load and the CPU governor beside them. +- Use the real display when João grants it for the session. Otherwise use Xvfb and report CPU and instructions only, because Xvfb throttles presents. + +**Verification:** the AC-14 spec and the AC-15 pairs. + +- [x] AC-14 [local]: both Midway browser arms time a production build of the same engine commit as the desktop player, and the harness refuses a dev server or a commit mismatch. proof: a `scripts/__tests__/midway-*.spec.ts` case, red on a page that loads `/@vite/client` and on mismatched commits, plus one legacy `flight.sh` run log that names its build and commit — Evidence (2026-10-14): `ac14-proof-a4c99a39b-legacy/run.log` under `/home/joao/.cache/tmp-tn/prd610-matched-runs/`: production `build:web`, static preview, runner PASS (4 assertions, 1,938 frames), 652 observed requests with no `/@vite/client`; WebGPU adapter NVIDIA/Turing on private Xvfb. Live receipt/hash verification passed for both installs, packed tarballs, desktop bundle/player and legacy web output at `a4c99a39b2b7db7d00d34bc5c5d8adaeec6abe00`. Latest Midway suite: 111 pass, including refusal and strict-exports packing regressions; installed bundler compatibility and acquisition fixture also pass. Fixed-step is not yet frame-coupled: no performance claim (AC-15). +- [ ] AC-15 [local]: the dashboard headline is the main-thread CPU ratio (three.js renderer main thread over the player main thread) on the lockstep window at takeoff, cockpit and rear gunner, with instructions and process-tree CPU beside it. proof: three alternating legacy and desktop pairs per pose with parity PASS, the dashboard API row, and a V8 profile of each arm split into the same owner rows (engine, game, HUD, harness) + Progress (2026-10-14): one clean pair exists (provisional baseline above); the host-tick reconciliation fix is `e3271a19c`. Left: three qualified pairs, the dashboard API row and the owner-row profiles. + +#### Phase 3: Structural levers, then the final benchmark +**Status:** NOT STARTED +**Files:** `packages/runtime-native/src/engine/player/run.cpp`, `packages/runtime-native/src/engine/renderer/{renderer,render_database}.cpp`, `packages/runtime-native/src/engine/animation/mixer.{h,cpp}`, `packages/core/src/animation.ts`, `packages/three-native/src/audio.ts`, `packages/core/src/{ui-state,ui-bridge}.ts`, `packages/runtime-native/src/engine/player/core-three.mjs`, `packages/three-native/src/{object-surface,tsl-uniforms}.ts`, `scripts/midway-bench/*`, `scripts/perf-live.ts`. +**Implementation:** AC-20 first, then one lever per commit, in budget order. Each lever passes tier 0, then a tier 1 instruction A/B, and stacks only when every B beats every A. A native fast path keeps the TS path for the web, and a spec or conformance case proves that both agree. + +- IPC (AC-20): attribute the stalls first (`perf record` on the player main thread by DSO and symbol, then cache-miss events on the top rows). Candidate levers, each kept only on the A/B rule: a pointer-compression V8 monolith (and ThinLTO/PGO where the recipe allows), HUD snapshot work bounded to real HUD changes, and thread placement that keeps the HUD processes and the GPU thread off the main thread's core and its SMT sibling. +- Render thread (AC-16): after the matrix walk, the main thread hands a frame snapshot (transforms, visibility, uniforms, skinning palettes) to a render thread. The render thread runs cull, project and encode, then hands submit and present to the GPU thread (old AC-9). Keep one frame in flight in total, and extend the GPU thread's deferred destruction to the snapshot. +- Animation (AC-17): first apply URO to rigs that no camera draws (PRD-573 v2.3: a staggered 1-in-4 rate, an update on visibility gain). Skip tracks that no consumer reads (28.8% of evaluated tracks). Then evaluate draw-only tracks (69.4%) in per-rig jobs, joined before the skinning upload. Move the `AnimationPlayer` frame step (fades, stride, foot plant) into the C++ mixer: one call per frame for all players. +- Engine JS (AC-18): positional audio and the listener read world poses in the native audio backend. The HUD bridge validates a message shape once and skips unchanged state. The player shim keeps `parent` and children on the JS side, returns `traverse` lists in one call, and caches attribute names per geometry version. +- V8: the player embeds V8 13.1.201.22. Try a current V8 as one lever when a prebuilt or the Android V8 recipe gives a desktop monolith within one day. Keep it only on the A/B rule. + +**Verification:** the AC-16 to AC-19 proofs. Any lever that can change pixels (render thread, URO) gets the visual judge. + +- [ ] AC-20 [local]: the player main thread reaches IPC 1.0 or more on the lockstep window at all three poses, with its instructions per frame no higher than the matched baseline. proof: three alternating pairs per pose with parity PASS, `perf stat` instructions and cycles per window, and a `perf record` DSO split (V8 JIT, V8 runtime, engine C++, driver) before and after + Partial 2026-10-15 (thread placement campaign `prd610-placement-20261015`): the low IPC is mostly thread placement. On Xvfb the GPU worker busy-waits in the NVIDIA present on cores shared with the game thread. External pinning gave IPC 0.82 / 0.83 / 0.93 (from 0.66 / 0.57 / 0.70) at identical instructions, and 1.37x / 1.58x / 1.87x against three.js. The player now owns `ThreadPlacement` (always on, ctest `native_engine_player_thread_placement`). Top-ranked core and L3-domain split: REJECT. Last-ranked core pair: INCONCLUSIVE (consistent direction, margin not cleared on the shared host). Next: 3 clean pairs on a quiet host, then default `auto`. + - 2026-10-15 compose-cache prototype readiness only: shared native/Wasm Object3D caches 13 bitwise TRS/pivot inputs plus pivot presence and 16 composed output words, after transform materialization. Published local-matrix bytes are still checked/repaired; flat identity-parent publication, virtual overrides, world revisions/force/queries and traversal remain intact. Full allocation cost is included: `sizeof(Object3D)` 440 → 680 bytes (+240/node); no environment switch or subtree suppression. Existing scene/reflection baseline checks pass 15/15; added edge cases also pass baseline 14/14 (appearance-preserving optimization, no functional RED). Candidate passes 14 scene + 37 math/animation/ABI + 2 render callback/reflection-reuse + 27 V8/player checks, plus warning-free startup. Review follow-up verifies two distinct quiet-NaN payloads against independently composed published bytes, revision increases on payload transitions and stable revision on repeated payloads: scene revision 1/1 PASS. The initial exact +1 assertion was too strict because local and world publication can each increment the revision; `/tmp/prd610-compose-payload-test{,-fixed}.log` retain both results. Logs `/tmp/prd610-compose-{baseline-*,candidate-*}`. Frozen A `player-ipo-base-fixed` (18839e526 non-IPO), B `player-compose`, same nine nonbinary game/assets/wrappers; identity `packages/runtime-native/build/tn-engine-ui/compose-trial-identity.json`. Subsequent one-pair result is NO-GO (Decisions): CPU guard fails and instruction signal is insufficient for the node footprint; no promotion or fresh visual-judge claim, all AC boxes remain open. + + - 2026-10-15 IPO readiness, no timing yet: isolated `build/tn-engine-ui-ipo` uses the existing global `CMAKE_INTERPROCEDURAL_OPTIMIZATION=ON`, Release GCC/default opaque V8/Dawn/Rust, no ISA/fast-math change and existing engine `-ffp-contract=off`. Actual compile/link flags include `-flto=auto -fno-fat-lto-objects`. Initial global IPO failed SDL tray `objcopy`; explicitly disabling IPO on `SDL3-static` preserves its required GObject symbol rewrite. The next build passed 37 CPU/25 adapter checks but failed both audio startup fixtures: generated math/runtime headers had different external-inline `find` bodies. Generator `static inline` fixes that ODR violation; both IPO and rebuilt non-IPO now pass 37 CPU plus 27 V8/player checks, including the two real RED cases, and startup without missing-script warnings. Frozen corrected arms `player-ipo-base-fixed` / `player-ipo-fixed` have identical nine nonbinary game/asset/wrapper files; failed candidate remains preserved. Identities: `packages/runtime-native/build/tn-engine-ui-ipo/trial-fixed-identity.json`; actual build/test/startup logs `/tmp/prd610-{ipo,base}-odr-fixed-*`. No parity, qualification or speed claim; AC-20 stays open. + + - 2026-10-15 diagnostic readiness: temporary default-off `TN_MATERIAL_DIAGNOSTICS=1` emits cumulative opacity unchanged/changed/signed-zero/NaN buckets and record/graph rebuild counts/thread-CPU nanoseconds in existing HOLD markers. All assignments and version increments remain intact; shared-material and main/reflection aggregation are tested. RED: three focused test translation units fail for absent diagnostic members (`/tmp/prd610-opacity-red.log`); GREEN: four focused ctests pass (`/tmp/prd610-opacity-green-tests.log`), player plus three test targets build. Review fixes restrict record timing to its cache-miss body and retain the original uint64 HOLD output when disabled (a >2^53 tick is tested); player rebuild and four focused ctests pass again (`/tmp/prd610-opacity-review-tests.log`). Record and graph timers are not nested. Their measured cache-miss-body CPU is an upper bound on opacity-induced work in those bodies, not complete rebuild cost or opacity attribution. Diagnostic acquisition is recorded below; no qualification claimed. AC-20 remains open. + + - 2026-10-15 decision — REJECT the redundant-opacity invalidation guard budget without implementing a guard. `perf-opacity-window-1` exits 0 and passes 3/3 assertions on RTX 2080, with three exact 1,000-host-tick HOLD intervals and 1,000 frames/updates/renders per pose. Takeoff / cockpit / rear gunner: same-value opacity writes 94,804 / 100,000 / 100,000; record misses 10 / 58 / 12; record cache-miss-body CPU 0.0000426 / 0.0001719 / 0.0000579 ms/frame. Graph misses/CPU are zero and thread timing is supported. Record and graph timers are separate; they exclude later materialization and do not prove opacity attribution or complete rebuild cost. Abundant writes therefore do not establish a useful invalidation-cost ceiling. Raw evidence: `~/.cache/tmp-tn/screens/perf-opacity-window-1/`; dashboard diagnostic row excluded, cycle `native-opacity-invalidation` closed reject. Frozen baseline/game/assets preserved; no qualified parity, AC-20 stays open. + + - 2026-10-15 decision — REJECT the isolated pointer-compressed V8 13.1.201.22 build, not pointer compression generally: compiler/configuration can also differ. `v8pc-resume-base-1` → `v8pc-resume-on-1`, takeoff / cockpit / rear gunner CPU: 8.897 → 12.210 / 8.194 → 10.747 / 12.242 → 12.875 ms/frame; instructions: 20.449 → 20.871 / 22.762 → 23.144 / 29.996 → 30.299 M/frame; IPC: 0.577 → 0.420 / 0.704 → 0.529 / 0.607 → 0.575. Every pose worsened CPU and instructions, so the second pair was stopped. Both exit 0 and pass 3/3 assertions on RTX 2080; all six windows have exactly 1,000 frames/updates/renders; candidate adapter checks pass 27/27. Raw evidence: `~/.cache/tmp-tn/screens/v8pc-resume-{base,on}-1/`; dashboard rows are exploratory and excluded, with no qualified parity. Default baseline and candidate archive remain preserved; AC-20 stays open. Dashboard publication took priority; cycle `native-v8-pointer-compression` closed in 27.56 minutes (05:38:24–06:05:58 UTC), within the 30-minute target. + +- [ ] AC-16 [local]: delivered by [PRD-613](../native-engine/PRD-613-native-render-thread.md) (owner, 2026-10-14: separate PRD). Cull, project and encode run on a render thread, and the player main thread's CPU per frame falls by at least 1.0 ms on the lockstep window. proof: a ctest on render-thread ordering (frame N encodes only frame N's snapshot; a mutation goes red), a fixed-step capture pair whose A-against-B difference is no larger than A against A, three alternating pairs per pose, visual judge NEUTRAL or better +- [ ] AC-17 [local]: animation costs the player main thread at most 0.4 ms per frame on the lockstep window (about 1.05 ms today), and an animated value the game reads is current for its frame. proof: a ctest on read-before-join (red when a game-read track evaluates in a job), the mixer meter on three alternating pairs, visual judge NEUTRAL or better at all three poses +- [ ] AC-18 [local]: the HUD bridge, positional audio and the player shim cost the player main thread at most 0.2 ms per frame on the lockstep window (about 0.45 ms today). proof: V8 profile owner rows before and after on the same window, an instruction A/B per moved piece, and a spec or conformance case per native fast path +- [ ] AC-5 [local]: the HUD snapshot round trip is under 16.7 ms p95 in flight. proof: `TN_UI_SNAPSHOT lastRoundTripMs` from `flight.sh desktop` — Evidence so far: p95 168 ms at filing, 26 ms on package T. The WPE backend and a state snapshot were rejected (see Context). Open lead from the 2026-10-13 trace: reading the offscreen window's own surface cost 0.6 ms, against 30 to 49 ms for a full-page snapshot. +- [ ] AC-19 [local]: the native desktop player is at least 2x three.js in Chromium on Midway at takeoff, cockpit and rear gunner, on main-thread CPU per frame (lockstep window) and on frame p50 (free-run, both arms uncapped), with parity PASS and process-tree CPU reported beside both. proof: `scripts/midway-bench/flight.sh` three alternating pairs per pose on the real display, visual judge NEUTRAL or better, and `pnpm exec vitest run scripts/__tests__/midway-dashboard.spec.ts` + - 2026-10-15 AC-19 native host foundation (incomplete): explicit `_setClockMode` negotiates autonomous wall-clock simulation independently of render hold; mode transitions rebase the accumulator without resetting host ticks, and same-mode polls preserve ticks and cadence. `_presentationCompletions` exposes bounded raw `{sequence, completedAtMs}` observations only after acquired-frame `present()` reports `WGPUStatus_Success` through Context/Presenter, with steady-clock timebase, epoch/reset, dropped and validity metadata; held, skipped and failed frames supply no completion. Hold transitions clear the retained series to exclude held gaps; frame-admission epochs reject old queued GPU jobs after a hold/release. Desktop teardown drains render/GPU workers before the endpoint member is destroyed. Inspect/player targets build; focused inspect protocol/input and existing free-run/frame-per-present ctests pass 4/4; the existing player fixture with `--live-clock` passes autonomous updates, held-render tick progress, headless presentation exclusion, fixed-step restoration, repeated live epochs and active teardown. Red initially reproduced unknown `_setClockMode`; two held fixture failures then exposed non-atomic request publication (empty request consumed before write), fixed in that fixture with `.tmp` + rename, matching the production desktop writer. Raw-series unit checks cover delay intervals, unchanged-mode preservation, hold epochs, stale queued-job rejection (red before the epoch check), failed-present exclusion, nonmonotonic timestamps and bounded truncation; `presentation-test missing_surface` also passes without opening a display. Logs: `/tmp/prd610-live-clock-{red,green,ctest,final-build}.log`, `/tmp/prd610-live-epoch-red.log` and `/tmp/prd610-live-status-{build,ctest,missing-surface,mailbox}.log`. No GPU acceptance or frame-p50 win claimed: Phase 2 still needs browser completion observations, desktop driver/scenario integration, real-display uncapped provenance, matched pose windows and paired visual/performance proof; AC-19 stays open. + - 2026-10-15 AC-19 browser completion foundation (incomplete): existing opt-in runtimeDiagnosticsSeries now retains raw successful-callback `{sequence, completedAtMs}` boundaries from the existing phase clock; legacy frameMs is unchanged. Completion IDs survive clear, collection gaps and bounded truncation; failed/skipped renders add no completion. Existing observation cloning preserves both fields without a new bridge. RED 5 failed/37 passed; GREEN 42/42 loop tests and core typecheck PASS; changed-file Biome passes with the existing stepFrame complexity warning. Tests distinguish outer-loop delay from callback body, verify ordering, retention, skipped/failed work and raw invalid intervals for later fail-closed consumption. Logs `/tmp/prd610-browser-completion-*`. Consumer validation, independent per-pose replay, uncapped real-display provenance and paired visual/performance proof remain unverified; AC-19 stays open. + - 2026-10-15 AC-19 browser clock transition (incomplete): the existing core playtest bridge now privately negotiates `_setClockMode` with direct `{mode,tick,epoch}` and `_clockEpoch`. Actual transitions clear retained completion samples, retain monotonic sequence IDs, rebase elapsed time without catch-up and rearm a driven loop once; same-mode calls preserve state. Mode reporting reads mutable bridge options, live `advance` retains its existing wait-wallspan semantics, and idempotent teardown restores fixed-step after the game loop stops. Focused RED reached two missing-seam failures plus one stale completion-field assertion; GREEN 72/72 loop/playtest tests, core typecheck, changed-file Biome (10 complexity warnings) and diff-check pass. Installed-bridge tests cover ready/sample/capability reporting in both modes, invalid modes, autonomous updates, epochs and disposed controls. Frozen Midway core/playtest entry wiring is present, but old frozen packages must be refreshed before acquisition. No GPU run or frame-p50 claim; consumer, uncapped provenance and visual/performance proof remain unverified and AC-19 stays open. Logs `/tmp/prd610-clock-transition-*`. + - 2026-10-15 AC-19 consumer foundation (incomplete): private `TN_PLAYTEST_COMPLETION_WINDOW_LABEL` selects one final plain WAIT per fresh browser/desktop invocation, following a fixed-step prefix. The runner retains the immediate pre-live setup snapshot, negotiates live mode, polls raw completions every 50 ms, warms for at least 2 seconds and retains at least 100 complete intervals between endpoints covering at least 5 seconds; p50, count, duration, source/timebase, epoch and raw samples are separate from legacy `frameMs`. Missing observations, sequence gaps, changed epochs, invalid/decreasing eviction counters, invalid timestamps, incompatible CPU windows, wrong clocks and bounded timeouts fail closed; bounded-ring eviction of already ingested completions is allowed after initial dropped=0, with minimum/maximum eviction counts retained. The >2,048-sample continuous-poll case passes while a missed-poll gap fails; cleanup restores fixed-step mode and releases input, retaining acquisition and cleanup errors. Opt-in reports say wall-clock and unqualified, record requested display plus effective browser uncapping args or native `TN_PLAYER_VSYNC`, and require headed/session-display admission before launch. Focused steps/CPU-window/desktop suites pass 94/94 (synthetic transport proves only prefix advances, controls restore and cadence is reported separately); full root `pnpm typecheck` exits 0; package typecheck, Biome `--write` (55 warnings, no fixes) and diff-check pass. The installed Three bridge test verifies completion-series passthrough and camera position; setup requests force full scene inclusion in both runner arms. The older batching fixture now checks total requested ticks and supplies its missing page stub. Host foundation committed `2c430db69`; consumer foundation is ready for fresh-cohort freeze. No GPU acquisition: physical display/surface-mode proof, refreshed frozen packages, matched setup comparison and alternating visual/performance acceptance remain open. Logs: `/tmp/prd610-completion-consumer-{tests,types,biome}.log`, `/tmp/prd610-completion-eviction-red.log` and `/tmp/prd610-completion-root-typecheck.log`. + - 2026-10-15 AC-19 exploratory dashboard consumption (incomplete): successful terminal reports may expose application-completion interval p50, label, count, duration, wall clock and producer source in the existing recent measurements table, separately from CPU windows. Invalid/missing cadence is hidden; every attempted completion window on a successful report is excluded from qualification, including malformed statistics. Three focused feature tests initially failed; dashboard/live suites now pass 176/176, the corrected legacy loop-fixture assertion passes 1/1, root TypeScript check passes, and changed-file Biome/diff-check pass (4 complexity warnings). The earlier seam fixture now projects known legacy fields instead of destructuring fields absent from its protocol type; production types are unchanged. No completed completion acquisition exists yet, no physical-display UI proof or GPU timing was run, and AC-19 stays open. Logs `/tmp/prd610-completion-dashboard-*`. + - 2026-10-15 AC-19 first takeoff completion diagnostic (incomplete): fresh cohort `~/.cache/tmp-tn/ac19-4199bb663` produced actual child exit 0/report PASS on both arms. Three.js completion p50 **3.500 ms**, 931 intervals over 5002.700 ms (4 assertions); native successful-present-return p50 **46.708539 ms**, 106 intervals over 5017.853 ms (3 assertions). Both retained epoch 1/drop 0 and NVIDIA provenance; browser actual argv contains both uncapping flags, and same-run native console records capability-derived `immediate (vsync=false)`. This is application completion cadence, not physical scanout: the existing owned Xvfb route explicitly records `physicalOutput=false`, unqualified; the active desktop Xwayland `:0` reports 0×0 and all DRM outputs disconnected, so real-display acceptance remains blocked. Both terminal logs are atomically published with exclusion metadata; `/api/midway` contains 186 runs including these two diagnostics. Sources: `~/.cache/tmp-tn/screens/ac19-diag-1-takeoff-{legacy,desktop}/{run.log,art/capture.json,art/console.json,display-provenance.json}`. Shared setup reports flight, altitude 62, IAS 61, throttle 1, airborne; apparent elapsed 1842/1844 ticks from playing-transition witnesses is not exact setup matching: Midway publishes state every six updates, so those transition ticks are publication timestamps. Actual labeled prefix spans are both 1847 ticks (web take-the-deck 60→1907; native deck-taken 542→2389), including the two key-release ticks; camera positions still differ slightly and exact battle-start/player-state equality is unverified. Native interval min/p10/p90/max is 43.538/43.968/49.578/76.457 ms; same-window body timing was not retained because player metrics were not enabled. Next discriminator is existing host-loop/phase attribution of this diagnostic gap, with explicit body metrics only in a separately authorized run; do not infer a cause from p50 alone. No further acquisition or speed acceptance; AC-19 stays open. + - 2026-10-15 AC-19 native coarse discriminator (incomplete): the one authorized `TN_PLAYER_METRICS=1` takeoff diagnostic `~/.cache/tmp-tn/screens/ac19-metrics-1-takeoff-desktop` exits 0/report PASS (3 assertions); completion p50 is **47.794219 ms**, 105 intervals over 5041.211 ms, +2.32% versus the previous native diagnostic. The predeclared coarse gate is **INSIDE**: frame-wall 47.387 ms is 99.15% of completion p50; main-thread CPU 7.453 ms, game wall 5.827 ms and render wall 3.815 ms leave the existing GPU-worker submit/acquire/blit/present path as the dominant observed CPU owner (previous-frame worker CPU 40.609 ms), without identifying an exact driver stage or proving causality. The retained 146 metric records account for 426 updates: update counts 2/3/4/5 occur 20/121/2/3 times. Host load rises 3.76→9.11; this instrumented single run remains exploratory, owned-Xvfb `physicalOutput=false`/unqualified, with no speed acceptance. Same-run `art/console.json` identifies NVIDIA GeForce RTX 2080, vendor nvidia, Vulkan backend and selected `immediate (vsync=false)`; `art/capture.json` independently records turing/nvidia/RTX 2080 and driver 615.78.08. These identify the selected adapter/backend, not physical scanout or a GPU-worker stage. Labeled post-deck prefix spans 1847 ticks in both arms; the native repeat’s published HUD player/time agrees with the prior native diagnostic, but cross-arm camera positions differ by up to 2.07 cm and exact cross-arm state matching remains unverified. Terminal exclusion-first publication is visible in `/api/midway` (187 runs). No second acquisition; inspect the existing GPU-worker profiler readiness next, without another flight until separately authorized. AC-19 stays open. + + - 2026-10-15 GPU-worker profiler readiness: optional exact-window native recordings now attach only the validated player process (`perf record --no-inherit -p`), while browser recordings and authoritative stat counters retain main-TID scope. New profile `recorderScope` records native-process/main-thread explicitly; existing separate FIFO ACK boundaries and cleanup remain intact. Focused RED 2 failed/32 passed, GREEN 34/34; playtest typecheck and rebuilt driver PASS. Initial root typecheck rejected stale dist declarations; after the focused driver rebuild, actual full `pnpm typecheck` exits 0 (`/tmp/prd610-process-profile-root-typecheck-after-build.log`). No acceptance or live-window attribution claim; AC-19/20 remain open. + + - 2026-10-15 native GPU-worker ownership diagnostic (`perf-gpu-worker-takeoff-1`): driver `703f7aff7`, frozen player/game cohort `4199bb663`; one takeoff window passes 3/3 assertions with 1000 updates and renders (ticks 1490→2490). Process-scoped recording decodes 10,444 samples on 21 threads, all owned player PID 4012651. GPU worker TID 4013091 is identified by GpuThread ancestry; 4456/5007 worker samples (89.0%) contain Surface::APIPresent, with NVIDIA driver/clock-read ancestry beneath Vulkan SwapChain::PresentImpl. Perf reports 23 lost samples and script reports 15 lost chunks; raw data, bounded no-inline decode and worker stack subset remain in `~/.cache/tmp-tn/screens/perf-gpu-worker-takeoff-1/profiles/`. The installed perf uses tgid,pid rather than the unsupported tid sort key. Metrics-on per-driven-tick rendering is necessary for this exact fixed-step diagnostic; no live-window join, speed acceptance, private-driver cause or Xvfb causality is established. API publishes the excluded/profiled row (188 runs). Existing Renderer GPU timestamps are available internally but not enabled/exposed by this V8 player; next discriminator must separate GPU execution from presentation CPU without treating elapsed render-pass time as queue-wait time. AC-19/20 remain open. + - 2026-10-15 AC-19 Wasm-browser takeoff diagnostic (incomplete): fresh `~/.cache/tmp-tn/ac19-4199bb663/wasm`, receipt engine `4199bb66391cf1004fc55951b818bb49b6e351cf`, serves `dist/assets/index-D1VP5UVs.js`; the one authorized `ac19-wasm-1-takeoff-native` invocation exits 0/report PASS (4 assertions). Actual browser callback-completion p50 is **18.250 ms**, 192 intervals over 5033.400 ms, epoch 1/drop 0; same-run capture records NVIDIA/turing and actual Vulkan recipe plus both uncapping flags. Pre-live fixed-step tick 1907 follows the 1847-tick labeled post-deck prefix; published flight/altitude 62/IAS 61/throttle 1/airborne and recorded camera position/forward equal the prior Three.js browser baseline. Host load rises 10.39→20.13 (5-minute 8.78→12.16), so these different-time, different-load measurements establish neither a cross-arm speed ratio nor a cause. Owned Xvfb explicitly records `physicalOutput=false`/unqualified; physical-display acceptance remains blocked and AC-19 stays open. Actual terminal sources are `~/.cache/tmp-tn/screens/ac19-wasm-1-takeoff-native/{run.log,art/capture.json,display-provenance.json,host-load.txt}`; exclusion-first atomic publication is visible in `/api/midway` (189 runs). No retry or additional acquisition. + + - 2026-10-15 native player GPU-timestamp readiness: default-off `TN_PLAYER_GPU_TIMING=1` with metrics reuses Renderer.setGpuTimer and adds optional enabled/support/sample-count/freshness/elapsed/four-segment fields to TN_PLAYER_FRAME; no resolved sample emits null elapsed/segments. The span excludes queue waiting, final swapchain blit and present. Requested TN_RENDER_THREAD=1 is explicitly rejected because there is no synchronized timing snapshot. With RT0, timestamp readback publishes through GpuResources::readInto event posting and the player main-thread event drain; the GPU worker only polls/posts completion. Fixture RED missing metric field, GREEN enabled/default-off; rejection RED accepted RT1 (exit0), GREEN clear unsupported exit2; final enabled/default-off/rejection fixtures all pass. Existing native GPU opt-in/shadow coverage ctests pass 2/2, player rebuild and fixture Biome pass. Logs `/tmp/prd610-gpu-timing-*`. These establish diagnostic readiness only, not a live GPU/present join, backlog cause or Android acceptance. Any later acquisition requires separately recorded new host revision/hash against unchanged game cohort4199 and the declared quiet gate; AC-19/20 remain open. + - 2026-10-15 AC-19 GPU-timer admission **PARKED**, exit 75: `ac19-gpu-quiet-1` obtained 20 observations at least 60 seconds apart over 1140.438 seconds, with zero passing quiet points (load1 3.77–18.57; pinned RTX utilization 0–100%). The bounded watch stopped within 20 minutes without holding capture/outer locks, launching a flight or creating an acquisition/dashboard row; `/api/midway` remains at 189 runs. Source: existing scratchpad `ac19-gpu-quiet-1-quiet-watch.jsonl`. Preflight actual host SHA `b80ede4950a618429fea7c7850d4be68d75b4dec2976ec59c01b4f3200dca885` matches diagnostic host revision `49b39a30117682325b7a08a41c8d5f698806f55b`; actual game SHA `2e152479a639bcafa31c41077e24ae3d4b545b6b320b17816fdf6ad8270df51f` matches the unchanged `4199bb66391cf1004fc55951b818bb49b6e351cf` game receipt and its explicit game/assets/UI scope. GPU timing availability/fresh counts/elapsed, completion p50 and window-load interpretation were **not acquired**, not zero. A subsequent read-only ownership audit found no live exact diagnostic/cohort-path match or native-player/orphan-Chrome process; recorded native profiler PID 4012651/starttime 3729859 is absent. The then-busy Chrome PID 186536/starttime 3971314 (100 ticks/second) starts after the watch's final monotonic observation 39622130.435 ms and has no known own-path match, so it does not attribute historical 100% utilization; ownership and that utilization's cause remain unproven. No processes or settings changed; no equivalent watch retry or acceptance claim. AC-19 stays open. + - 2026-10-15 AC-19 negotiated-epoch correction (incomplete): the collector now requires a nonnegative safe-integer epoch from the private clock negotiation and rejects an epoch mismatch on the first poll as well as later polls. Native `_setClockMode` returns the current epoch under the existing presentation mutex; ordinary clock snapshots/protocol remain unchanged and same-mode calls retain epoch/samples/ticks. The first-poll reset regression fails before the fix and passes afterward; missing/negative/fractional/nonfinite/unsafe negotiated epochs also fail closed with cleanup. Focused consumer/desktop tests pass 67/67, native inspect protocol/input checks 2/2, the actual headless live-mailbox fixture passes, full root `pnpm typecheck` exits 0, and changed-file Biome/diff-check pass (22 lint warnings, no fixes). Logs `/tmp/prd610-epoch-{red,green,native-build,native-tests,live-fixture,root-typecheck,biome}.log`. No GPU acquisition: historical 4199/49b frozen hosts intentionally lack the new reply epoch and fail closed with the corrected collector until a future fresh cohort is built; historical receipts are unchanged. AC-19 remains open. diff --git a/docs/PRDs/performance/PRD-611-hud-overlay-latency-and-cpu.md b/docs/PRDs/performance/PRD-611-hud-overlay-latency-and-cpu.md new file mode 100644 index 0000000000..9c84943ad0 --- /dev/null +++ b/docs/PRDs/performance/PRD-611-hud-overlay-latency-and-cpu.md @@ -0,0 +1,78 @@ +# PRD-611 — The Linux HUD overlay keeps up with the game and stops burning a core + +**Status:** PARTIAL (round trip p50 54 to 12 ms, web-process cost per game frame -9 to -17%; AC-5 p95 and the `pnpm test:ui-native` box are open) +**Priority:** P1 — PRD-610 AC-5 is open: the HUD trails the game by many frames on Midway and the WebKit web process is the largest CPU consumer of the run. +**Complexity:** 6 (MEDIUM) +**Owner:** João +**Depends on:** PRD-610 (this PRD closes its AC-5 and rides PR #493) + +## Context + +The Linux UI overlay (`packages/runtime-native/native/ui-overlay/src/offscreen.rs`) is a WebKitGTK view in a `GtkOffscreenWindow` on its own thread. The game never calls into it: it reads a latest-frame mailbox. A cadence loop asks `WebView::snapshot(SnapshotRegion::Visible)` for the pixels, copies them into a pooled buffer and publishes them. + +PRD-610 measured the cost on Midway in flight (2026-10-13, Xvfb): `get_snapshot` p50 54 ms and p95 68 ms, copy and compare 0 to 1 ms, and `WebKitWebProcess` at 61.6% of all CPU samples (about 2.3 times the game main thread). `get_snapshot` re-rasters the whole page on the CPU every time, and it blocks the page's own rendering while it runs. + +## Evidence gathered for this PRD (2026-10-13, WebKitGTK 2.54.1, GTK 3.24, Xvfb) + +Probes are under `native/ui-overlay/src/bin/` (see Solution) and the numbers below come from them. + +1. With the view background **opaque**, the page's own compositor frame is complete and exact: drawing the view into a cairo surface after each `damage-event` costs 0.3 to 2 ms and matches `get_snapshot` to within a few dozen bytes of 8 MB. +2. With the view background **not fully opaque** (alpha 0 up to 0.99), only the first buffers of the SHM swap chain are complete. Every later buffer holds just the repainted damage region and zero elsewhere, so reading the offscreen window or the view gives a partial frame. The `PropagateDamagingInformation`, `UseDamagingInformationForCompositing`, `UnifyDamagedRegions` and `UseSkiaForComposition` features, `WEBKIT_DISABLE_COMPOSITING_MODE`, `WEBKIT_DMABUF_RENDERER_FORCE_SHM` and the hardware acceleration policies change nothing (the other policies abort, as `offscreen.rs` documents). +3. A full-viewport empty layer whose transform changes by 0.01 px every page frame makes every buffer complete, at the price of a full-frame composite per page frame. + +## Solution + +Take the pixels from the page's own compositor frames instead of a second full raster per ask, and keep `get_snapshot` as the fallback and the verifier. The mechanism lives only in the overlay (`native/ui-overlay/**`): nothing here knows Midway, and the game's HUD source is untouched. + +### Phase 1: Measure and choose + +- [x] Baseline: two Midway flights of the unchanged `get_snapshot` capture record `lastRoundTripMs`, the `settled` trace and the WebKit share of CPU samples. proof: `~/.cache/tmp-tn/midway-runs/hud-base-desktop/run.log`, `hud-snap1-desktop/run.log`, `perf report --sort comm` (2026-10-13, Xvfb, loaded host: round trip p50/p95 62/100 ms and 54/96 ms; 2709 and 2598 HUD frames per flight; `WebKitWebProcess` 61.6% and 63.1% of CPU samples, the game 15% and 27%) +- [x] The damage route is characterized: the WebKit probes show which buffers are complete and which are partial. proof: `native/ui-overlay/src/bin/probe-damage.c` (2026-10-13, 1920x1080 Xvfb: with the coverage layer and nudge, 5 of 5 buffers per step are complete and match `get_snapshot` to maxDelta 1; without them, later buffers are partial) +- [x] One route is chosen and recorded under `## Decisions`, with the number that decided it. proof: the Decisions entry cites the flight numbers + +### Phase 2: Implement + +- [x] The chosen route is in `offscreen.rs` behind a named override (`TN_UI_OVERLAY_CAPTURE=snapshot` keeps today's path) and falls back to `get_snapshot` after three captures in a row that never complete. proof: Midway flights (`hud-fx1` fell back after 3 misses and kept the HUD alive on `get_snapshot`; `hud-snap1` ran the override; `hud-paceA`/`hud-paceB` ran `capture=compositor` with 1 partial frame in 3000) +- [x] Unit tests cover the new merge, change-detection and fallback logic. proof: `cargo test` in `native/ui-overlay` (26 passed, 4 new `copy_composited` tests; the fallback is a counter on the live view and is proven by the Midway flights, not a unit test) +- [ ] The Linux WebKit focus, keyboard and editing regressions stay green. proof: `pnpm test:ui-native` + - Red at `HEAD` before this PRD's changes too: `ordinary_controls_use_native_focus_and_editing` expects `value=Aé` and observes `value=A2` after the `us,fr` layout switch (same result with `TN_UI_OVERLAY_CAPTURE=snapshot` and on a copy of the untouched `offscreen.rs`). It stops at that key step, so it proves nothing about this change. Needs a look at the Xvfb keyboard layout on this machine. + +### Phase 3: Prove it on Midway + +- [ ] AC-5: the HUD frame reaches the mailbox within one frame (16.7 ms) at p95 in a Midway flight. proof: `TN_UI_SNAPSHOT` and `TN_UI_SNAPSHOT_TRACE` lines of `hud-pace*-desktop/run.log` + - Not met: p50 12 ms but p95 29 and 32 ms (`hud-paceA`, `hud-paceB`), against 54/96 and 62/100 ms with `get_snapshot`. A request lands at a random point of the page's 60 Hz frame, so the tail cannot go below one page frame plus the composite. +- [x] The WebKit web process uses less CPU per game frame than the baseline over the same flight. proof: `perf report --sort comm` on `perf-flight.sh` runs (cycles of `WebKitWebProcess` per game frame: 0.27e9 `hud-base`, 0.248e9 `hud-snap1`, 0.250e9 `hud-nopace1`, 0.225e9 `hud-paceB`; share of the player's samples 61.6%, 63.1%, 58.2%, 56.5%). Only 9 to 17% less: see Results. +- [x] The visual judge rates the HUD before and after IMPROVEMENT or NEUTRAL, per frame and overall. proof: judge verdict recorded under `## Results` (NEUTRAL on takeoff, cockpit, reargun and overall, two runs per arm; the same judge also said it cannot rule out a few percent of opacity by eye, so `probe-damage` maxDelta 1 against `get_snapshot` is the pixel evidence) + +## Decisions + +- 2026-10-13, agent: the HUD is read from the page's own compositor frames, made whole by a coverage layer (Solution). Rejected: WPE (not installed, needs sudo, and its web process composites the same way, so the transparent-buffer defect is likely shared); accumulating partial buffers (probe `probe-acc`: maxDelta 54 to 98 against `get_snapshot`, 30k to 300k bytes over 8, from double-composited translucent pixels); the WebKit damage feature flags (no effect on the buffers). Deciding numbers: Midway round trip p50/p95 54/96 ms with `get_snapshot` against 24/31 ms with compositor frames (`hud-snap1`, `hud-fx2`), and 6182 against 2598 HUD frames per flight. +- 2026-10-13, agent: the nudge is a `data-tn-fx` attribute on `html`. A custom property on the root produced no repaint (probe: 0 frames after the first) and a class is lost when a page rewrites `className`. +- 2026-10-13, agent: absolute WebKit CPU did not fall with the faster capture alone. Sample shares were `WebKitWebProcess` 60.9% (`hud-fx3`) against 63.1% (`hud-snap1`), and the capture loop made 2.4 times as many HUD frames as the game took (the game took one frame per 50 to 60 ms in this lane). So the capture is paced: it starts so it ends just before the game takes its next frame (`pace_open`), with `TN_UI_OVERLAY_PACE=off` as the override. + +## Results + +Integration audit (2026-10-13): the requested HUD commits `8a2270834`, `8b3c35c96`, `19d17d4f7` and first-present ordering fix `db23a199f` are already ancestors of `535749525`; no duplicate cherry-pick is needed. The frozen `~/.cache/tmp-tn/pkg-ac10-winner-hud-v1/tn-native-engine-player-v8` is a regular file with SHA-256 `ca0d2db010a598e4664a0e9bf1094df37b93e380d5d73e06e9fb33f969269b24`, contains the capture/pacing controls, and reports `capture=compositor` in `winner-hud-v1-desktop/console.json`. That fresh flight passed parity and recorded whole-run HUD p50/p95 **39/65 ms** (249 one-second observations), or **42/67 ms** after player tick 300 (197 observations). Host load was **37.04 to 20.97 on 24 cores**: these latency readings do not qualify an isolated improvement or close AC-5. The old `cache1-a1`/`cache1-b1` players reported no compositor marker; their 105/107 ms p95 cannot be attributed to this integrated HUD build. + +An attempted same-player snapshot/compositor comparison is retained under `hud-integrated-snapshot-a1-desktop` and `hud-integrated-compositor-b1-desktop` (parity passed both). These omitted `TN_PLAYER_METRICS=1`, so their 14/15 one-second HUD observations lack the intended player-tick mapping and do not qualify steady-flight latency. The corrected snapshot arm `hud-integrated-metrics-snapshot-a1-desktop` was stopped when unrelated CPU workers drove load to **116.14 on 24 cores**; its run-local `timing-exclusion.txt` records the conflict, and the runner exited 2 during cleanup. Its player was confirmed gone before releasing the lane. No corrected compositor arm was launched. Source and binary integration are verified; controlled current-build HUD latency and the 16.7 ms p95 target remain unverified. + +Midway flight on this machine (Xvfb, loaded host, 2026-10-13). `lastRoundTripMs` is the one-second `TN_UI_SNAPSHOT` line, HUD frames are `settled` events, about 2500 game frames per flight. + +| Flight | Capture | Round trip p50/p95 | HUD frames | Game frame p50/p95 | +| --- | --- | --- | --- | --- | +| `hud-base` | `get_snapshot` | 62/100 ms | 2709 | 68/117 ms | +| `hud-snap1` | `get_snapshot` | 54/96 ms | 2598 | 52/105 ms | +| `hud-fx2` | compositor, unpaced | 24/31 ms | 6182 | 59/72 ms | +| `hud-nopace1` | compositor, unpaced | 23/30 ms | 5755 | 54/68 ms | +| `hud-paceA` | compositor, paced | 12/29 ms | 2954 | 53/61 ms | +| `hud-paceB` | compositor, paced | 12/32 ms | 2996 | 54/70 ms | + +- The game took one frame per 52 to 60 ms in this lane, so a HUD capture every 23 ms made 2.3 frames the game never saw. Pacing leaves about 1.2 captures per game frame and ends each just before the game takes it. +- The WebKit web process runs on one thread (`perf report --sort pid`: 56.4% of the player's samples in the main thread of the web process, nothing in a compositor thread) at about one core in every arm. That thread does the page's JavaScript, layout, CPU paint and the composite. The capture was a small part of it: removing the second raster per ask and then 40% of the captures moved the per-frame cost by 9 to 17%. The rest is the page's own paint, which is CPU raster because accelerated compositing aborts on an offscreen window. +- Visual judge, fresh read-only Sonnet subagent, BEFORE = `hud-base` and `hud-snap1`, AFTER = `hud-paceA` and `hud-paceB`, poses takeoff, cockpit and reargun at 1280x720 (native desktop capture, not the web WebGPU recipe): NEUTRAL on all three and overall. Images: `~/Downloads/prd611-hud/`. +- `hud-pace1` and `hud-paceB` exited 1 on `[WebGPU] Device error (Validation): Destroyed texture [Texture "of [Surface]"] used in a submit` (2 of 5 flights on engine build `84da13581`, including the same build with `TN_UI_OVERLAY_PACE=off` passing). The overlay creates no WebGPU resource, so this is the engine's GPU-thread present, not this change. Parity passed on `hud-paceA`. + +## Blocked on + +- A cheaper page paint than the CPU raster: WPE headless (not installed, needs a package install with sudo), or WebKitGTK accelerated compositing on an offscreen window (aborts today). Owner call on installing WPE. +- The game's HUD CSS (inset box-shadow blur, full-screen gradients, `backdrop-filter`) sets the paint cost; changing it is game code and out of scope here. diff --git a/docs/PRDs/performance/PRD-617-every-template-runs-on-the-native-engine.md b/docs/PRDs/performance/PRD-617-every-template-runs-on-the-native-engine.md new file mode 100644 index 0000000000..884245e8ec --- /dev/null +++ b/docs/PRDs/performance/PRD-617-every-template-runs-on-the-native-engine.md @@ -0,0 +1,279 @@ +# PRD-617 — Every template runs on the native engine + +**Status:** IN PROGRESS +**Priority:** P0 — the owner blocks the v1 release until AC-1 to AC-7 pass (Decisions, 2026-10-15); 11 of 13 templates, the starter among them, still pin `engine: "legacy"`. +**Complexity:** 4 (MEDIUM) +**Owner:** João +**Depends on:** PRD-573 (PR #493 carries this work: native default, the `legacy` pins, the pin ratchet test, the CI engine artifact builds) + +Recovery snapshots integrated into PR #493; publish-state tests: 46 passed. Runtime matrix and visual proof remain pending; no acceptance criteria are newly verified. + +2026-10-16 GPU timer correction (engine layer; Complexity: 1 → LOW; risk override: none). +Tempest's reported 39.7 ms excluded offscreen work from the 44.3 ms per-pass sum, including a +1152×648 cloud pass of about 4.3 ms. Bounded plan: share timestamp collection with secondary, +render-target and probe renderers; instrument every scene, shadow, post, TRAA, mip and compute +pass; resolve and sum complete timestamp pairs once at the owning frame boundary. Keep timing +opt-in, asynchronous, source-tagged and fail-closed. Integration is unchanged: web frame selector 7 +and the V8 player still consume Renderer::lastGpuMs(). +The new native_engine_gpu_timer_covers_offscreen case reproduced RED on the original timer: +immediate and deferred targets both failed the resolved-pair assertion. GREEN: the same case now +resolves four render-pass pairs (offscreen scene/output and main scene/output), checks nonzero +offscreen attribution and the total, and separately checks a compute encoder plus main frame +resolves three pairs. All three focused GPU timer CTests pass headlessly on NVIDIA RTX 2080/Vulkan. +The explicit native tests/render-driver/V8-player targets and Wasm preset build successfully. +The requested native-engine suite completed: 407/409 passed. Its TRAA source-order assertion +searched for the old raw pass-begin spelling; updated to the timed wrapper, it now passes alongside +all three timer cases (4/4 focused). The remaining registry snapshot failure is pre-existing: +HEAD bindings expose Scene.fogNode's getter while HEAD's native-registry.json omits it; that is the +only generated-dump difference. The unrelated snapshot is unchanged. No release or visual +acceptance box is ticked by this instrumentation fix. + +2026-10-15 reviewer input defect: the V8 player's playtest mailbox treated DOM `buttons` as +`button + 1`, misidentifying right/middle presses and every release. The pending fix tracks the +previous mouse mask and maps its changed bit to DOM `button`, preserving chord transitions and +touch contacts. `player-ui-input.mjs` now asserts actual canvas callbacks for right/middle +presses/releases, chords, unchanged/ambiguous masks and two touch contacts. Script syntax and +diff checks pass; Biome excludes this fixture. Runtime red reproduced against the existing +UI-enabled player SHA256 `43c490665a420539274469b9c050ae5ca1a51d4499f5c054fcbe362aa8a63d1f`: +right press reported button 1 instead of 2, middle press 3 instead of 1, releases 0 instead of +the released button (`/tmp/pr493-pointer-red.log`, exit 1). The shipped capture lock was held +and released. The same mailbox callback regression passed on rebuilt player SHA256 +`ab5cbcc17223906fcacd70b4e9dd5b20e5331c2e0f31ab22ec7cf982ae881bdd` +(`/tmp/pr493-pointer-compute-green.log`); separate CTest results were real-input UI and compute +passed, compute-ops failed on duplicate sprite storage declarations. The UI-overlay presentation +test failed once at blue share 0 despite receiving its click intent, then passed unchanged on a +diagnostic rerun; its asynchronous presentation race remains under investigation. +No AC is ticked by this note. + +2026-10-15 current-consumer replay: starter and snow web/desktop builds and installed/package artifact comparisons passed (player `5e40a3d5363c`, Wasm `490bfaa43d73`). Starter web movement passed; play failed frame-time P95 at 38.4 ms against 33 ms with no console errors. Production-readiness reached scene teardown and failed because native storage attributes lack `dispose()`. Snow web footsteps reached particle culling and failed because the Camera facade omitted its live projection conventions. These are engine issues; no game workaround or performance threshold change was made. Camera bindings now pass native ABI culling tests and rebuilds; a guarded Snow desktop survives replay passes all three assertions on rebuilt player `df34fb682d6d` with its unchanged game bundle. Fresh consumer-package/web replay, lifecycle repair and remaining qualification are pending. No acceptance box is newly verified. + +2026-10-15 BufferAttribute synchronization: Snow's measured renderError (0.1227) came from native setters updating the engine while needsUpdate uploaded an older JS array. The facade now copies each setter's changed raw range back into the same JS array; retained game edits survive, copyAt synchronizes its source item, and bulk transforms synchronize their affected attributes. The private view API reuses the existing ABI, preserves scalar types, validates finite integer ranges, and retains exact-length validation for full writes. Repeated setters copy changed elements rather than the whole buffer (linear-cost regression). Verification: 203 unit tests, package typecheck, Biome and 31 document checks pass. Real ABI range/geometry checks pass in build/wasm; browser_gc passes. At that revision the complete browser_backend smoke failed at "Fn returns cached graph built once at definition", reproduced with unchanged HEAD source and smoke test; it was not a green smoke gate. The 2026-10-16 test correction below resolves that stale predicate. Visual judgment and paired CPU measurements remain unverified. No acceptance box is ticked. + + +2026-10-16 fresh consumer replay at `99e88c2bc`: both consumers were reinstalled from freshly packed local packages, rebuilt for web and desktop, and checked against producer/installed/packaged Wasm and player bytes. Snow web footsteps passes 9/9 assertions: renderError and colliderError are zero, six footsteps and 2.201 m movement observed. Starter web production-readiness passes 5/5, including collection, restart and entity teardown/respawn. Both ran headed hardware WebGPU on NVIDIA/Turing at 1280×720 with zero console, network or runtime diagnostics. The continued hardware replay passes all four desktop scenarios (starter react-hud/render-chain; Snow snow-physics/survives) and starter web movement/survives. Snow web production-performance fails: frame P95 94.2 ms against 33 ms and 18.62 FPS against 30. The capture used a private virtual display; these timings are diagnostic, not release performance qualification. The matrix stopped at that failure. A separate remainder run passes Snow web snow-physics/survives/touch-controls/weather and starter web play, giving 13/14 scenario passes overall. All five remainder cases used hardware NVIDIA/Turing; Snow ran at 1280×720 and starter play at 1920×1080. Snow performance, physical-display performance qualification, legacy visual comparison and paired CPU remain pending. Artifacts: /tmp/pr493-99e-matrix/ and /tmp/pr493-99e-browser-remainder/. No acceptance box is newly verified. + +2026-10-16 Wasm smoke correction: the existing Fn contract builds a fresh graph per invocation. The smoke test now checks distinct valid graphs and invokes both previously uncalled scope-order/throw callbacks. The original scope order and error checks remain intact. The real Wasm browser_backend and browser_gc tests pass 2/2 in `build/wasm`; package typecheck, Biome, documentation links and 267 document/CI contract tests pass. This changes tests only, with no production runtime change. + +Native coverage qualification (Linux): the official command now passes using separate instrumented legacy and owned-engine trees, with coverage-inspection CTests still executed for correctness. Results: 47 legacy targets, 323 profiled owned-engine tests and 23 reference checks; 80.60% total line coverage and 82.22% for src/engine/. The generated docs/verification/native-coverage-2026-08-28.md records source digest d53fe78e3ba8611050b7ecc1ae42f7a41fcd796ef3c4ef3df0e307f8656da7e6; all seven freshness tests pass, alongside 47 coverage-lane unit tests. Scope remains physics/UI OFF and engine player OFF; V8 player and other platforms are not coverage-qualified by this run. No acceptance box is newly verified. + +Snow CPU candidate plan: Complexity: 1 → LOW; risk override: none. The captured browser profile attributes about 54% of main-thread self-time to native attribute dispatch, boxing and read-back under Heightfield.refreshGeometry. Hypothesis: bulk writes to validated plain Float32 position/normal attributes can remove at least 10 ms of the observed 37–51 ms render phase while retaining identical heights, normals, dirty windows and bounds; custom/interleaved/normalized attributes retain their setter path. Mutable files: packages/core/src/world.ts and its existing world-heightfield.spec.ts tests. Frozen judge: existing Snow game source/assets/scenarios, 1920×1080, 4× MSAA, hardware WebGPU, fixed quality and frame windows; baseline runtime artifacts remain installed in the original consumer. Before a keep, run three baseline A/A measurements, five interleaved baseline/candidate pairs, shared-path desktop/browser behavior checks and two captures per arm judged independently for no visible regression. Private-display comparisons use render/CPU timing only, never release FPS. The candidate passes 19 Heightfield tests, including numerical dirty-window/bounds checks and interleaved, normalized and custom-setter fallback, plus core typecheck; Biome reports no errors and two complexity warnings. Three unprofiled hardware-WebGPU baseline A/A runs measured steady render P50 53.4/47.1/45.4 ms (300 samples each), establishing an 8.0 ms noise band. The original consumer artifacts stayed byte-identical. Candidate packaging, five paired A/B runs, shared-path gameplay and visual judgment remain pending; no performance win or acceptance box is claimed. + +2026-10-16 native deterministic screenshot final (no acceptance box ticked). Defect: after a native +`advance(render:true)` disables automatic RAF (`#driven = true`), a desktop screenshot request finds no +present, so `captureFrameScreenshot` never runs and the host times out. Implemented: +`FixedStepLoop.renderCurrentFrame()` calls `stepFrame(now, "render-only")` — an explicit mode that runs +the frame hooks and render, skips the frozen startup settle entirely, and leaves `#primePending` for the +next ordinary frame. It is exposed through an optional `IGamePluginRuntime.renderCurrent`, the bridge's +`renderOnly` operation and `runtime.renderOnly`; the native host endpoint forwards `renderOnly`; +`captureDeviceScreenshot` passes it to the desktop driver only for a counted desktop run, and the driver +invokes it once the host has consumed the request file so the copy flag exists. The host's 120-iteration +capture wait is now a bounded monotonic ~2 s budget; the 5 s map budget and image validation stay. Browser +behavior is unchanged (the browser capture never uses the operation). +Verification: all three new tests reproduced RED before the fix (core `renderCurrentFrame is not a +function`; `runtime.renderOnly` absent; driver render callback never fired) and pass after it — +`packages/core/__tests__/loop.spec.ts`, `packages/playtest/__tests__/three-bridge.spec.ts`, +`packages/playtest/__tests__/desktop-playtest.spec.ts` (including the end-to-end runner chain). Native: +the legacy player fixed the old timeout 3/3; the owned engine first returned an unknown `renderOnly`, so +the missing native-endpoint route was added, moving the native cases RED 2/4 → GREEN 4/4, and +`protocol_methods.inc` plus its generator were refreshed and the test registered. Zero-time RED: on a +freshly frozen loop with a pending startup settle the first attempt spent two ticks; the explicit +`render-only` mode keeps the pending prime for the next ordinary frame, and the focused regression +(`freezeClock(2)`, start, render-only then an ordinary frame) reproduced RED (`afterPhysics 2, tick 2, +updates 2`) and passes GREEN, with 73 loop/bridge tests passing. The false +`clearFrameScreenshotReady()` "drop late captures" line and comment were removed from the timeout branch. +Focused checks: laptop core/playtest units 5243 passed, 7 skipped (GitMCP excluded from the empty Git +lane); local 61 plugin/GitMCP passed; global `pnpm typecheck` passed and lint passed with warnings. The +full `pnpm test` stopped on native: 140 native specs, 11 failed, 1608 passed, 64 skipped (eight missing +test bins for V8+QuickJS, two stale SBOM receipts, one gradle-wrapper dependency-name parser); the +preexisting baseline is UNPROVEN. No full CI qualification is claimed. +Packaging: producer/core/playtest pack and installed artifacts are identical. All 15 frozen archives from +the capture-fixed cohort are byte-identical on the new routed cohort; only the owned C++ player changed, +via the existing builder override `d6953a29`; legacy `7e157d58` and Wasm `df3e` rebuilt unchanged; foreign +deps, game, assets and original scenarios are unchanged. +Visuals: the original native React HUD passed 3/3 on owned and legacy, with no screenshot advance +workaround. An additional visual scenario (only `setup.startTick` 128) ran four times (two per arm), 3/3 +each, same end tick 328, on NVIDIA RTX2080/Turing at 1280×720 — 6 runs total, 18/18. Original startup +counts varied (261/270), so the explicit common pose anchor is for the visual capture only, not a new +default. A fresh read-only judge `/root/native_capture_routed_visual_judge` returned NEUTRAL for both +pairs and overall: geometry, materials, lights, shadows, sky and HUD preserved; static pose only, so +motion and flicker are not assessed. Figures: `/tmp/pr493-capture-routed-visual/compare1.png`, +`compare2.png`; proof: `/tmp/pr493-capture-routed-native-proof`; posting on PR #493 remains for the +parent lane. +Browser: the original owned browser render-chain replayed twice headed on hardware (6/6 each) on the new +core/playtest pack with the high guard; logs `/tmp/pr493-capture-fixed-browser-proof/{1,2}/report.stdout.log`. +The scenario's `capture.target` metadata says desktop, but the actual `page.screenshot`/browserArgs prove +it is the browser; do not mislabel it native. +Still open without acceptance ticks: the native high-quality render-chain and paired CPU; Snow's 33 ms P95 +failure; the template matrix, coverage, CI and PGO. + +## Context + +On 2026-10-15 the owner made the native C++ engine the default (`engine: "native"`) for v1. On the +release branch the V8 engine bundler refuses 7 of 13 templates with `TN_NATIVE_ENGINE_UNBOUND` +imports, and the V8 player fails 4 more at runtime. Only minimal and puzzle run on the native engine. +Measured by the PRD-573 release lane on 2026-10-15. + +Refused by the bundler (post and TSL imports): + +| Template | Refused imports | Examples | +| --- | --- | --- | +| starter | 18 | `film`, `lut3D`, `traa`, `luminance`, `renderOutput`, `rtt`, `struct`, `wgslFn`, `DepthTexture`, `LUTCubeLoader` | +| snow | 6 | | +| rain | 4 | | +| shooter | 2 | | +| action-rpg, runner, sailing | 1 each | | + +On the web Wasm engine the starter fails all three playtests with `TN_NATIVE_UNCATALOGUED_NODEPROXY` +(`FilmNode`, `Lut3DNode`, `TRAANode`). + +Bundle clean, but fail on the V8 player (missing runtime bindings): + +| Template | Runtime failure | +| --- | --- | +| platformer, tower-defense | `MeshStandardMaterial.flatShading` unbound | +| racing | `Mesh.computeBoundingSphere` unbound | +| rts | `TN_V8_FRAME_FAILED` on `updateRanges.push`, every frame | PRD-573 Phase v1.8 ("slice 8", not started) and its v1.5 known red +(native lowering of the LUT, grain and TAA stages) name the same gap. + +The exact per-template list is the input of the pin ratchet test that PR #493 adds: it asserts that +each pinned template is still refused (bundler codes, or the recorded runtime failure for the four +runtime-pinned templates), and it fails with "remove the legacy pin from