diff --git a/.claude/settings.json b/.claude/settings.json
index 7d1df9e3c2..c8769deec1 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -11,28 +11,6 @@
}
]
}
- ],
- "UserPromptSubmit": [
- {
- "hooks": [
- {
- "type": "command",
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ponytail-context.mjs\"",
- "timeout": 5
- }
- ]
- }
- ],
- "SubagentStart": [
- {
- "hooks": [
- {
- "type": "command",
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/ponytail-context.mjs\"",
- "timeout": 5
- }
- ]
- }
]
}
}
diff --git a/.github/actions/workspace-dist/action.yml b/.github/actions/workspace-dist/action.yml
index 1033671f32..473f885680 100644
--- a/.github/actions/workspace-dist/action.yml
+++ b/.github/actions/workspace-dist/action.yml
@@ -22,6 +22,16 @@ inputs:
workspace product.
required: false
default: "true"
+ shared-artifact:
+ description: >-
+ Name of this run's uploaded workspace to take the compiled bundles from, instead of restoring
+ them from the cache or building them again. Empty for the job that produces the workspace.
+ Consumers set it because they all `needs:` that producer: an unfiltered `needs: scope` starts
+ them in parallel with it, so every one of them looked for a key nobody had saved yet and
+ compiled the workspace itself (measured at 71-95s each, nine times a run, PRD-481). Such a
+ consumer must keep `save-bundles: "false"` — there is no key of its own to publish.
+ required: false
+ default: ""
runs:
using: composite
@@ -32,6 +42,14 @@ runs:
run: |
echo "node=$(node --version)" >> "$GITHUB_OUTPUT"
echo "started=$(date +%s)" >> "$GITHUB_OUTPUT"
+ # The producer's own path is below: restore the key, or build it when the key misses. Every
+ # consumer takes the upload instead, which is this run's exact bytes for this commit rather than
+ # whichever earlier run last published this key.
+ - name: Take the compiled workspace from this run's producer
+ if: inputs.shared-artifact != ''
+ uses: actions/download-artifact@v4
+ with:
+ name: ${{ inputs.shared-artifact }}
# The key names everything the bundles are built from and nothing else. `src/**`, the package
# manifests, the tsup configs, the per-package generators that write files into the bundle, and
# the script that decides which packages get built at all. Templates are deliberately absent:
@@ -43,6 +61,7 @@ runs:
# rebuild nobody needed.
- name: Restore the compiled workspace
id: dist
+ if: inputs.shared-artifact == ''
uses: actions/cache/restore@v4
env:
# Rebuilding these small bundles is cheaper than the default ten-minute segment wait.
@@ -62,13 +81,13 @@ runs:
echo "workspace dist restored for these sources; skipping the build"
node packages/core/scripts/bundle-engine-mcp.mjs
- name: Build missing workspace bundles
- if: steps.dist.outputs.cache-hit != 'true'
+ if: steps.dist.outputs.cache-hit != 'true' && inputs.shared-artifact == ''
shell: bash
run: |
set -euo pipefail
pnpm tsx scripts/workspace-packages.ts build
- name: Pack current workspace files
- if: inputs.pack-archives != 'false'
+ if: inputs.pack-archives != 'false' && inputs.shared-artifact == ''
shell: bash
run: |
set -euo pipefail
@@ -159,9 +178,9 @@ runs:
if: always()
shell: bash
env:
- DIST_CACHE_HIT: ${{ steps.dist.outputs.cache-hit }}
+ DIST_SOURCE: ${{ inputs.shared-artifact != '' && format('artifact `{0}`', inputs.shared-artifact) || format('cache hit `{0}`', steps.dist.outputs.cache-hit || 'unavailable') }}
DIST_SAVER: ${{ inputs.save-bundles == 'true' && 'yes' || 'no' }}
DIST_STARTED: ${{ steps.toolchain.outputs.started }}
run: |
- printf 'Workspace bundle cache hit: `%s`; publish: `%s`; restore/build/repack/validation execution: `%ss`. This is product reuse, not a test verdict.\n' \
- "${DIST_CACHE_HIT:-unavailable}" "${DIST_SAVER:-unavailable}" "$(( $(date +%s) - ${DIST_STARTED:-$(date +%s)} ))" >> "$GITHUB_STEP_SUMMARY"
+ printf 'Workspace bundles from: %s; publish: `%s`; restore/build/repack/validation execution: `%ss`. This is product reuse, not a test verdict.\n' \
+ "${DIST_SOURCE:-unavailable}" "${DIST_SAVER:-unavailable}" "$(( $(date +%s) - ${DIST_STARTED:-$(date +%s)} ))" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/ci-janitor.yml b/.github/workflows/ci-janitor.yml
new file mode 100644
index 0000000000..cc74e1ce53
--- /dev/null
+++ b/.github/workflows/ci-janitor.yml
@@ -0,0 +1,107 @@
+name: CI janitor
+
+# A merged or closed pull request's runs prove nothing: no verdict can change, no artifact gets
+# read, and the head ref keeps holding runners until each one finishes on its own clock. That
+# cost another 5.0k runner-minutes over 2026-09-18 to 10-02 (PRD-380). Cancelling the head ref's
+# in-flight runs — and the merge group's, which GitHub starts for a queued pull request and then
+# leaves running after it leaves the queue — is the only cleanup a repository can do for itself, and
+# `pull_request: closed` reaches it within a minute of the merge.
+on:
+ pull_request:
+ types: [closed]
+ # A failed CI run whose every failure is a known environmental flake reruns only its failed jobs,
+ # once. Runs from the default branch's copy of this file (a workflow_run rule).
+ workflow_run:
+ workflows: [CI]
+ types: [completed]
+
+# Cancelling a run is an Actions write, and this is the one workflow here that asks for one. A
+# token that cannot do it reports a green job while cancelling nothing, which is the exact
+# failure this workflow exists to prevent — so the grant is explicit rather than inherited.
+permissions:
+ actions: write
+
+jobs:
+ retry-flakes:
+ if: >-
+ github.event_name == 'workflow_run' &&
+ github.event.workflow_run.conclusion == 'failure' &&
+ github.event.workflow_run.run_attempt == 1
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Rerun the failed jobs once when every failure is a known flake
+ env:
+ GH_TOKEN: ${{ github.token }}
+ RUN_ID: ${{ github.event.workflow_run.id }}
+ # Environmental signatures seen on 2026-10-02/03: SwiftShader device loss, a bridge call
+ # outliving its budget, an unreadable WebGPU adapter, a dev server not up yet, a 5xx on a
+ # download, and a runner shut down under its job. Anything else is a real failure.
+ FLAKE: TN_PLAYTEST_SOFTWARE_DEVICE_LOST|Bridge operation '[a-z]+' exceeded|TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING|TN_GOLDEN_PATH_DEV_UNAVAILABLE|ERR_CONNECTION_REFUSED|HTTP 50[0-9]|runner has received a shutdown signal
+ run: |
+ set -euo pipefail
+ # Aggregates (ci-required, golden-path, run-summary) only echo another job's failure.
+ failed="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$RUN_ID/jobs?filter=latest&per_page=100" --paginate \
+ --jq '.jobs[] | select(.conclusion == "failure") | select(.name | test("^(ci-required|golden-path|run-summary)$") | not) | .id')"
+ [ -n "$failed" ] || { echo "No primary job failed; nothing to retry."; exit 0; }
+ for id in $failed; do
+ if ! gh api "repos/$GITHUB_REPOSITORY/actions/jobs/$id/logs" 2>/dev/null | grep -qE "$FLAKE"; then
+ echo "Job $id failed for a reason that is not a known flake; leaving the run red."
+ exit 0
+ fi
+ done
+ gh run rerun "$RUN_ID" --failed --repo "$GITHUB_REPOSITORY"
+ echo "Reran the failed jobs of run $RUN_ID once: every failure matched a known flake." >> "$GITHUB_STEP_SUMMARY"
+
+ cancel:
+ # A fork's runs live in another repository, and its runs cannot be cancelled from here. Trying
+ # would put a red check on every fork PR close, so the guard is the whole job.
+ # A promotion PR's head is `develop`: its runs are develop's own CI, never a lane to sweep.
+ if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref != 'develop' && github.event.pull_request.head.ref != 'main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ # The head ref is branch text chosen by whoever opened the PR. Interpolated into `run:` it
+ # is a command injection into a job holding `actions: write`, so it arrives through `env:`
+ # and every other value below is quoted.
+ - name: Cancel the head ref's and the merge group's queued and in-progress runs
+ env:
+ GH_TOKEN: ${{ github.token }}
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
+ BASE_REF: ${{ github.event.pull_request.base.ref }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ SELF_RUN_ID: ${{ github.run_id }}
+ run: |
+ set -euo pipefail
+ # A merge-group run reports its branch as `gh-readonly-queue//pr--`.
+ # GitHub drops the pull request from the queue on close but never cancels the run it has
+ # already started, so that run holds runners for a verdict nothing will read: on
+ # 2026-10-02 run 37093098790 kept nine self-hosted jobs busy for #404 after it merged.
+ # Built from the event's own base ref and number, so it reaches that group and no other.
+ group_prefix="gh-readonly-queue/${BASE_REF}/pr-${PR_NUMBER}-"
+ # Collected before the loop, so a failed lookup fails this step instead of quietly
+ # cancelling nothing under `set -e`.
+ runs="$(gh run list --repo "$GITHUB_REPOSITORY" --branch "$HEAD_REF" \
+ --json databaseId,status,headBranch \
+ --jq '.[] | select(.status == "queued" or .status == "in_progress") | [.databaseId, .headBranch] | @tsv'
+ gh run list --repo "$GITHUB_REPOSITORY" --json databaseId,status,headBranch --limit 100 \
+ | jq -r --arg prefix "$group_prefix" '.[] | select(.status == "queued" or .status == "in_progress") | select(.headBranch | startswith($prefix)) | [.databaseId, .headBranch] | @tsv'
+ )"
+ cancelled=0
+ while IFS=$'\t' read -r run_id branch; do
+ # A fork's branch may legitimately share this repository's ref name; never reach
+ # past the event's own repository for something to cancel.
+ [ -n "$run_id" ] || continue
+ [ "$run_id" != "$SELF_RUN_ID" ] || continue
+ # A run that completed between the list and the cancel is the normal race, not a
+ # failure: the point of the cancel is already satisfied.
+ # force-cancel, not `gh run cancel`: a plain cancel can hang on a job still queued for a
+ # self-hosted label and keep the run's concurrency slot (run 37098559814).
+ if gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/force-cancel" >/dev/null; then
+ cancelled=$((cancelled + 1))
+ printf 'Cancelled run %s on %s.\n' "$run_id" "$branch"
+ else
+ printf 'Run %s finished before the cancel; nothing to stop.\n' "$run_id"
+ fi
+ done <<< "$runs"
+ printf 'Janitor finished for %s: %s run(s) cancelled.\n' "$HEAD_REF" "$cancelled"
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6b68167224..59d5fcc437 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,11 +11,16 @@ on:
# Drafts run nothing; marking one ready for review starts the board.
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch: {} # Explicit full audit; never a selectable exemption.
+ # PRD-481: the merge queue. GitHub tests the exact result a merge would produce, so two PRs
+ # merged back to back can no longer put a tree on develop that nothing tested. An unchanged base
+ # means the queue's tree is the pull request's tree, and tree reuse makes that instant.
+ merge_group:
+ types: [checks_requested]
schedule:
- cron: "17 3 * * *"
concurrency:
- group: ci-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && 'latest' || github.run_id }}
+ group: ci-${{ github.event_name }}-${{ github.ref }}-${{ github.event_name == 'pull_request' && !github.event.pull_request.draft && 'latest' || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Read-only. The workflow used to hold Actions write access so a failing job could cancel its own
@@ -29,7 +34,7 @@ jobs:
scope:
name: Change scope
if: ${{ !github.event.pull_request.draft }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
outputs:
scope: ${{ steps.classify.outputs.scope }}
@@ -37,6 +42,11 @@ jobs:
reason: ${{ steps.classify.outputs.reason }}
plan: ${{ steps.classify.outputs.plan }}
candidate_sha: ${{ steps.classify.outputs.candidate_sha }}
+ # PRD-481 tree reuse: the classification asks the Actions API which successful run tested this
+ # exact tree. Read-only, and nothing else in the workflow gains from it.
+ permissions:
+ contents: read
+ actions: read
steps:
- uses: actions/checkout@v7
with:
@@ -47,10 +57,13 @@ jobs:
name: Classify the complete pull-request diff
env:
TN_CI_EVENT: ${{ github.event_name }}
- TN_CI_TARGET: ${{ github.base_ref }}
+ # A merge group reports no `base_ref`; its target is on the event. Passing it is what keeps
+ # a develop merge group on the reduced native matrix and a main one on the full matrix.
+ TN_CI_TARGET: ${{ github.base_ref || github.event.merge_group.base_ref }}
TN_CI_FORCE_FULL: ${{ vars.TN_CI_FORCE_FULL }}
TN_CI_BASE: ${{ github.event.pull_request.base.sha }}
TN_CI_HEAD: ${{ github.event.pull_request.head.sha }}
+ GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
extra=()
@@ -79,9 +92,9 @@ jobs:
# job that discards its own node_modules — it only added a serial edge in front of the run.
# The cheap gates now start together, and the expensive ones name the gate they wait on.
typecheck:
- needs: scope
- if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ needs: [scope, build-artifacts]
+ if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }}
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 15
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -97,6 +110,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
pack-archives: "false"
- run: pnpm typecheck
@@ -104,9 +120,11 @@ jobs:
lint:
needs: scope
# A Markdown-only change runs no CI job: the docs lane is re-validated on the nightly run and
- # at promotion, so this job is skipped rather than spending a runner on inert prose.
- if: needs.scope.outputs.selection != 'prose'
- runs-on: ubuntu-latest
+ # 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 == 'instructions' || needs.scope.outputs.selection == 'ci'
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 10
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -162,7 +180,7 @@ jobs:
test:
needs: scope
if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 45
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -192,18 +210,29 @@ jobs:
# that job that does: the docs check, the build and the package walk each make sense once.
#
# Every test still runs. `vitest --shard` slices the same file list, and the slices were checked
- # to add back up: 117 + 117 + 117 = 351 files, union identical to the unsharded suite with no
- # file in two shards. `scripts/__tests__/ci-structure.spec.ts` holds the matrix to that shape,
+ # to add back up: the slices partition the file list, union identical to the unsharded suite with
+ # no file in two shards. `scripts/__tests__/ci-structure.spec.ts` holds the matrix to that shape,
# and `run-test-suite.sh` refuses a phase selection that would run nothing at all.
+ #
+ # Four slices, not three, because `--shard` sorts the file list by a hash of its path and slices
+ # that order, so the split is honest about file count and blind to duration. Measured on run
+ # 37049488719 the three legs ran 163s / 285s / 472s of vitest over 397s / 712s / 709s of test
+ # time, and replaying vitest's own `sha1(path)` partition over those per-file durations
+ # reproduces all three assignments exactly. At four slices the same replay predicts
+ # 80s / 114s / 211s / 320s of vitest, which with the 40-121s of job overhead measured on the same
+ # run puts three legs inside six minutes. The fourth cannot: it carries
+ # `create-threenative/__tests__/template-assets-compile.spec.ts` at 320s on its own, and no shard
+ # count splits a single file. A fifth slice would leave both of those numbers unchanged and buy
+ # one more runner, so four is the whole of it.
test-unit:
- needs: scope
- if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-24.04
+ needs: [scope, build-artifacts]
+ if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }}
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
- shard: ["1/3", "2/3", "3/3"]
+ shard: ["1/4", "2/4", "3/4", "4/4"]
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
TN_SUITE_PHASES: unit
@@ -225,15 +254,18 @@ jobs:
# this job takes the cached result rather than repeating it three times over.
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
pack-archives: "false"
- uses: ./.github/actions/playwright-chromium
- run: pnpm test
test-native:
- needs: scope
+ needs: [scope, build-artifacts]
if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 75
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -356,6 +388,14 @@ jobs:
- name: Restore the compiled native tree
uses: actions/cache@v4
with:
+ # No `.github/workflows/ci.yml` in this key. It named this job's own workflow file, so
+ # every edit to ci.yml — this one included — produced a key no run had ever saved, and the
+ # three pull requests that followed a workflow change each compiled the whole Linux host
+ # and stored their own 1.35 GiB copy. What ci.yml actually contributes to this build is
+ # the toolchain above, and `dpkg-query -W` in that identity already covers every package
+ # this job installs. Exact key only, with no prefix fallback: a tree restored from a
+ # different commit carries objects newer than the sources `restore-source-mtimes.mjs`
+ # stamps below, and ninja would read that as up to date.
# Cargo's output belongs here too. With the C++ compile eliminated by #78, the host build
# step was still ~112s and all of it was Rust: `threenative_native_physics` and the
# `ui-overlay` crate with its webkit2gtk bindings. Measured locally, physics alone is a
@@ -375,7 +415,7 @@ jobs:
packages/runtime-native/build/tn-linux-quickjs
packages/runtime-native/.runtime/physics-target
packages/runtime-native/native/ui-overlay/target
- key: native-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.native-toolchain.outputs.identity }}-${{ hashFiles('packages/runtime-native/scripts/**', 'packages/runtime-native/*.json', 'pnpm-lock.yaml', '.github/workflows/ci.yml', 'packages/runtime-native/CMakeLists.txt', 'packages/runtime-native/CMakePresets.json', 'packages/runtime-native/**/*.cmake', 'packages/runtime-native/src/**', 'packages/runtime-native/include/**', 'packages/runtime-native/tests/**', 'packages/runtime-native/native/**/build.rs', 'packages/runtime-native/native/**/.cargo/**', 'packages/runtime-native/native/**/src/**', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/Cargo.lock') }}
+ key: native-build-${{ runner.os }}-${{ runner.arch }}-${{ steps.native-toolchain.outputs.identity }}-${{ hashFiles('packages/runtime-native/scripts/**', 'packages/runtime-native/*.json', 'pnpm-lock.yaml', 'packages/runtime-native/CMakeLists.txt', 'packages/runtime-native/CMakePresets.json', 'packages/runtime-native/**/*.cmake', 'packages/runtime-native/src/**', 'packages/runtime-native/include/**', 'packages/runtime-native/tests/**', 'packages/runtime-native/native/**/build.rs', 'packages/runtime-native/native/**/.cargo/**', 'packages/runtime-native/native/**/src/**', 'packages/runtime-native/native/**/Cargo.toml', 'packages/runtime-native/native/**/Cargo.lock') }}
# `ccache --show-stats` at the end of the job reports one number for three builds, and that
# number has been stuck at `Hits: 184 / 574 (32.06%)` on every run measured — identical
# before and after each lane got its own restore-key namespace, and with the restore
@@ -452,6 +492,9 @@ jobs:
# imported from '.../packages/runtime-native/tests/production-profile.test.mjs'
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
# The pump-silence observer drives the real host with the public native-smoke bundle. Keep
# this explicit because the shared action only builds packages/*/dist, not examples/*/dist.
@@ -495,9 +538,9 @@ jobs:
if-no-files-found: error
test-browser:
- needs: scope
+ needs: [scope, build-artifacts]
if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ 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 }}
@@ -525,6 +568,9 @@ jobs:
# so they build it themselves — otherwise the run dies on a missing assets/dist/index.js.
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
- uses: ./.github/actions/playwright-chromium
# `playwright.config.ts` scaffolds its starter projects from packed tarballs, and without
@@ -546,9 +592,9 @@ jobs:
if-no-files-found: ignore
test-playtest:
- needs: scope
+ needs: [scope, build-artifacts]
if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ 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 }}
@@ -573,6 +619,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
- uses: ./.github/actions/playwright-chromium
- run: pnpm test:playtest:ci
@@ -586,7 +635,10 @@ jobs:
template:
- starter
- platformer
- runs-on: ubuntu-latest
+ # Hosted on purpose: ~25 short template legs run all at once on hosted runners while builds and
+ # tests hold the local pool. On 5 local slots these legs waited 17-25 min for a runner (run
+ # 37070815769); sharing the board across both pools is what shortens wall time.
+ runs-on: ubuntu-24.04
# The scenario arithmetic this number came from moved out to `template-nonvisual`, which now
# owns the non-visual sweep for all eight templates. What is left is `verify:golden-path` —
# pack, scaffold, install, dev, test, build, assert, once per matrix leg — which measured 309s
@@ -716,10 +768,25 @@ jobs:
# 33734775193 `minimal` finished in 80s and `platformer` took 407s, and the run is only ever as
# fast as its slowest leg. Timestamping platformer's step splits it as ~74s of scaffold install
# and typecheck plus 15 scenarios at ~20s each, so the scenarios are what makes it long and
- # they shard cleanly. Platformer and starter run in thirds. Puzzle was measured at 399s of
- # scenario work on run 33843502361 (carry 134s, swing 255s), so it runs in two halves as well;
- # every other template is already well under the leg it would be sharded against, and splitting
- # them would buy nothing and pay the 74s setup twice.
+ # they shard cleanly. Every slice then has to absorb its own ~65s of leg overhead — 36-51s of
+ # checkout, install and Playwright before the step, 10-42s of target install, typecheck and
+ # display setup inside it — so the number of shards is set by the slowest slice plus that
+ # overhead, not by the total.
+ #
+ # Per-scenario seconds on run 37049488719 — sailing again on 37071464562, which measured its
+ # two slices at 168s and 467s — and the slowest slice each count predicts:
+ # platformer 15 at ~20s -> 3 -> ~150s measured. starter 24 -> 3 -> ~210s measured.
+ # puzzle 10, 399s of work -> 2 -> ~197s measured. runner/snow/rts/racing/tower-defense/
+ # action-rpg/minimal keep one leg each; all measured under 280s.
+ # shooter 26 at 32-47s, 922s total -> 4 -> 363s, 5 -> 356s, 6 -> 288s. Six is the count that
+ # clears six minutes, and 65s of that is the leg overhead.
+ # rain 5 at 331/202/222/78/61 -> 5 -> 396s. Five is its floor: five scenarios, so one per
+ # slice, and its heaviest scenario alone is 331s of software rendering. It cannot reach six
+ # minutes at any shard count and is left honest rather than split past its scenario count.
+ # sailing 6 at 55/247/30/98/28/59 -> 2 -> 467s measured, 3 -> 335s. The second slice is
+ # the change: at two it carried the 247s float-after-the-run-ends alone with the sails and
+ # win scenarios for 404s of scenario work, 63s over the leg budget. Three puts one of the
+ # heavy scenarios on each leg and the worst of them at ~275s of work plus ~60s of overhead.
#
# Every scenario still runs. `shard` selects a slice of the same classifier output the whole
# lane has always used, and the step fails closed if the slices do not add back up to the list.
@@ -737,15 +804,30 @@ jobs:
- { template: puzzle, shard: "1/2" }
- { template: puzzle, shard: "2/2" }
- { template: racing, shard: "1/1" }
+ - { template: rain, shard: "1/5" }
+ - { template: rain, shard: "2/5" }
+ - { template: rain, shard: "3/5" }
+ - { template: rain, shard: "4/5" }
+ - { template: rain, shard: "5/5" }
- { template: rts, shard: "1/1" }
- { template: runner, shard: "1/1" }
- - { template: sailing, shard: "1/1" }
- - { template: shooter, shard: "1/1" }
+ - { template: sailing, shard: "1/3" }
+ - { template: sailing, shard: "2/3" }
+ - { template: sailing, shard: "3/3" }
+ - { template: shooter, shard: "1/6" }
+ - { template: shooter, shard: "2/6" }
+ - { template: shooter, shard: "3/6" }
+ - { template: shooter, shard: "4/6" }
+ - { template: shooter, shard: "5/6" }
+ - { template: shooter, shard: "6/6" }
+ - { template: snow, shard: "1/1" }
- { template: starter, shard: "1/3" }
- { template: starter, shard: "2/3" }
- { template: starter, shard: "3/3" }
- { template: tower-defense, shard: "1/1" }
- runs-on: ubuntu-latest
+ # Balanced like the rest: the pool advertises itself only while a runner is idle, so these legs
+ # take local slots when free and overflow to hosted otherwise, never queueing behind the pool.
+ 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 }}
@@ -844,7 +926,7 @@ jobs:
# as cancelled, so the skip is correct there; it still cannot be mistaken for a pass, because a
# cancelled run has no passing checks to merge on.
if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -861,9 +943,9 @@ jobs:
test "$result" = "success"
benchmark:
- needs: scope
+ needs: [scope, build-artifacts]
if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 25
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -879,6 +961,9 @@ jobs:
- run: pnpm install --frozen-lockfile
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
pack-archives: "false"
- run: pnpm tsx scripts/count-loc.ts --check
@@ -914,7 +999,7 @@ jobs:
build:
needs: [scope, build-artifacts]
if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 2
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -935,8 +1020,8 @@ jobs:
# the protected build join above requires both this producer and the complete native lane.
build-artifacts:
needs: scope
- if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }}
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 20
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -980,7 +1065,7 @@ jobs:
performance-contracts:
needs: scope
if: ${{ needs.scope.outputs.selection == 'full' && !cancelled() }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 2
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -1044,9 +1129,9 @@ jobs:
if-no-files-found: warn
budgets:
- needs: scope
- if: ${{ needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ needs: [scope, build-artifacts]
+ if: ${{ needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci' }}
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 15
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -1069,6 +1154,9 @@ jobs:
# reads like a guarantee it does not make. The shared action is what actually supplies dist.
- uses: ./.github/actions/workspace-dist
with:
+ # Compiled once by build-artifacts and downloaded rather than rebuilt: this job is
+ # ordered behind it above, so the upload always exists and the cache key never misses.
+ shared-artifact: workspace-packages
save-bundles: "false"
pack-archives: "false"
- run: pnpm budgets
@@ -1099,8 +1187,9 @@ jobs:
supply-chain:
needs: scope
# 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.
- if: needs.scope.outputs.selection != 'prose'
+ # 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 == 'instructions' || needs.scope.outputs.selection == 'ci'
# 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
@@ -1168,8 +1257,13 @@ jobs:
ci-required:
needs: [scope, 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]
if: ${{ always() && !github.event.pull_request.draft }}
- runs-on: ubuntu-latest
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
+ # A reused verdict is only a pass if the run it cites passed its own ci-required job, and that
+ # is an Actions API read rather than anything this repository can compute.
+ permissions:
+ contents: read
+ actions: read
steps:
- uses: actions/checkout@v7
with:
@@ -1183,6 +1277,7 @@ jobs:
TN_CI_HEAD_REF: ${{ github.head_ref }}
TN_CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
TN_CI_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ GH_TOKEN: ${{ github.token }}
run: node scripts/ci-required.mjs
# PRD-296 item 3. Three gates once sat behind a permanently red one and were skipped on every
@@ -1221,8 +1316,8 @@ jobs:
- ci-required
# `!cancelled()` and not `always()`, for the reason golden-path gives above: a cancelled run
# has nothing to report and its summary would only describe the cancellation.
- if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-latest
+ if: ${{ !cancelled() && (needs.scope.outputs.selection == 'full' || needs.scope.outputs.selection == 'ci') }}
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
permissions:
contents: read
diff --git a/.github/workflows/integration-csg.yml b/.github/workflows/integration-csg.yml
deleted file mode 100644
index 9237afd587..0000000000
--- a/.github/workflows/integration-csg.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Integration csg
-on:
- pull_request:
- paths:
- - 'examples/integrations/csg/**'
- - '.github/workflows/integration-csg.yml'
- push:
- paths:
- - 'examples/integrations/csg/**'
- - '.github/workflows/integration-csg.yml'
-permissions:
- contents: read
-concurrency:
- group: integration-csg-${{ github.event.pull_request.head.ref || github.ref_name }}
- cancel-in-progress: true
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- defaults:
- run:
- working-directory: examples/integrations/csg
- steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- with:
- persist-credentials: false
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
- with:
- node-version: '22'
- - run: npm install --ignore-scripts --no-audit --no-fund
- - id: build
- run: npm run build
- - run: npm run test:contracts
- - if: ${{ !cancelled() && steps.build.outcome == 'success' }}
- run: npm run test:integration
diff --git a/.github/workflows/integration-ik.yml b/.github/workflows/integration-ik.yml
deleted file mode 100644
index 39f70a8622..0000000000
--- a/.github/workflows/integration-ik.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: Integration ik
-on:
- pull_request:
- paths:
- - 'examples/integrations/ik/**'
- - '.github/workflows/integration-ik.yml'
-permissions:
- contents: read
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- defaults:
- run:
- working-directory: examples/integrations/ik
- steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- with:
- persist-credentials: false
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
- with:
- node-version: '22'
- - run: npm ci --ignore-scripts --no-audit --no-fund
- - run: npm test
diff --git a/.github/workflows/integration-vegetation.yml b/.github/workflows/integration-vegetation.yml
deleted file mode 100644
index e1b2b9a31e..0000000000
--- a/.github/workflows/integration-vegetation.yml
+++ /dev/null
@@ -1,24 +0,0 @@
-name: Integration vegetation
-on:
- pull_request:
- paths:
- - 'examples/integrations/vegetation/**'
- - '.github/workflows/integration-vegetation.yml'
-permissions:
- contents: read
-jobs:
- test:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- defaults:
- run:
- working-directory: examples/integrations/vegetation
- steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- with:
- persist-credentials: false
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
- with:
- node-version: '22'
- - run: npm ci --ignore-scripts --no-audit --no-fund
- - run: npm test
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000000..51e898d1e7
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,246 @@
+name: Integration lanes
+
+# One workflow, one job per feature. A per-feature workflow file means a per-feature set of
+# triggers, permissions, concurrency groups and review surface, none of which any of these lanes
+# needs of its own: each job below keeps its own paths gate, its own draft guard, its own runner and
+# its own artifact names, so what a pull request runs is unchanged.
+on:
+ pull_request:
+ # `ready_for_review` is what makes the draft guards below safe: without it the workflow never
+ # fires on the event that un-skips a job, and the lanes go quiet instead of cheap.
+ types: [opened, synchronize, reopened, ready_for_review]
+
+permissions:
+ contents: read
+
+# `pull_request` alone, never `pull_request` plus `push`: one commit pushed to a branch reaches both
+# triggers, so the pair ran the identical proof twice for the same tree. A per-branch `push` filter is
+# the other shape that outlives its pull request — `integration-decals` carried
+# `fix/vq11-decal-material-lifetime` for a merged pull request #394 and kept a 30-minute lane alive
+# for as long as the branch existed.
+#
+# The supersession `integration-csg` and `integration-decals` declared, kept once for the set: a newer
+# commit on the pull request's head cancels the integration run it replaces.
+concurrency:
+ group: integration-${{ github.event.pull_request.head.ref || github.ref_name }}
+ cancel-in-progress: true
+
+jobs:
+ # The per-lane trigger filters these five files used to carry, as one gate: the pull request's own
+ # changed paths decide which lanes run, and a lane this commit does not touch is skipped rather than
+ # spending a runner. Editing this workflow keeps every lane's gate true, which is what
+ # `.github/workflows/integration-.yml` did for each of them.
+ paths:
+ name: lanes this pull request touches
+ if: ${{ !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
+ permissions:
+ contents: read
+ pull-requests: read
+ outputs:
+ csg: ${{ steps.filter.outputs.csg }}
+ decals: ${{ steps.filter.outputs.decals }}
+ ik: ${{ steps.filter.outputs.ik }}
+ tone: ${{ steps.filter.outputs.tone }}
+ vegetation: ${{ steps.filter.outputs.vegetation }}
+ steps:
+ - id: filter
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR: ${{ github.event.pull_request.number }}
+ # One API call and a regex per lane, translated from each lane's former `paths:` globs.
+ run: |
+ set -euo pipefail
+ files="$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename')"
+ lane() { if grep -qE "$2" <<<"$files"; then echo "$1=true"; else echo "$1=false"; fi >> "$GITHUB_OUTPUT"; }
+ lane csg '^examples/integrations/csg/.*$|^\.github/workflows/integration\.yml$'
+ lane decals '^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/.*$|^\.github/workflows/integration\.yml$'
+ lane ik '^examples/integrations/ik/.*$|^\.github/workflows/integration\.yml$'
+ lane tone '^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^\.github/workflows/integration\.yml$'
+ lane vegetation '^examples/integrations/vegetation/.*$|^\.github/workflows/integration\.yml$'
+
+ csg:
+ name: csg
+ if: ${{ needs.paths.outputs.csg == 'true' && !github.event.pull_request.draft }}
+ needs: paths
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: examples/integrations/csg
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ - run: npm install --ignore-scripts --no-audit --no-fund
+ - id: build
+ run: npm run build
+ - run: npm run test:contracts
+ - if: ${{ !cancelled() && steps.build.outcome == 'success' }}
+ run: npm run test:integration
+
+ ik:
+ name: ik
+ if: ${{ needs.paths.outputs.ik == 'true' && !github.event.pull_request.draft }}
+ needs: paths
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: examples/integrations/ik
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ - run: npm test
+
+ vegetation:
+ name: vegetation
+ if: ${{ needs.paths.outputs.vegetation == 'true' && !github.event.pull_request.draft }}
+ needs: paths
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+ timeout-minutes: 10
+ defaults:
+ run:
+ working-directory: examples/integrations/vegetation
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ - run: npm ci --ignore-scripts --no-audit --no-fund
+ - run: npm test
+
+ decals:
+ name: decals
+ if: ${{ needs.paths.outputs.decals == 'true' && !github.event.pull_request.draft }}
+ needs: paths
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: ./.github/actions/pnpm
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/workspace-dist
+ with:
+ pack-archives: 'false'
+ save-bundles: 'false'
+ - uses: ./.github/actions/playwright-chromium
+ - run: pnpm exec vitest run --maxWorkers=1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts packages/create-threenative/__tests__/template-runtime-cost.spec.ts
+ - name: Capture actual receiver projection, motion, removal and restart
+ run: node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: vq11-decals-${{ github.event.pull_request.head.sha || github.sha }}
+ path: artifacts/vq11-decals
+ if-no-files-found: error
+
+ decals-native:
+ name: Linux native decal correctness
+ needs: [decals]
+ runs-on: ubuntu-24.04-arm
+ timeout-minutes: 60
+ env:
+ CMAKE_BUILD_PARALLEL_LEVEL: '2'
+ TN_PLAYTEST_ALLOW_SOFTWARE: '1'
+ TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000'
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ - uses: ./.github/actions/pnpm
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ cache: pnpm
+ - uses: actions/setup-python@v7
+ with:
+ python-version: '3.12'
+ - run: pnpm install --frozen-lockfile
+ - name: Build this source's public JavaScript packages
+ uses: ./.github/actions/workspace-dist
+ with:
+ save-bundles: 'false'
+ pack-archives: 'false'
+ - name: Verify native preflight and capture rejection controls
+ run: pnpm exec vitest run packages/create-threenative/__tests__/shooter-decals-native.spec.ts --maxWorkers=1
+ - name: Restore checksum-provisioned native dependencies
+ uses: actions/cache/restore@v4
+ with:
+ path: packages/runtime-native/third_party
+ key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }}
+ - name: Install the existing native lane's system prerequisites
+ run: >-
+ sudo apt-get update && sudo apt-get install -y
+ libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev
+ libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev
+ libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev
+ libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr
+ libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev
+ - name: Build the ordinary ARM desktop host
+ run: pnpm --filter @threenative/runtime-native native:build
+ - name: Run the same packaged decal scenarios and unchanged pixel gates
+ env:
+ THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral
+ run: node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts
+ - name: Retain native screenshots, host console and executable identities
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: vq11-decals-native-${{ github.event.pull_request.head.sha || github.sha }}
+ path: |
+ artifacts/vq11-decals-native/
+ !artifacts/vq11-decals-native/variants/**
+ retention-days: 30
+ if-no-files-found: warn
+
+ tone:
+ name: exposure
+ if: ${{ needs.paths.outputs.tone == 'true' && !github.event.pull_request.draft }}
+ needs: paths
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
+ with:
+ persist-credentials: false
+ ref: ${{ github.event.pull_request.head.sha }}
+ - uses: ./.github/actions/pnpm
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
+ with:
+ node-version: '22'
+ cache: pnpm
+ - run: pnpm install --frozen-lockfile
+ - uses: ./.github/actions/playwright-chromium
+ - name: Build this commit's standalone bridge and runner
+ run: pnpm --filter @threenative/playtest build
+ - name: Validate histogram and fail-closed assertion paths
+ run: pnpm exec vitest run --maxWorkers=1 packages/playtest/__tests__/capture.spec.ts packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts scripts/__tests__/tone-capture-proof.spec.ts
+ - name: Render underexposed and restored scene through the real runner
+ run: pnpm test:tone
+ - name: Retain runtime screenshots and exact adapter evidence
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: tone-exposure-${{ github.event.pull_request.head.sha }}
+ path: artifacts/tone-exposure
+ if-no-files-found: error
diff --git a/.github/workflows/native-platforms.yml b/.github/workflows/native-platforms.yml
index a9ad933fe1..3fad8e2b40 100644
--- a/.github/workflows/native-platforms.yml
+++ b/.github/workflows/native-platforms.yml
@@ -53,7 +53,10 @@ jobs:
# The caller is the single selection authority. Dispatch has no exemption inputs and is full.
scope:
name: Validate caller selection and candidate
- runs-on: ubuntu-latest
+ # PRD-480. `github.event` inside a `workflow_call` is the caller's event payload, so this
+ # expression reads the same `pull_request` fork flag ci.yml did when it called this file: a fork
+ # PR falls through to hosted runners and never reaches the owner's machine.
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
outputs:
scope: ${{ steps.classify.outputs.scope }}
@@ -61,6 +64,10 @@ jobs:
reason: ${{ steps.classify.outputs.reason }}
plan: ${{ steps.classify.outputs.plan }}
candidate_sha: ${{ steps.classify.outputs.candidate_sha }}
+ # PRD-380 phase 2: `full` for a main push, the nightly, an explicit audit, a merge group or pull
+ # request into main; `reduced` for an ordinary pull request, which runs the Linux rows only.
+ native_tier: ${{ steps.classify.outputs.native_tier }}
+ starter_rows: ${{ steps.rows.outputs.starter_rows }}
steps:
- uses: actions/checkout@v7
with:
@@ -78,6 +85,34 @@ jobs:
fi
grep -Fx "candidate_sha=$candidate" "$RUNNER_TEMP/ci-scope.outputs"
cat "$RUNNER_TEMP/ci-scope.outputs" >> "$GITHUB_OUTPUT"
+ # A job's `if` cannot read `matrix` (GitHub's context table lists github, needs, vars and
+ # inputs there), so a row an ordinary pull request does not owe is dropped by shaping the
+ # matrix instead. x64 and arm64 run the identical release-container proof; the arm64 leg is
+ # GitHub's free hosted `ubuntu-24.04-arm` runner, because there is no V8 or Dawn prebuilt for
+ # Linux arm64 (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64) and a
+ # source V8 build is not allowed in CI — so that host is built with QuickJS over wgpu-native,
+ # the one WebGPU backend publishing an arm64 Linux archive, which scripts/download-deps.mjs
+ # and scripts/native-build.mjs select from `process.arch`. A reduced run keeps x64 alone, and
+ # x64 routes through the same TN_RUNNER switch every other movable Linux job uses (PRD-480).
+ - id: rows
+ env:
+ TN_NATIVE_TIER: ${{ steps.classify.outputs.native_tier }}
+ run: |
+ set -euo pipefail
+ node -e '
+ const rows = [
+ { platform: "linux-x64", runner: "${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}" },
+ { platform: "linux-arm64", runner: "ubuntu-24.04-arm" },
+ ];
+ // Reduced only when the tier says so: an unreadable tier keeps every row.
+ const reduced = process.env.TN_NATIVE_TIER === "reduced";
+ // A keyed object, never a bare array. GitHub expands `strategy.matrix` from an axis
+ // vector or from `include`, and run 37100382876 wrote the array: the rows were set, the
+ // step passed, and `starter-linux` never started — no job, no error — while the reusable
+ // workflow still concluded failure, so `ci-required` read a red native lane with every
+ // leg green. `{"include":[…]}` is the same two rows in the shape GitHub expands.
+ process.stdout.write(`starter_rows=${JSON.stringify({ include: reduced ? rows.slice(0, 1) : rows })}\n`);
+ ' >> "$GITHUB_OUTPUT"
web-reference:
needs: scope
@@ -89,7 +124,7 @@ jobs:
if: >-
needs.scope.outputs.selection == 'full' &&
inputs.ios_only == false
- runs-on: ubuntu-24.04
+ 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 }}
@@ -136,7 +171,7 @@ jobs:
name: Android V8 source payload
needs: scope
if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
# 180-minute build ceiling plus 30 minutes for actions/cache/save, per the action's note.
timeout-minutes: 210
outputs:
@@ -186,6 +221,9 @@ jobs:
needs.android-v8-source.outputs.published == 'false' &&
github.event_name != 'pull_request' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop')
+ # PRD-480 leaves this one on GitHub's machines: it is the release write itself — `contents:
+ # write` and a `gh release upload` below — and no pool container is handed a token that can
+ # publish. It is named on the hosted allow-list in ci-structure.spec.ts.
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
@@ -213,7 +251,7 @@ jobs:
name: Native collector evidence coverage
needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator]
if: ${{ !cancelled() && needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 5
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -248,7 +286,7 @@ jobs:
name: Networking qualification matrix
needs: [scope, android-emulator-parity, desktop-parity, desktop, ios-simulator]
if: ${{ always() && needs.scope.outputs.selection == 'full' }}
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER_LIGHT) && 'ubuntu-24.04' || vars.TN_RUNNER_LIGHT }}
timeout-minutes: 5
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -316,6 +354,8 @@ jobs:
needs.scope.outputs.selection == 'full' &&
inputs.ios_only != true &&
needs.android-v8-source.result == 'success'
+ # Hosted, measured: SwiftShader renders the emulator's GPU on the CPU, and on a 4-thread tn-local
+ # slot run 37082733117 spent 41 min of this job's 45 still running APKs (hosted finishes in ~30).
runs-on: ubuntu-24.04
timeout-minutes: 45
env:
@@ -447,8 +487,14 @@ jobs:
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
+ # `|| true` on both udev calls, because the routed `tn-local` container cannot do what
+ # they do: no udev is running in it and its /sys is read-only, so `udevadm trigger`
+ # fails there for a reason that has nothing to do with acceleration. The container does
+ # not need them — `scripts/ci-runners.sh` passes `--device /dev/kvm` and the image's
+ # entrypoint sets its mode before the job starts — and a step that fails on the way to
+ # the report below costs this lane the whole emulator run.
+ sudo udevadm control --reload-rules || true
+ sudo udevadm trigger --name-match=kvm || true
# Report the mode, never assert it. `test -w /dev/kvm` as the last line of this step —
# copied from native-release.yml, where nothing has exercised it — turned "this runner
# has no KVM" into a failed job, which is worse than the slow boot it was meant to fix.
@@ -740,7 +786,7 @@ jobs:
if: >-
needs.scope.outputs.selection != 'prose' &&
inputs.ios_only == false
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 20
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -812,7 +858,7 @@ jobs:
if: >-
needs.scope.outputs.selection == 'full' &&
inputs.ios_only != true
- runs-on: ubuntu-24.04
+ runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
timeout-minutes: 75
env:
TN_CI_SHA: ${{ needs.scope.outputs.candidate_sha }}
@@ -1003,9 +1049,11 @@ jobs:
name: ${{ matrix.platform }} desktop core
needs: scope
# Runs on every event since 2026-09-01 (owner call) — see android-emulator-parity. The only
- # remaining gate is the manual `ios_only` dispatch toggle, which exists to run the iOS lane
- # alone; it is not a skip of the default flow.
- if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true
+ # remaining gates are the manual `ios_only` dispatch toggle, which exists to run the iOS lane
+ # alone, and the tier: macOS and Windows are not Linux rows, so an ordinary pull request's
+ # reduced matrix (PRD-380 phase 2) skips them rather than holding two hosted runners per push.
+ # No label exempts them, and main, the nightly and an audit keep the full tier.
+ if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true && needs.scope.outputs.native_tier == 'full'
strategy:
fail-fast: false
matrix:
@@ -1242,8 +1290,12 @@ jobs:
# The release container is a ZIP on macOS and Windows and desktop-distribution.mjs shells
# out to `zip -r -q`. A missing archiver must be a red job, never a silent pass.
if [ "${{ matrix.platform }}" = "Windows" ]; then
- choco install zip -y --no-progress
- choco install nsis --version=3.11.0 -y --no-progress
+ # The community feed drops requests now and then ("package was not found" for a listed
+ # version, run 37079046851). Retry twice; makensis -VERSION below still fails closed.
+ for attempt in 1 2 3; do
+ choco install zip -y --no-progress && choco install nsis --version=3.11.0 -y --no-progress && break
+ [ "$attempt" = 3 ] || sleep 30
+ done
nsis='/c/Program Files (x86)/NSIS'
"$nsis/makensis.exe" -VERSION
cygpath -w "$nsis" >> "$GITHUB_PATH"
@@ -1757,18 +1809,17 @@ jobs:
if: needs.scope.outputs.selection == 'full' && inputs.ios_only != true
strategy:
fail-fast: false
- matrix:
- include:
- # x64 and arm64 run the identical release-container proof. The arm64 leg is GitHub's
- # free hosted `ubuntu-24.04-arm` runner: there is no V8 or Dawn prebuilt for Linux arm64
- # (kuoruan/libv8 ships Linux x64 only; Dawn's ubuntu-latest asset is x64) and a source V8
- # build is not allowed in CI, so the arm64 host is built with QuickJS over wgpu-native —
- # the one WebGPU backend that publishes an arm64 Linux archive. scripts/download-deps.mjs
- # and scripts/native-build.mjs select that route from `process.arch`.
- - platform: linux-x64
- runner: ubuntu-24.04
- - platform: linux-arm64
- runner: ubuntu-24.04-arm
+ # The arm64 row is not a row an ordinary pull request owes: it is the free hosted
+ # `ubuntu-24.04-arm`, kept for main, the nightly and an explicit audit so the arm64 host keeps
+ # proving itself on the branch that ships it. `scope` shapes the rows from the tier it
+ # resolved, so this job runs linux-x64 alone on a reduced run and both rows otherwise. The
+ # routing values live beside the shaping, on the runner PRD-480 fixed: the movable x64 leg
+ # goes through the TN_RUNNER switch and falls back to the same hosted image, arm64 stays
+ # hosted because the owner's machine is x64. That shaping is never empty — a run owing no
+ # native lane at all is skipped at the call site in ci.yml, so the tiers that reach this job
+ # always owe x64 — and a leg the tier does drop is skipped by the `desktop` and
+ # `ios-simulator` `if:` gates, which GitHub reports as skipped, not failed.
+ matrix: ${{ fromJSON(needs.scope.outputs.starter_rows) }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 50
env:
@@ -2002,7 +2053,10 @@ jobs:
ios-simulator:
name: iOS simulator runtime and no-Xcode consumer handoff
needs: scope
- if: needs.scope.outputs.selection == 'full'
+ # A macOS-15 runner and an Apple toolchain are the opposite of a Linux row, so an ordinary pull
+ # request's reduced matrix (PRD-380 phase 2) skips this leg. It still runs on main, the nightly,
+ # an explicit audit and any merge group or pull request into main.
+ if: needs.scope.outputs.selection == 'full' && needs.scope.outputs.native_tier == 'full'
# Runs on every event since 2026-09-01 (owner call) — see android-emulator-parity.
runs-on: macos-15
timeout-minutes: 60
diff --git a/.github/workflows/native-release.yml b/.github/workflows/native-release.yml
index 71893d361d..f7b630adcd 100644
--- a/.github/workflows/native-release.yml
+++ b/.github/workflows/native-release.yml
@@ -15,11 +15,14 @@ on:
tags:
- "runtime-native-v*"
pull_request:
- paths:
- - ".github/workflows/native-release.yml"
- - "scripts/__tests__/native-release-proof.spec.ts"
- - "scripts/__tests__/native-release-android-staging.spec.ts"
- - "packages/runtime-native/tests/native-platform-workflow.test.mjs"
+ # The proof is the most expensive board in this repository (macOS, Windows, Linux and a
+ # 210-minute Android producer) and an ordinary PR push never needed it: over 2026-09-18 to
+ # 10-02 it cost 7.0k runner-minutes against a pool that holds about three runs. It runs for
+ # a promotion PR into main, or when a PR carries the `release-proof` label - `labeled` is
+ # what makes that route reachable, and the open/reopen events are what make a promotion PR
+ # reach it. The `paths` filter is gone: it was the thing that re-ran the board on every push
+ # to four files, one of them touched by ordinary runtime-native PRs.
+ types: [opened, synchronize, reopened, labeled]
workflow_dispatch:
permissions:
@@ -27,24 +30,24 @@ permissions:
actions: read
concurrency:
- # Measured over this workflow's entire history, re-counted 2026-09-11: 41 runs, 18 cancelled,
- # 17 failed, 4 succeeded. The four successes are PRD-078's non-publishing hosted proof, and they
- # arrived only after this setting stopped evicting them - the earlier count behind this choice was
- # 18 runs with zero successes. Every cancellation was a proof run killed by the next push to its
- # own branch. `clean-consumer` sits behind a ~20 minute build matrix, so on an actively-pushed
- # branch it cannot reach its own first line no matter what its timeout is - which is why this sits
- # alongside the timeout raise rather than instead of it. Evidence here is candidate-keyed, so a
- # superseded run's output is still valid for the SHA it was produced from and is worth letting
- # finish.
- # `github.event_name` is in the group because a manual proof on main and an automatic one would
- # otherwise share `native-release-refs/heads/main` and evict each other.
- # `github.event.workflow_run.head_sha` is in the group because every `workflow_run` run shares
- # `github.ref` (the default branch) and `github.event_name` (`workflow_run`): without the head
- # SHA they all serialize behind each other in one group while the Actions queue drains. A new CI
- # completion for a newer main SHA gets its own group instead of queueing behind a superseded
- # evidence run; a re-run for the same SHA still shares its group. Tag publication stays
- # per-ref as before.
- group: native-release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha }}-${{ github.ref }}
+ # One group for every run of this workflow, whatever triggered it. It used to carry
+ # `github.event_name`, the triggering CI head SHA and `github.ref`, so a promotion PR, a manual
+ # dispatch, a tag push and each main CI completion each held a runner simultaneously - and a
+ # two-minute `build` join waited over an hour behind them (PRD-380).
+ #
+ # What that keying bought: a new CI completion for a newer main SHA got its own group instead of
+ # queueing behind a superseded evidence run. That is now the intended trade - the queue behind a
+ # superseded proof is a few minutes of one runner, where running them beside each other was the
+ # whole pool.
+ #
+ # `cancel-in-progress: false` is unchanged and load-bearing: every cancellation in this
+ # workflow's history was a proof run killed by the next push to its own branch, and a
+ # candidate-keyed result is still valid for the SHA it was produced from.
+ #
+ # Only a run that will prove joins the shared group. Every ordinary PR push still starts this
+ # workflow (its `gates` job then refuses it), and in one shared group those no-op runs would queue
+ # behind a real proof and replace a pending one, since a group keeps a single pending run.
+ group: ${{ (github.event_name != 'pull_request' || github.event.pull_request.base.ref == 'main' || contains(github.event.pull_request.labels.*.name, 'release-proof')) && 'native-release-proof' || format('native-release-skip-{0}', github.run_id) }}
cancel-in-progress: false
jobs:
@@ -117,8 +120,15 @@ jobs:
gates:
needs: validate-tag
# Scheduled/manual CI also completes on main, but only push CI qualifies a release.
+ # And the pull_request trigger fires on every push now (the label needs `labeled`), so this
+ # job is where an ordinary PR is refused: PRD-373 retired the `promotion/` ref, so a PR
+ # targeting main is the promotion, and `release-proof` is the owner's manual route for any
+ # other PR. Every proof job below needs `gates`, so refusing here spends nothing.
if: >-
${{ !cancelled() &&
+ (github.event_name != 'pull_request' ||
+ github.event.pull_request.base.ref == 'main' ||
+ contains(github.event.pull_request.labels.*.name, 'release-proof')) &&
(github.event_name != 'workflow_run' || github.event.workflow_run.event == 'push') &&
(github.ref_type != 'tag' || needs.validate-tag.result == 'success') }}
runs-on: ubuntu-24.04
diff --git a/.github/workflows/pipeline-cache.yml b/.github/workflows/pipeline-cache.yml
index f35b4da29b..e143871462 100644
--- a/.github/workflows/pipeline-cache.yml
+++ b/.github/workflows/pipeline-cache.yml
@@ -9,8 +9,12 @@ on:
- packages/runtime-native/**
- packages/playtest/**
- patches/wgpu-native@25.0.2.2.patch
+ # No `feat/prd-368-persistent-pipeline-cache`: the lane it was opened for landed on 2026-09-10 and
+ # the branch only kept a 31-minute android-arm64 ABI job alive on every push nobody was reading
+ # (2.4k runner-minutes over 41 runs, PRD-481). A per-branch trigger outlives its pull request by
+ # default; `scripts/__tests__/ci-structure.spec.ts` now fails closed on the next one.
push:
- branches: [main, feat/prd-368-persistent-pipeline-cache]
+ branches: [main]
paths:
- .github/workflows/pipeline-cache.yml
- packages/runtime-native/**
diff --git a/AGENTS.md b/AGENTS.md
index b78c59b2e0..abac05e411 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,6 +25,8 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro
- Branch from `origin/develop` (not a local branch that may be ahead of it), target `develop`, squash-merge. Set `git config threenative.integrationBranch develop` per checkout.
- The commit that finishes a PRD also `git mv`s it to `docs/PRDs/done/`.
- `main` takes only the ordinary full-checked `develop -> main` PR, merged with a merge commit, never squash or rebase.
+- **Push once per run**: while your PR's CI runs, commit locally; push to fix that run or after it finishes. A PRD tick rides with the next code push, never alone.
+- **No workflow file per feature**: add its CI proof as a job in an existing workflow.
- Before retargeting, inventory `gh pr list` and `pnpm worktree:status`; retarget in-flight PRs one at a time and never rewrite another worktree.
## Where a change goes
@@ -75,7 +77,7 @@ pnpm native:build # opt-in; downloads deps, compiles th
pnpm native:verify:desktop # 300 native frames + a non-blank screenshot
```
-**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; everything else, plus main PRs, main pushes and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. A new commit on a PR head re-runs its checks. Never cache test verdicts. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
+**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; a change touching only CI configuration (`.github/workflows/*.yml`, `scripts/ci-*`, their specs, `tools/ci-runners/`) runs the `ci` selection — `typecheck`, `lint`, `budgets`, `supply-chain` and the unit shards; everything else, plus main PRs, main pushes, the merge queue and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. An ordinary pull request runs that lane's Linux rows only — macOS, Windows, iOS and arm64 are the full matrix, kept for `main` pushes, the nightly, an audit and anything targeting `main` — so a PR's own evidence never carries those legs. A new commit on a PR head re-runs its checks. Reuse a verdict only for an identical whole-repo tree that CI itself passed. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file.
TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed.
@@ -87,7 +89,7 @@ TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions c
Results go in the existing PRD, PR or response; a separate evidence file only when the user asks or a workflow consumes it. Prose edits need only the doc checks and mirror regeneration — no implementation checkpoints or artificial negative controls.
**Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour.
-**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell.
+**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell.
Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree.
## Working outside the repo
diff --git a/CLAUDE.md b/CLAUDE.md
index a210fa8869..dada8c70f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,6 +27,8 @@ One source, two runtimes: browser WebGPU and an owned C++ host for desktop/Andro
- Branch from `origin/develop` (not a local branch that may be ahead of it), target `develop`, squash-merge. Set `git config threenative.integrationBranch develop` per checkout.
- The commit that finishes a PRD also `git mv`s it to `docs/PRDs/done/`.
- `main` takes only the ordinary full-checked `develop -> main` PR, merged with a merge commit, never squash or rebase.
+- **Push once per run**: while your PR's CI runs, commit locally; push to fix that run or after it finishes. A PRD tick rides with the next code push, never alone.
+- **No workflow file per feature**: add its CI proof as a job in an existing workflow.
- Before retargeting, inventory `gh pr list` and `pnpm worktree:status`; retarget in-flight PRs one at a time and never rewrite another worktree.
## Where a change goes
@@ -77,7 +79,7 @@ pnpm native:build # opt-in; downloads deps, compiles th
pnpm native:verify:desktop # 300 native frames + a non-blank screenshot
```
-**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; everything else, plus main PRs, main pushes and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. A new commit on a PR head re-runs its checks. Never cache test verdicts. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
+**CI.** The pre-push hook runs `pnpm ci:fast` drift checks only; it proves no runtime behaviour. `pnpm ci:local --full` runs the full board; `pnpm ci:local --affected --base origin/develop --target develop` runs what CI would select (dirty or unknown inputs fall back to full). A change touching only Markdown that no fixture consumes (not `AGENTS.md`/`CLAUDE.md`) runs no CI job; a change touching only CI configuration (`.github/workflows/*.yml`, `scripts/ci-*`, their specs, `tools/ci-runners/`) runs the `ci` selection — `typecheck`, `lint`, `budgets`, `supply-chain` and the unit shards; everything else, plus main PRs, main pushes, the merge queue and nightly, is full. `ci-required` rejects missing or failed selected checks; full keeps `typecheck`, `lint`, `build`, `budgets`, `supply-chain`, unit/browser/playtest gates, `golden-path`, `template-nonvisual`. `native-platforms.yml` (including `desktop-parity`) **blocks the merge** when a full selection touches native code, targets `main`, or cannot prove itself native-free. An ordinary pull request runs that lane's Linux rows only — macOS, Windows, iOS and arm64 are the full matrix, kept for `main` pushes, the nightly, an audit and anything targeting `main` — so a PR's own evidence never carries those legs. A new commit on a PR head re-runs its checks. Reuse a verdict only for an identical whole-repo tree that CI itself passed. Details: `docs/PRDs/done/PRD-373-selective-ci-and-develop-promotion.md`.
Registry commands read auth from `~/.npmrc`; pass a checkout-local untracked `.npmrc` with `npm --userconfig .npmrc `. Never print either file.
TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions come from the `catalog:` in `pnpm-workspace.yaml`, templates excepted. Biome formats — never hand-format. Interfaces are `I`-prefixed; classes and type aliases are not. Unit tests: `/__tests__/*.spec.ts`, vitest, node environment, DOM and GPU stubbed.
@@ -89,7 +91,7 @@ TypeScript 5.9 `strict`, **ESM only**; relative imports end in `.js`. Versions c
Results go in the existing PRD, PR or response; a separate evidence file only when the user asks or a workflow consumes it. Prose edits need only the doc checks and mirror regeneration — no implementation checkpoints or artificial negative controls.
**Fail closed**: malformed input throws, a missing observation fails, an empty assertion set fails. `pnpm test` proves units; **a playtest scenario proves the game** — every runtime-behaviour change gets one, rerun on each later change to that behaviour.
-**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell.
+**Nearest lane first, CI last.** A CI round trip costs over an hour; prove it locally (unit test, playtest, emulator, attached device) and push once green. While `TN_RUNNER` is set, run only the focused checks for what you changed and push: the full board runs once, on `tn-local`. **Prefer an emulator over hardware** when it can hold the claim; a stopped emulator means start it, not "blocked". Never call `xvfb-run` (its exit status is its own cleanup kill; `sh scripts/xvfb.sh ` is the wrapper). An unnamed WebGPU adapter may be SwiftShader: pass `--browser-recipe webgpu` and check `adapter.info`. Free a port with `lsof -ti tcp: | xargs -r kill`; `pkill -f vite` kills your own shell.
Long gates write `artifacts/gates/status.json`: read it with `pnpm gate:status`, probe a stale phase with `pnpm gate:doctor`; `pnpm gate:resume` continues only while worktree, branch, HEAD, lease and artifact still match. None of them repair or remove a worktree.
## Working outside the repo
diff --git a/README.md b/README.md
index 1dfd725431..020658274a 100644
--- a/README.md
+++ b/README.md
@@ -121,7 +121,9 @@ lifecycle.
| `tower-defense` | Four tower types, three upgrade levels, twelve waves and an orbital strike on a polished diorama |
| `racing` | Three laps, checklines, rescue sectors, boost pads |
| `sailing` | Wind, waves, buoyancy, and an ordered course |
+| `rain` | A procedural coastal storm, weather controls, and a free-fly camera |
| `shooter` | Hitscan and projectile weapons, hunting targets, timed pickups |
+| `snow` | Deformable snow footprints, a ball that carves its own track, blizzard weather |
Details in [`create-threenative`](packages/create-threenative/README.md).
diff --git a/biome.json b/biome.json
index 4309380656..f13b6b48f4 100644
--- a/biome.json
+++ b/biome.json
@@ -1,5 +1,6 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"ignore": [
"**/dist/**",
@@ -16,6 +17,7 @@
".worktrees/**",
".claude/worktrees/**",
".opencode/**",
+ ".leanpi/**",
".linchpin/**",
".tnshots/**",
".mcp.json",
diff --git a/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md b/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md
index 60a87235b0..77d9e903f3 100644
--- a/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md
+++ b/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md
@@ -6,7 +6,7 @@ prd_contract: v1
**Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch:
[docs/PRDs/AAA-visuals](./README.md). Judged with
-[PRD-341](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md), which is the only way to
+[PRD-341](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md), which is the only way to
tell whether this landed. Source studied: [TheLongSilence](https://github.com/achimala/TheLongSilence)
`src/gfx/PostFX.js`, the `LUM_FRAG` / `REDUCE_FRAG` / `ADAPT_FRAG` chain.
diff --git a/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md b/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md
deleted file mode 100644
index b4f8fdd707..0000000000
--- a/docs/PRDs/AAA-visuals/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md
+++ /dev/null
@@ -1,111 +0,0 @@
----
-prd_contract: v1
----
-
-# PRD-341 — a frame's tone is a number, and the number is a gate
-
-**Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch:
-[docs/PRDs/AAA-visuals](./README.md). **Land this first** — it is what makes every other PRD in the
-batch judgeable, and it is the cheapest thing here. Source studied:
-[TheLongSilence](https://github.com/achimala/TheLongSilence) `tools/levels.mjs`, `tools/judgeset.mjs`.
-
-**Goal: "it looks flat" stops being an opinion.** The reference states the rule plainly and this
-repository already believes it — *playtest is the eyes, not screenshots* — but the eyes currently
-report two numbers where five are needed.
-
-**Complexity:** a histogram over pixels already decoded, a new assertion kind, a CLI report =
-**LOW**. An afternoon, if `assert.tone` reuses the existing capture path.
-
-## The problem, measured at `43d03e6a`
-
-### 1. The capture path measures enough to detect a blank frame and nothing more
-
-`packages/playtest/src/capture.ts` computes `visiblePixels`, `maxLuminance`, `brightPixels`, a mean
-and a standard deviation, and spends all of it on `CAPTURE_GUARD_LIMITS` — is this frame blank, is
-it suspiciously dark. Those are liveness checks. None of them can tell a correctly exposed frame
-from one rendered three stops under, because a three-stop-under frame has plenty of variance and
-plenty of visible pixels. It just looks wrong.
-
-### 2. Every look PRD in this repository currently ends in an argument
-
-The batch's own history is the evidence: the recorded method for calibrating a render against a
-reference is *quantiles plus a one-build ablation*, written down after eyeballing picked the wrong
-cause four times. That method exists, it works, and it is not available as an assertion — so it gets
-re-hand-rolled per investigation and never becomes a gate.
-
-The reference names the exact shape that ends the argument: **mean, p1, p50, p99, clip%, black%**,
-per frame, with an average row. `0.00% of pixels clip and the 99th percentile is 165` is a sentence
-someone can act on, and it is what the reference measured immediately before fixing its highlight
-range.
-
-### 3. Fail closed, or it measures nothing
-
-The repository's own rule: an empty assertion set is a failure, a missing observation is a failure.
-A tone assertion whose capture never happened must go red, not skip — this is the exact shape of the
-v1 harness bug that reported green on scenarios asserting nothing.
-
-## What ships
-
-### `packages/playtest` — `assert.tone`
-
-A new assertion kind over a captured frame:
-
-```jsonc
-{
- "assert": "tone",
- "atStep": "landed",
- "mean": { "min": 60, "max": 140 }, // 0-255, display-referred, after tonemap
- "p99": { "min": 150 }, // the frame has highlights
- "p1": { "max": 40 }, // and it has shadows
- "clipFraction": { "max": 0.005 }, // 0.5% of pixels at display white
- "blackFraction": { "max": 0.25 }
-}
-```
-
-Every bound is optional; **an assertion with no bound at all throws at schema validation** rather
-than passing vacuously. Percentiles are computed from a 256-bin luminance histogram over the decoded
-frame — the reference decodes to a reduced size on the grounds that the statistics are unchanged and
-a full-res decode of twenty frames is pointlessly slow, and the same applies here.
-
-### `packages/playtest` — `tone` in the CLI
-
-`node packages/playtest/dist/runner/cli.js tone ` prints the reference's table verbatim in
-shape: one row per shot, an average row, no browser needed. This is the tool an agent runs on a
-directory of captures without writing a scenario, and it is what makes a set judgeable at once.
-
-### Reporting
-
-`TN_TONE` per captured frame, in the observation record, so a run that captured frames and asserted
-nothing still leaves the numbers behind for the next round.
-
-## What does not ship
-
-- No verdict. This measures; it does not decide that a frame is good. Pixel metrics never replace
- semantic image review — the sculpt server's rule holds.
-- No colour statistics, no per-channel histograms, no saturation. Luminance only in v1.
-- No baseline comparison. `pnpm visuals` / `visuals:ab` already own A/B against a baseline; this
- bounds a frame on its own terms, which is what a scenario with no baseline needs.
-
-## Acceptance criteria
-
-1. **A misexposed frame is red.** The example fixture renders a scene with `toneMappingExposure`
- pinned two stops under; a `tone` assertion bounding `mean` and `p99` fails, and the failure text
- names both the measured and the required value.
- *Red-green:* the mutation **is** the pinned exposure — restore it and the same assertion passes.
- Paste both.
-2. **An empty bound set throws.** A `tone` assertion with no bounds fails schema validation with a
- named error before the run starts.
- *Red-green:* soften the schema to allow it; `assertion-schema.spec.ts` goes red.
-3. **A missing capture fails, it does not skip.** A `tone` assertion at a step that produced no
- frame reports a failure naming the step.
- *Red-green:* return `undefined` from the evaluator when the capture is absent; the fail-closed
- spec goes red.
-4. **The CLI table matches the assertion.** `cli.js tone` on a frame and `assert.tone` on the same
- frame report the same six numbers, to the printed precision.
- *Red-green:* change the histogram bin count in one path only; the cross-check spec goes red.
-5. **It works where captures work.** The delete-test lane rule applies — this needs a GPU lane, so
- the scenario lives on `pnpm test:templates`, not on CI's frame-less template runner.
-
-## Out of scope
-
-Baseline diffing, perceptual metrics, and any automatic judgement of composition.
diff --git a/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md b/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md
index 68c3673fd3..4ee8e4f494 100644
--- a/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md
+++ b/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md
@@ -6,7 +6,7 @@ prd_contract: v1
**Status:** PROPOSED — filed 2026-09-03, measured at `43d03e6a`. Batch:
[docs/PRDs/AAA-visuals](./README.md). Judged with
-[PRD-341](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md). Source studied:
+[PRD-341](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md). Source studied:
[TheLongSilence](https://github.com/achimala/TheLongSilence) `src/world/Fleet.js:57-140`, the
`BEACON_VERT` / `BEACON_FRAG` pair and the `BEACON_HDR` note.
diff --git a/docs/PRDs/AAA-visuals/README.md b/docs/PRDs/AAA-visuals/README.md
index ce80012039..654f07b572 100644
--- a/docs/PRDs/AAA-visuals/README.md
+++ b/docs/PRDs/AAA-visuals/README.md
@@ -15,7 +15,7 @@ rather than arguable.
| # | PRD | Layer | Cost | Why it is here |
| --- | --- | --- | --- | --- |
-| 1 | [341 — a frame's tone is a number, and the number is a gate](./PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | `playtest` | LOW | `assert.tone`: mean, p1/p50/p99, clip%, black%. "It looks flat" stops being an opinion. |
+| 1 | [341 — a frame's tone is a number, and the number is a gate](../done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | `playtest` | LOW | `assert.tone`: mean, p1/p50/p99, clip%, black%. "It looks flat" stops being an opinion. |
| 2 | [339 — the frame sets its own exposure](./PRD-339-the-frame-sets-its-own-exposure.md) | `core` + templates | MEDIUM | There is no auto exposure in the repository. Largest single look gap. |
| 3 | [342 — where the frame goes: pass-cost ablation](./PRD-342-where-the-frame-goes-pass-cost-ablation.md) | `playtest` | LOW-MED | Every `quality.ts` tier is currently an unmeasured guess. |
| 4 | [340 — evaluate a scatter once per instance](./PRD-340-evaluate-a-scatter-once-per-instance.md) | `core` | MED-HIGH | Density is most of the AAA exterior, and the naive form is vertex-bound where the resolution scaler cannot reach it. |
diff --git a/docs/PRDs/CI/EXECUTION-ORDER.md b/docs/PRDs/CI/EXECUTION-ORDER.md
new file mode 100644
index 0000000000..86763a3831
--- /dev/null
+++ b/docs/PRDs/CI/EXECUTION-ORDER.md
@@ -0,0 +1,84 @@
+# CI PRDs — execution order
+
+Updated 2026-10-02. Goal: CI fast and reliable, with as much of the work as possible on the owner's
+machine.
+
+Baseline, measured over 2026-09-18 to 10-02: 115.5k runner-min, about 58k a week. On run 37043413533,
+jobs spent 239 min waiting for a runner against 146 min of work. The full audit is in
+[PRD-481](PRD-481-ci-does-each-piece-of-work-once.md).
+
+```mermaid
+flowchart TD
+ A["PRD-482 phase 1 pre-push hook green, under 15 s"] --> P["push the local develop backlog"]
+ T(["owner: fork-PR approval"]) --> B["PRD-480 Linux CI on tn-local runners"]
+ P --> B
+ P --> C["PRD-481 phase 2 each run does work once"]
+ M(["owner: merge queue on develop"]) --> D["PRD-481 phase 1 tree reuse + merge queue"]
+ C --> D
+ B --> E["PRD-482 phase 2 push rule, lean instructions"]
+ B --> F["PRD-380 release proof and native matrix off PRs, janitor"]
+ P --> G["PRD-481 phase 3 triggers fire only when they prove something"]
+```
+
+## Order
+
+| # | Work | State (2026-10-03) | Blocked on |
+|---|---|---|---|
+| 1 | [PRD-482](../tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md) phase 1 | Done in #403: hook green, 9 s | — |
+| 2 | [PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md) phases 1–2 | Done in #404: AC-1 green, run 37070815769 (33 jobs on `tn-local`, 5 on the light lane) | — |
+| 2 | PRD-481 phases 2–3 | Merged (#405): dist built once, shards under 6 min, one `integration.yml` | native cache box: `develop` must warm the new key |
+| 3 | PRD-481 phase 1 | Merged (#405): reuse only for an identical tree whose validation profile covers this run; merge queue on | live reuse and merge-group proofs |
+| 3 | [PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) phases 1 and 3 | #406 in the merge queue | phase 2 (reduced PR native matrix) not started |
+| 3 | PRD-480 phase 3 | In #404: Linux native legs routed; the Android emulator stays hosted (CPU-bound SwiftShader overran its 45 min) | a green `native-platforms` run |
+| 4 | PRD-482 phase 2 | Done in #403: push rule, ponytail once per session, playtest AGENTS.md 1,113 words | — |
+| 5 | Flaky template lanes | NEW. `tower-defense` (SwiftShader `DEVICE_LOST`, timeouts) and `rain` (`CAPTURE_PROVENANCE_MISSING`) fail about half the time on hosted and local. They have bounced the merge queue three times. Needs a fix in the template or the playtest bridge, not a quarantine | an owner for the lane |
+
+Each PRD's 7-day runner-minute acceptance (PRD-380, 481, 482 AC-1) can only be measured a week after its phases land.
+PRD-480 AC-2 (summed queue wait < 60 min) is measured and not met: 317 min, with 42 min wall against 39 hosted.
+It needs an owner call: more slots, or overflow part of the board to hosted runners.
+
+Rows with the same number can run as parallel lanes. Before parallel lanes edit `ci.yml`, they agree on
+which jobs each one touches.
+
+## Owner actions
+
+- ~~**Runner token**~~ — done 2026-10-02; it lives in the operator's untracked runner env file, never
+ in the repository.
+- ~~**Fork-PR approval**~~ — done 2026-10-02: `all_external_contributors`, applied with the owner's approval.
+- **Merge queue:** the owner approved enabling it on `develop` (2026-10-02). It waits until `ci.yml`'s
+ `merge_group` trigger is on `develop`, since the ruleset requires `ci-required` and a queue whose
+ check never reports stalls every merge. Start with a build concurrency of 2 and raise it only on measured
+ queue time.
+
+## Working this file as a goal
+
+- **Done when:** PRD-380, PRD-480, PRD-481 and PRD-482 are all in `docs/PRDs/done/`, each with its
+ acceptance boxes ticked from real runs.
+- **Pick work:** take the lowest-numbered row whose blocker is clear. When a row is blocked on an owner
+ action, record that under the PRD's `## Blocked on`, move on to the next independent row, and ask the
+ owner once.
+- **Rules:** one draft PR per PRD, branched from `origin/develop` (root `AGENTS.md`). Tick a box only on
+ a green proof. Update this table's sizes and blockers as rows land.
+
+## Review by Astra (2026-10-02), adopted
+
+- **Same tree is not same validation.** A reduced PR matrix (PRD-380 phase 2) passing on tree T must not
+ satisfy a promotion PR on T, whose full native matrix never ran. PRD-481's reuse now needs an identical
+ tree *and* a source run whose validation profile covers the current one: the full board, every
+ currently required check and matrix leg passed, the same target tier and the same runner profile.
+ Tests prove that a reduced pass cannot satisfy a promotion, and that a profile change refuses reuse.
+- **Light lane.** `scope`, `ci-required` and the summaries get a reserved runner (PRD-480), so a join never
+ queues behind a 20-minute build.
+- **Build once before reusing verdicts.** PRD-481 phase 2 (artifact sharing, cache warming, shards) runs
+ ahead of phase 1. That was already the order.
+- **Not now:** narrower affected-check selection (a follow-up PRD, once the planner's exemptions are
+ proven one at a time), a remote compiler cache, more machines, Nx/Bazel/ARC. Measure first.
+
+## Triage of the older CI PRDs (2026-10-02)
+
+| PRD | Verdict | Action taken |
+|---|---|---|
+| PRD-303 CI closes its regression doors | Done by other work. All five doors are closed on `develop`, though no box was ever ticked | Moved to [`done/PRD-303.md`](../done/PRD-303.md) with a superseded note; its cache-key item went to PRD-481 phase 2 |
+| PRD-364 remove gate bloat | Junk. A stale draft copy that a merge (`e8ee4770f`) brought back; the real one is COMPLETE in [`done/`](../done/PRD-364-remove-development-gate-bloat.md) (PRs #129 and #131) | Deleted the `CI/` copy and repointed its one inbound link |
+| PRD-379 native release follows CI completion | Finished. Its last phase was observed on `main`: CI 36134527157 → native-release 36140281930 and 36142531873, `gates` 39 s | Ticked with that evidence and moved to [`done/`](../done/PRD-379-native-release-follows-ci-completion.md) |
+| PRD-380 a PR never starves the runner pool | Still valid: 12.0k runner-min per 14 days | Reshaped to current PRD rules (3 phases, a proof on every box); runs after PRD-480 |
diff --git a/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md b/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md
deleted file mode 100644
index 15bad66e1e..0000000000
--- a/docs/PRDs/CI/PRD-364-remove-development-gate-bloat.md
+++ /dev/null
@@ -1,217 +0,0 @@
----
-prd_contract: v1
----
-
-# PRD-364 — Remove repeated verification and bookkeeping from the development loop
-
-**Status:** PROPOSED. Planning only; no implementation authorized by this document.
-**Date:** 2026-09-06 (America/Vancouver). **Inspected checkout:** `713ca111`.
-**Layer:** repository tooling and contributor policy, not engine or game behavior.
-**Complexity: 5 → MEDIUM mode:** more than ten implementation files (+3), multi-package verification (+2).
-
-A developer should be able to change prose or fix one defect without rebuilding the framework repeatedly or repairing unrelated evidence accounting before pushing.
-
-## Decision
-
-Remove redundant executions and arbitrary accounting blockers first. Then exempt a deliberately narrow class of prose-only PRs from compilation and game matrices. Preserve complete verification for every executable change, main, nightly and releases. No new package, dependency graph framework, performance budget, dashboard, or persistent verification cache.
-
-This proposes a policy change to the blanket local gates in root AGENTS.md and the every-PR policy documented in CI. Those rules remain binding until their implementation phase edits them. It does not silently supersede PRD-303: its full template and platform coverage remains for executable changes, main and nightly. Native parity already has label conditions; preserve those independently.
-
-## Integration ledger
-
-Rows describe planned changes, not completed integration. Line references point to the inspected baseline; implementation records replacement caller lines and observed controls.
-
-| ID | Changed/new thing | Existing live caller | Replaces | Removal phase | Negative control |
-|---|---|---|---|---|---|
-| L1 | Evidence report without file/line/duplicate-count rejection | package.json `budgets` → scripts/check-evidence-budget.ts:289 | Small-file and narrative-length blocking thresholds | 1 | Large bytes and tracked generated agent instructions still exit nonzero |
-| L2 | Census drift becomes advisory; single manifest freshness call | scripts/check-budgets.ts:462; package.json `budgets` | Census tolerance failure and repeated manifest command | 2 | Stale capability manifest still fails the live budgets command |
-| L3 | Bounded pre-push checks | githooks/pre-push → scripts/ci-fast.sh:20 | Whole-workspace typecheck and budget chain on every push | 3 | Deliberate lint error still blocks the hook |
-| L4 | Reuse the current invocation's completed build locally | scripts/ci-local.sh:25 → scripts/run-test-suite.sh | Extra test-phase build and build-backed package test scripts | 4 | Remove required dist output and prebuilt execution fails |
-| L5 | NEW scripts/ci-change-scope.mjs, internal CLI only | .github/workflows/ci.yml `scope` output → expensive jobs | Unconditional heavyweight jobs for prose-only PRs | 5 | Mixed prose/core change selects full verification |
-| L6 | Same narrow scope for native workflow | .github/workflows/native-platforms.yml event → jobs | Native builds for prose-only PRs | 6 | Shared TypeScript or unknown path selects existing native behavior |
-
-## Findings and evidence
-
-### 1. The expensive part is broad execution, not just the budgets job
-
-Read-only `gh run list --workflow ci.yml --limit 8 --json databaseId,conclusion,createdAt,updatedAt,headSha,event` returned eight PR runs. Seven succeeded, one failed. Created-to-updated elapsed time ranges from **6m36s to 12m06s**, median **8m08s** across the eight; these timestamps include orchestration and are not pure execution time. This small sample is not a percentile estimate.
-
-Latest green [run 34068539162](https://github.com/ThreeNativeHQ/threenative/actions/runs/34068539162), head `7b0f0ea7ed1b242c028f7f422df6f9999e070dd9`, was created at 2026-09-07T00:02:19Z and updated at 00:09:48Z (7m29s). `gh run view 34068539162 --json jobs` returned 32 jobs:
-
-| Observed job | Started → completed, UTC | Elapsed |
-|---|---|---|
-| build | 00:02:23 → 00:03:00 | 37s |
-| budgets | 00:03:03 → 00:04:00 | 57s |
-| test-native | 00:02:23 → 00:06:41 | 4m18s |
-| template-nonvisual (puzzle, 2/2) | 00:05:27 → 00:09:18 | 3m51s |
-| run-summary | 00:09:21 → 00:09:47 | 26s |
-
-The late template start is observed; account concurrency as its cause is an inference, not proven by the job API. Removing budgets alone cannot eliminate this critical path. Failed run `34068103200` failed `test-unit (2/3)`; its cause was not investigated and is not classified as bloat.
-
-### 2. The hook repeats mandatory work regardless of changed paths
-
-`githooks/pre-push` invokes `ci-fast.sh`, which serially runs lint, docs, whole-workspace typecheck, the entire budgets chain, agent sync checking and selected scaffold/drift tests. Root instructions independently require typecheck/lint/test before completion. A push repeats checks without knowing whether source changed. Keep the inexpensive drift protection; remove heavyweight mandatory hook execution. Developers retain explicit complete commands, and CI remains authoritative.
-
-### 3. Accounting has become a product-change dependency
-
-`scripts/check-evidence-budget.ts` rejects evidence tree file counts, Markdown length over 1,000 lines and duplicate bytes, alongside useful total-byte limits and detection of generated sweep instructions. `docs/PRDs/AGENTS.md` requires a separate justification commit to raise caps and owner approval for deletion. This can turn adding proof into consolidation work. Retain byte limits (72 MiB verification, 200 MiB benchmark) and generated-instruction protection. Remove file-count, per-file-line and duplicate-byte thresholds as blockers; retain measurements and duplicate inspection tools. Do not delete any evidence as part of this PRD.
-
-`check-budgets.ts:462–478` also fails when the dated native LOC census drifts by more than five lines. Make numeric drift advisory; retain malformed/missing required record checks and native coverage. Framework/native LOC triggers already warn: removing those would not unblock CI.
-
-Historical corroboration, not a current failure claim: [PRD-330](../done/PRD-330-documentation-footprint-reduction.md) records a stale retention-index budget failure and an unrelated pre-push budget failure during documentation cleanup. Retention integrity stays enforced in this proposal; it needs separate consumer analysis before removal.
-
-### 4. Build and manifest work is repeated through live callers
-
-`ci-local.sh` builds packages before `pnpm test`; `run-test-suite.sh` defaults to `docs,build,package-test,unit`, so it builds again. Standalone package tests for assets, ueformat, raw-unreal and site rebuild; the runner already has `TN_SUITE_PREBUILT=1` and pure-check replacements. Reuse those within one invocation, with a complete root build first. Never trust an arbitrary old dist tree.
-
-`package.json` runs `capabilities:check` in budgets, and `enforceBudgets` calls `capabilityManifestErrors` again. Remove the duplicate root-chain invocation, preserve the standalone command and the enforcement call. Workspace caching, native build caching, unit sharding and template scenario sharding already exist; do not reimplement them.
-
-### 5. Workflow topology is overbroad for prose, but broad runtime coverage is intentional
-
-Both workflows trigger for all PRs to main. Native desktop/starter/iOS lanes remain broad; web-reference and parity legs already have PR-label conditions. A core or playtest edit can break native, so a native-directory-only filter is rejected. Initial exemption is only Markdown under `docs/PRDs/` and `docs/verification/`, with explicit exclusions below. All other changes run today's board. This targets planning/evidence prose without pretending ordinary README/template instructions are inert.
-
-## Scope and success criteria
-
-| Change class | Local completion / PR behavior after implementation |
-|---|---|
-| Strict prose-only class | Docs links, agent mirrors, evidence integrity, relevant docs/evidence contract tests; no build, typecheck, native compilation or game matrix |
-| Any executable, config, generated contract, unknown path or uncertain diff | Complete current verification; full local gate before completion |
-| main, nightly, release, manual full invocation | Complete existing board; no prose exemption |
-
-Prose classification uses the merge-base diff, including both names of renames and deleted files. Exclude all AGENTS.md/CLAUDE.md, generated retention indices, native census/coverage records and any Markdown read by executable fixtures, parsers or gate inputs. Enumerate those consumers before enabling exemption; reuse evidence-citations.ts knowledge where appropriate, but directory citation alone does not prove semantic harmlessness. Unknown consumer classification falls back to full. Empty diff, missing merge base, Git error and malformed inputs select full or fail; never claim an empty run passed.
-
-Acceptance targets are one-time evaluation criteria, **not new CI timing gates**: reduce prose PR job count by at least 75% versus the 32-job sample; demonstrate zero compiler/browser/native invocations on a qualifying PR; eliminate the second root build from a full local run; prove above-line/file evidence no longer blocks while oversized evidence still fails. Measure elapsed times on three comparable before/after runs, reporting cache state and runner differences. Do not promise runtime PR speedups from prose scoping.
-
-No database, API, user-facing UI or data migration. Internal CLI/workflow output is the user interface: print the selected scope, reason, commands run and checks explicitly not applicable.
-
-## Architecture and sequence
-
-```mermaid
-flowchart TD
- E[PR event and complete Git diff] --> S[Existing workflow invokes narrow scope CLI]
- S -->|Strict prose class| D[Docs and evidence checks]
- S -->|Mixed, unknown or full event| F[Existing full board]
- D --> A[Existing summary verifies selected checks]
- F --> A
- D -->|Failure| R[Named red check]
- F -->|Failure| R
-```
-
-```mermaid
-sequenceDiagram
- participant Dev as Developer
- participant Hook as Pre-push hook
- participant CI as Existing workflows
- participant Scope as Scope CLI
- Dev->>Hook: Push
- Hook->>Hook: Bounded lint/docs/drift checks
- alt Check fails
- Hook-->>Dev: Exact failing check and log
- else Checks pass
- Hook->>CI: Existing PR event
- CI->>Scope: Event and merge-base diff
- Scope-->>CI: Prose or full with reason
- CI->>CI: Execute selected gates
- CI-->>Dev: Results; unexpected skips fail
- end
-```
-
-## Implementation phases
-
-Every phase is independently revertible and has an automated checkpoint. No phase may add more than five implementation files; this PRD's checklist/evidence updates are tracked separately. Run controls in an isolated in-repository worktree; never mutate a collaborator's tree. After each phase, use an independent prd-work-reviewer (or equivalent) to inspect the diff, run the targeted checks and audit live invocation. Only PASS releases the next phase. This is a future implementation requirement, not a claim that implementation has occurred.
-
-### Phase 1 — Adding proof does not require file-count or line-count repairs
-
-**Files:** EDIT scripts/check-evidence-budget.ts (keep hard integrity/bytes, report other measurements), scripts/__tests__/evidence-budget.spec.ts (behavior controls), docs/PRDs/AGENTS.md (remove retired threshold policy), docs/PRDs/CLAUDE.md (generated mirror).
-
-L1: remove file and duplicate threshold rejection, line-cap enforcement and its exception inventory. Keep exported measurement helpers only where real consumers require them; remove tests solely pinning retired constants. Keep total-byte errors, Git/read failures, generated sweep instruction rejection and canonical duplicate tooling. Existing `pnpm budgets` is the caller; no new command.
-
-Tests: `should accept long evidence when total bytes remain within the cap`; `should accept many small files when total bytes remain within the cap`; `should reject evidence when tracked bytes exceed the cap`; `should reject generated sweep instructions when tracked`. Drive the existing CLI against real scratch Git repositories, not just mocked findings.
-
-Red/revert: current code rejects long/many-file fixtures; restoring threshold rejection must reproduce that failure after the change. Oversized and generated-instruction fixtures must still fail after it. User action: stage a qualifying long evidence record and run the evidence checker; see exit 0 with factual measurements.
-
-Verify: `pnpm exec vitest run scripts/__tests__/evidence-budget.spec.ts`; `pnpm sync:agents`; `pnpm sync:agents --check`; shared full verification below. No evidence deletion and no byte-cap increase.
-
-### Phase 2 — A native edit does not require a LOC census refresh
-
-**Files:** EDIT scripts/check-budgets.ts, scripts/__tests__/budgets.spec.ts, package.json.
-
-L2: remove nativeCensusDrift from enforceBudgets errors; retain the existing CLI advisory report. Keep nativeCensusErrors and native coverage hard failures. Remove only the redundant `pnpm capabilities:check` entry from the root budgets chain; keep its standalone script and capabilityManifestErrors in enforceBudgets.
-
-Tests: `should report census drift without failure when native source line counts change`; `should reject stale capabilities when budgets run`; `should check the capability manifest once when the root budget chain runs`. Record CLI execution/call counts with distinct fixtures rather than comparing strings alone.
-
-Red/revert: current numeric drift fixture fails; restoring enforcement fails it again. Corrupt a public capability entry and observe budgets fail after removing the duplicate command. User action: run `pnpm budgets` after a harmless source-line change; see drift advisory rather than failure solely from line counts.
-
-Verify: `pnpm exec vitest run scripts/__tests__/budgets.spec.ts`; `pnpm budgets`; `pnpm capabilities:check`; shared full verification.
-
-### Phase 3 — A push stops repeating whole-workspace validation
-
-**Files:** EDIT scripts/ci-fast.sh, githooks/pre-push, AGENTS.md, CLAUDE.md (generated); NEW scripts/__tests__/ci-fast.spec.ts.
-
-L3: remove typecheck and budgets from the mandatory fast runner; retain lint, docs, agent mirrors and current selected drift tests. Keep truthful hook help and explicit full verification instructions. Root policy permits the narrow prose completion lane defined above only after phases 5–6 prove it; until then full completion gates still apply. State that passing the hook alone never proves runtime correctness. No successful-result cache or skip-by-HEAD marker.
-
-Tests: `should run bounded checks when the pre-push hook executes`; `should fail the hook when lint fails`; `should not invoke build typecheck or budgets when fast checks run`. Invoke the real hook with a PATH command recorder and failure injection; also execute `pnpm ci:fast` normally.
-
-Red/revert: old runner records typecheck/budgets calls; reintroducing either fails the bounded-execution assertion. User action: `pnpm ci:fast`; see the smaller board and time per check.
-
-Verify: `pnpm exec vitest run scripts/__tests__/ci-fast.spec.ts`; `pnpm sync:agents`; `pnpm ci:fast`; shared full verification.
-
-### Phase 4 — Full local verification builds once per invocation
-
-**Files:** EDIT scripts/ci-local.sh, scripts/run-test-suite.sh only if existing prebuilt support needs adjustment; NEW scripts/__tests__/ci-local.spec.ts.
-
-L4: make full ci:local start with `pnpm build` (includes generated files, examples and site), then run the suite with `TN_SUITE_PREBUILT=1 TN_SUITE_PHASES=docs,package-test,unit`. Preserve the explicit core boundary gate. Build failure prevents dependent test execution and produces nonzero exit. `ci:local test` retains standalone building behavior; never select prebuilt solely because a dist folder exists. Unknown local job selection must fail rather than exit 0 without work.
-
-Tests: `should build once when the full local board runs`; `should build prerequisites when only test is selected`; `should fail when the prerequisite build fails`; `should reject prebuilt execution when required outputs are missing`. Use a command trace of the real shell runner, then drive the real full local board.
-
-Red/revert: original full board records both its own build and the suite build. Remove a required output after the completed build in an isolated negative run; test must fail loudly. User action: `pnpm ci:local`; see one preparation build and complete package checks, including site and native checks.
-
-Verify: `pnpm exec vitest run scripts/__tests__/ci-local.spec.ts`; `pnpm ci:local`; shared full verification. If browser/golden-path steps internally rebuild, record separately; removing those is outside this bounded slice.
-
-### Phase 5 — A planning-only PR skips the game board safely
-
-**Files:** NEW scripts/ci-change-scope.mjs; EDIT .github/workflows/ci.yml, scripts/__tests__/ci-structure.spec.ts, scripts/ci-workflow.ts, scripts/__tests__/ci-needs.spec.ts.
-
-L5: add a small native-Node scope job (no workspace install), expose its decision as job output and wire the existing expensive jobs to it. Extend the existing graph guard to recognize that exact scheduling prerequisite; do not generally permit coverage jobs to gate other coverage jobs. Run prose docs/evidence assertions in an existing job, with explicit Vitest file selection from the inspected contract consumers. Validate that these commands do not build or launch browsers before calling the lane lightweight. Reuse existing summary/golden-path aggregation and teach their inline decisions to accept only deliberate not-applicable results, never unexpected skipped/failed/cancelled selected jobs. Preserve check names; inspect actual branch protection requirements before changing aggregation behavior.
-
-Tests in existing CI suites: `should choose full when shared core changes`; `should choose full when a consumed Markdown record changes`; `should choose full when diff discovery is incomplete`; `should choose prose when only inert PRD text changes`; `should reject unexpected skipped jobs when full scope is selected`. Invoke the actual scope CLI on scratch Git histories, including renames, deletions and mixed changes. Collect the production repository's Markdown reader census before freezing the conservative exclusions.
-
-Red/revert: baseline workflow runs build for a real PRD-only diff. Removing scope conditions restores that behavior and fails the workflow contract. Replacing a valid prose file with a core change must select full. User action: open/update an implementation test PR with only this PRD text changed; see docs results, explicit scope reasons and zero expensive jobs executed. Hosted proof must record job API results, not just YAML tests. Publishing that test PR is a separate implementation action, not part of this drafting task.
-
-Verify: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`; `pnpm check:docs`; `gh run view --json jobs`; repeat on mixed/core and main/full events. Shared full verification. If required-check settings or a semantic Markdown consumer cannot be verified, keep full selection; phase remains incomplete.
-
-### Phase 6 — Native scheduling shares the proven prose exemption
-
-**Files:** EDIT .github/workflows/native-platforms.yml, scripts/__tests__/ci-structure.spec.ts, docs/PRDs/CI/PRD-303.md (explicit reconciliation of scope policy).
-
-L6: invoke the same scope CLI and apply its output to currently broad native jobs, composed with existing label/input conditions. Do not widen or remove parity label conditions. Preserve workflow_call, workflow_dispatch (including ios_only), main and scheduled behavior. No separate native-only source classifier. Update PRD-303 to point at this later policy decision without marking its unfinished coverage work complete.
-
-Tests: `should preserve full native scheduling when shared TypeScript changes`; `should omit native compilation when a proven prose-only PR changes`; `should preserve caller and manual input behavior when invoked outside a PR`. Assert all current platform legs and both parity conditions remain represented.
-
-Red/revert: baseline starts broad native jobs on qualifying prose PRs; restoring unconditional scheduling fails the exemption contract. Core/config/unknown-path mutations select full. User action: inspect the same hosted prose PR across both workflows; no native compilation executes. Observe a full-scope run retaining current platform execution/label semantics.
-
-Verify: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`; `gh run view --json jobs`; shared full verification and independent review. Timing comparison is manual inspection of actual run data, not a new pass/fail service.
-
-## Verification evidence and completion
-
-For every implementation phase run `pnpm typecheck && pnpm lint && pnpm test`, plus its targeted commands and `pnpm budgets`. Run real runtime/native scenarios when verification tooling changes affect them. Paste exit codes, selected test names/counts, raw command output, live-caller census and negative-control output in phase evidence. Existing gate status/doctor commands remain available; no new evidence framework. Before calling a gate PASS, observe its relevant mutation fail and then restore and rerun it.
-
-| Phase | Required evidence | Current result |
-|---|---|---|
-| 1–2 | Real budget CLI red/green, retained hard failures, manifest call count | NOT RUN — proposed |
-| 3 | Hook command trace, injected failure, actual fast run | NOT RUN — proposed |
-| 4 | Full local command trace, missing-output failure, actual full board | NOT RUN — proposed |
-| 5–6 | Scratch Git classifier controls, hosted prose/full job lists, timing comparisons | NOT RUN — proposed |
-| All | Full prescribed gates and independent phase review | NOT RUN — proposed |
-
-Drafting evidence: read-only source inspection and GitHub run queries above were executed. No performance improvement, current budget failure, branch-protection configuration or future gate PASS is claimed. Source-based repetition findings are not timing measurements. Draft validation is recorded in [the drafting verification record](../../verification/prd-364-draft-audit-2026-09-06.md).
-
-## Acceptance checklist
-
-- [ ] Evidence file/line/duplicate-count and census drift cannot block a change; byte/integrity/capability/native coverage failures still do.
-- [ ] Pre-push stops invoking full typecheck/budgets; full local verification prepares its root build once and standalone commands still work.
-- [ ] Proven prose PRs run at least 75% fewer CI jobs with zero compile/browser/native calls; executable/unknown changes retain complete applicable coverage.
-- [ ] Every phase has observed controls, full verification, live wiring and independent review; timing observations identify cache/runner conditions.
-- [ ] Existing policy and PRD-303 agree with the implemented behavior; no evidence deletion, new timing gate, new dependency or competing verification framework was introduced.
-
-First implementation action: reproduce Phase 1's long-file and many-small-files failures in scratch Git fixtures. Estimate: 10 minutes to establish the initial red; overall implementation estimate 3–5 working days, dominated by hosted scheduling proof rather than script edits.
diff --git a/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md b/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md
index 71d9c42800..2694308dc7 100644
--- a/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md
+++ b/docs/PRDs/CI/PRD-380-a-pull-request-never-starves-the-runner-pool.md
@@ -4,70 +4,97 @@ prd_contract: v1
# PRD-380 — A pull request never starves the runner pool
-**Status:** NOT STARTED
+**Status:** PARTIAL — phases 1 and 2 complete; phase 3's janitor is in and its proof is a live merged PR
**Complexity:** 6 → MEDIUM-HIGH (+1 workflow triggers, +1 reduced PR matrix, +1 scheduled janitor, +1 shared release concurrency, +1 guarded specs, +1 npm-release wiring).
**Owner:** CI release tooling.
**Problem:** The org's GitHub-hosted runner concurrency is small (roughly three runs at once) and it is consumed by matrices that a pull request does not need. On 2026-09-12 thirteen CI runs sat queued, five of them `main` pushes, while three heavy runs held the pool; a two-minute `build` join waited over an hour. Three multipliers: `native-release.yml` runs its macOS/Windows/Linux proof on **every** PR that touches one of four paths (including `native-platform-workflow.test.mjs`, which ordinary runtime-native PRs touch), `ci.yml` runs the **full** `native-platforms` matrix for any `selection == 'full'` PR, and nothing cancels a run whose PR has already merged, so dead runs keep holding runners. The owner's ask: pushing to a branch must not restart or stall the whole board.
Baseline: `develop`. [PRD-373](../done/PRD-373-selective-ci-and-develop-promotion.md) owns the selective-CI classifier and the develop→main promotion rule; this PRD narrows what the selection is allowed to spend a runner on. PRD-379 changes *how* `native-release` learns about CI completion; this PRD changes *what triggers* it.
-### Phase 1 — The release proof stops running on every PR push
-
-**Progress:**
-
-- [ ] Callers wired and building: `.github/workflows/native-release.yml`, `.github/workflows/npm-release.yml`, `scripts/__tests__/ci-structure.spec.ts`, `scripts/__tests__/native-release-proof.spec.ts`
-- [ ] Required test green: the release proof's PR trigger is label-gated (e.g. `release-proof`) or `workflow_dispatch`-only, and a `develop`→`main` promotion PR still runs it; a `v*` npm release still triggers the native release
-- [ ] Observed red recorded, then restored green
-- [ ] User verification performed on the named platform
-- [ ] Evidence record written
-- [ ] Independent reviewer returned PASS
-
-### Phase 2 — PRs run a reduced native matrix; the full matrix stays on main and nightly
-
-**Progress:**
+## Decisions
+
+- 2026-10-02 (João, via the CI audit): reshaped to the current PRD rules (proof on every box, no ceremony
+ boxes, at most 3 phases). The old phase 4, one concurrency group for release proofs, folds into phase 1
+ because it edits the same workflow. The janitor is a `pull_request: closed` trigger instead of a
+ scheduled script. Linux native rows move to the owner's machine under
+ [PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md), so phase 2 is mainly about the hosted
+ macOS, Windows and iOS legs. Measured 2026-09-18 to 10-02: native-release PR runs cost 7.0k
+ runner-min, and runs still going after their PR merged or closed cost 5.0k.
+- 2026-10-02 (phase 1): a promotion PR is `develop -> main`. PRD-373 retired the `promotion/`
+ ref on 2026-09-12, so the proof's PR route keys on `github.event.pull_request.base.ref == 'main'`
+ (which also covers the `hotfix/` emergency path, that branch's own full qualification) rather than
+ on a branch prefix. The one shared concurrency group reverses the `head_sha`-in-the-group choice
+ above on purpose: a superseded proof now queues instead of running beside a live one.
-- [ ] Callers wired and building: `.github/workflows/ci.yml`, `.github/workflows/native-platforms.yml`, `scripts/__tests__/ci-efficiency.spec.ts`
-- [ ] Required test green: a PR selection emits the Linux-only native rows; a `main` push or nightly emits the full matrix, and the full set cannot be label-exempted away
-- [ ] Observed red recorded, then restored green
-- [ ] User verification performed on the named platform
-- [ ] Evidence record written
-- [ ] Independent reviewer returned PASS
-
-### Phase 3 — Runs for merged or closed PRs are cancelled automatically
-
-**Progress:**
-
-- [ ] Callers wired and building: new `.github/workflows/ci-janitor.yml` + `scripts/ci-janitor.ts`, registered in `scripts/__tests__/ci-structure.spec.ts`
-- [ ] Required test green: the janitor cancels queued/in-progress runs for a merged or closed PR head and leaves open-PR and `main` runs alone
-- [ ] Observed red recorded, then restored green
-- [ ] User verification performed on the named platform
-- [ ] Evidence record written
-- [ ] Independent reviewer returned PASS
+### Phase 1 — The release proof stops running on every PR push
-### Phase 4 — Release-proof workflows share one repo-wide concurrency group
+**Status:** COMPLETE
-**Progress:**
+**Files:** `.github/workflows/native-release.yml`, `.github/workflows/npm-release.yml`,
+`.github/workflows/release-candidate.yml`, `scripts/__tests__/native-release-proof.spec.ts`,
+`scripts/__tests__/ci-structure.spec.ts`.
-- [ ] Callers wired and building: `.github/workflows/native-release.yml`, `.github/workflows/release-candidate.yml`, `scripts/__tests__/ci-structure.spec.ts`
-- [ ] Required test green: at most one native release proof matrix runs at a time across branches, and a superseded proof is not evicted mid-run
-- [ ] Observed red recorded, then restored green
-- [ ] User verification performed on the named platform
-- [ ] Evidence record written
-- [ ] Independent reviewer returned PASS
+- [x] Release proof never runs on an ordinary PR push. proof: `pnpm exec vitest run scripts/__tests__/native-release-proof.spec.ts`,
+ which also asserts it still runs on a promotion PR, the `release-proof` label, manual dispatch and an npm `v*` release.
+ Evidence: `native-release-proof.spec.ts` 2026-10-02, 61 passed; 3 new assertions red before the workflow change.
+ The `pull_request.paths` filter is replaced by `types: [opened, synchronize, reopened, labeled]`, and
+ `gates` — which every proof job `needs:` — now refuses an ordinary develop PR, so the whole board costs nothing.
+ npm-release.yml and release-candidate.yml trigger no part of this proof and are unchanged.
+- [x] At most one release proof holds runners at a time across branches. proof: `pnpm exec vitest run
+ scripts/__tests__/ci-structure.spec.ts`, with a case asserting the shared concurrency group.
+ Evidence: `ci-structure.spec.ts` 2026-10-02, 2 assertions red before the workflow change, 123 passed after.
+ `group: native-release-proof`, `cancel-in-progress: false` kept.
-**Files (maximum five):**
+### Phase 2 — PRs run a reduced native matrix; the full matrix stays on main and nightly
-- EDIT `.github/workflows/native-release.yml` — PR trigger gate, `v*` release input, shared concurrency group.
-- EDIT `.github/workflows/native-platforms.yml` — reduced PR rows, full rows for main/nightly.
-- EDIT `.github/workflows/ci.yml` — pass the selection to the native call.
-- ADD `.github/workflows/ci-janitor.yml` and `scripts/ci-janitor.ts` — cancel runs for merged/closed PR heads.
-- EDIT `scripts/__tests__/ci-efficiency.spec.ts`, `scripts/__tests__/ci-structure.spec.ts` — guards for every change above.
+**Status:** COMPLETE
+
+**Files:** `.github/workflows/ci.yml`, `.github/workflows/native-platforms.yml`,
+`scripts/ci-change-scope.mjs`, `scripts/ci-required.mjs`,
+`scripts/__tests__/ci-efficiency.spec.ts`, `scripts/__tests__/ci-needs.spec.ts`,
+`packages/runtime-native/tests/starter-desktop.test.mjs`.
+
+- [x] An ordinary PR selection emits only the Linux native rows. proof: `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts`.
+ Evidence: `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`
+ 2026-10-02, 216 passed; 2 new assertions and 4 `ci-needs` cases red before the change, plus
+ `packages/runtime-native` `native-platform-workflow` + `starter-desktop` 79 passed after it.
+ The plan carries `nativeTier` (`full` | `reduced` | `none`), decided once in
+ `scripts/ci-change-scope.mjs` from the change's native reach and its target branch, and
+ `native-platforms.yml` reads it: `desktop` (macOS, Windows) and `ios-simulator` skip unless it is
+ `full`, and `starter-linux` runs `linux-x64` alone — the arm64 row is shaped into the matrix by
+ the `scope` job, because a job's `if` cannot read `matrix`. `web-reference`, `android-v8-source`,
+ `android-emulator-parity` and `desktop-parity` are Linux rows and still run.
+- [x] `main` pushes, the nightly and pull requests into `main` keep the full matrix. proof: `pnpm exec vitest run
+ scripts/__tests__/ci-efficiency.spec.ts`, which also asserts no label exempts it.
+ Evidence: same run as above, 2026-10-02, 216 passed; the case fails on the pre-change files.
+ (This box read `promotion/*` PRs; phase 1 retired that ref, so the promotion route is
+ `base.ref == 'main'` — see the 2026-10-02 phase-1 decision.) A `main` push, the nightly, an
+ explicit `workflow_dispatch` and any merge group or pull request into `main` all classify
+ `nativeTier: full`, no job in `native-platforms.yml` reads a pull-request label, and
+ `ci-required` records the tier in its verdict — a reduced pass can therefore never stand in for a
+ full requirement (PRD-481), which `ci-needs.spec.ts` proves in three cases.
+
+### Phase 3 — A merged or closed PR stops spending runners
+
+**Status:** IN PROGRESS — janitor landed, live proof pending.
+
+**Files:** NEW `.github/workflows/ci-janitor.yml` (`pull_request: types: [closed]`, cancels the head
+ref's queued and in-progress runs with `gh run cancel`), `scripts/__tests__/ci-structure.spec.ts`.
+
+- [x] Closing or merging a PR cancels its head ref's in-flight runs within a minute. proof: the janitor run id
+ and the cancelled run ids on one merged PR.
+ The workflow and its guards are in: `ci-structure.spec.ts` 2026-10-02, 124 passed (the case was red
+ with no `ci-janitor.yml`). Box stays open — the proof is a live merged PR, and until one merges the
+ only executed evidence is a local `gh` stub, which is not the claim.
+ Evidence: janitor run 37096061684 cancelled run 37095819714 when #410 merged; janitor run 37091826027
+ cancelled run 37091514862 when #408 merged (2026-10-03).
+
+`ci-janitor.yml` is the one workflow here holding `actions: write`, so the existing "no job cancels
+its own run" guard now names it as its single exception rather than dropping the ban: the janitor
+fires on `closed`, never from a failure step, scopes every cancel to the event's own repository, and
+skips its own run id.
## Acceptance criteria
-- [ ] A push to an open runtime-native PR starts only the reduced Linux native rows, not the macOS/Windows/Android simulation matrix.
-- [ ] The full native matrix still runs on every `main` push and nightly, and a frozen `promotion/*` PR.
-- [ ] The native release proof runs on a promotion PR, on a label or manual dispatch, and on an npm `v*` release — never on an ordinary PR push.
-- [ ] A merged or closed PR's queued and in-progress runs are cancelled within one janitor interval.
-- [ ] Two release proofs on different branches cannot hold runners at the same time.
-- [ ] `pnpm exec vitest run scripts/__tests__/ci-efficiency.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/native-release-proof.spec.ts` is green.
+- [ ] AC-1 [shared]: proof: the PRD-481 audit script over the 7 days after phase 3 lands. Native-release
+ PR runs plus post-close runs fall from 12.0k to under 2k runner-min per 14 days. Evidence: pending.
diff --git a/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md b/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md
new file mode 100644
index 0000000000..58a7fb12a6
--- /dev/null
+++ b/docs/PRDs/CI/PRD-480-linux-ci-runs-on-the-owner-machine.md
@@ -0,0 +1,266 @@
+---
+prd_contract: v1
+---
+
+# PRD-480 — Linux CI runs on the owner's machine
+
+**Status:** PARTIAL — phases 1 and 3 verified (#404, #410); AC-1 green; AC-2 measured, needs the owner's call
+**Complexity:** 5 (HIGH)
+**Owner:** CI tooling
+**Depends on:** None
+
+Complexity: 5 → MEDIUM (about 8 files +2, new system +2, GitHub runner API +1); risk override → HIGH because a
+public repository gains a code-execution path onto a private machine.
+
+## Context
+
+CI's critical path is the queue, not the work. On 2026-10-02 the runs were:
+
+| Run | Job work | Waiting for a runner | Longest wait for one job |
+|---|---|---|---|
+| 37043413533 (PRD-475 PR) | 146 min | 239 min | 19.3 min |
+| 37047989282 (`develop` PR) | 266 min | 127 min | 17.6 min |
+
+`ThreeNativeHQ/threenative` is public and has 0 self-hosted runners. Every job therefore shares
+the org's hosted concurrency cap with every other open PR. A full board is about 45 jobs. The branch-local
+`integration-*.yml` workflows (decals, exposure, exposure cold boot, …) started 124 runs since
+2026-10-01, against 72 `CI` runs, and they draw on the same pool.
+
+Job setup is already cheap (install 4 s, `workspace-dist` 81 s), so trimming jobs moves little. The
+pool size is the lever. [PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) narrows what a
+PR spends runners on. This PRD adds runners.
+
+Files inspected: `.github/workflows/ci.yml` (19 Linux `runs-on`), `.github/workflows/native-platforms.yml`
+(Linux jobs plus `macos-15`, `windows-2025`, `ubuntu-24.04-arm`), `.github/workflows/integration-*.yml`,
+`.github/actions/playwright-chromium/action.yml` (`--with-deps` runs apt), `scripts/ci-local.sh`,
+`scripts/__tests__/ci-structure.spec.ts`.
+
+## Solution
+
+Ephemeral GitHub Actions runners run in Ubuntu 24.04 containers on the owner's machine, registered
+**to this repository only** with the label `tn-local`. Every Linux job that can move picks its runner
+from one expression:
+
+```yaml
+runs-on: ${{ (github.event.pull_request.head.repo.fork || !vars.TN_RUNNER) && 'ubuntu-24.04' || vars.TN_RUNNER }}
+```
+
+- **Kill switch:** delete the `TN_RUNNER` repository variable and every job goes back to hosted
+ runners. `scripts/ci-runners.sh up` sets `TN_RUNNER` after the runners come online, and `down` clears it
+ before they stop. After a crash or power loss, recover with `gh variable delete TN_RUNNER`.
+- **Fork guard:** a PR from a fork always runs on hosted runners and never reaches the machine.
+- **Same verdicts, no caching:** the job still runs inside the workflow, on a fresh checkout of the
+ candidate SHA, and `ci-required` is unchanged. The rule "Never cache test verdicts" stands.
+- **One full run per candidate:** while `TN_RUNNER` is set, agents run only the focused checks for what
+ they changed (the touched spec, the affected playtest), then push. The full board runs once, on
+ `tn-local`. Today the agent runs `pnpm test` or `pnpm ci:local --full` here, then CI repeats the same
+ board, so this removes the duplicate. With `TN_RUNNER` unset, CI is hosted and slow again, and the
+ local full run before pushing comes back.
+- **Comparable timing:** each container is pinned (`--cpuset-cpus`) to two whole cores, four threads,
+ the CPU shape of a hosted `ubuntu-24.04` runner, so `nproc` reads 4 and every worker pool sizes itself
+ to that. A `--cpus 4` quota is not enough: `nproc` still reads every host CPU and vitest ran 8 workers
+ in a 4-core budget. This is a controlled starting point, not a promise of identical timing.
+- **Host stays stable:** five slots by default, and two cores are never given to a slot. Each slot gets
+ `--memory 12g --memory-swap 12g --oom-score-adj 800`, so under memory pressure the kernel kills a CI
+ job before the owner's processes.
+- **Light lane:** `scope`, `ci-required`, `run-summary` and other small joins run on one more,
+ unpinned `tn-local-light` runner (`--cpus 1`, 2 GB) that heavy jobs never select. Otherwise a 20-minute
+ build on every heavy slot holds a 10-second join in the queue.
+- **Runs itself:** the operator installs `scripts/ci-runners.sh up`/`down` as a boot service (a user
+ service with lingering on Linux) that first checks out the latest `develop`. So the pool starts with
+ the machine, takes image fixes on the next start, and clears `TN_RUNNER` on shutdown. `up` and `down`
+ also delete offline `tn-local*` registrations: an idle runner that is stopped never deregisters.
+ `pnpm ci:runners:install` does the whole setup; the operator guide is
+ [tools/ci-runners/README.md](../../../tools/ci-runners/README.md).
+- **Ephemeral:** each container takes one job, exits and is recreated by a host-side `docker run --rm`
+ loop. No `/tmp`, port, Xvfb display or workspace state crosses jobs.
+
+**Stays hosted:** `native-platforms` macOS, Windows, iOS and `linux-arm64` legs; the `supply-chain`
+job (its gitleaks step needs `docker run`, and mounting the Docker socket would give jobs root on the
+host); release workflows; `site-docs`.
+
+```mermaid
+flowchart LR
+ E[PR push / dispatch] --> X{fork PR or TN_RUNNER unset?}
+ X -- yes --> H[hosted ubuntu-24.04]
+ X -- no --> L[tn-local container 4 CPU / 16 GB, ephemeral]
+ L --> R[ci-required]
+ H --> R
+ E --> N[macOS / Windows / iOS / arm64] --> H
+```
+
+**Image:** `ghcr.io/actions/actions-runner` (Ubuntu 24.04 base with passwordless sudo) plus what the
+moving jobs take from the hosted image: build-essential, cmake, ninja, ccache, clang, xvfb, and the
+Playwright Chromium system dependencies. Phase 3 adds JDK 17, the Android SDK and emulator, with
+`/dev/kvm` passed through.
+
+**Credential:** ephemeral runners re-register for every job, so the stack mints registration
+tokens with an owner-supplied fine-grained token (this repository, "Administration: read & write").
+The token lives in an untracked env file outside the repo and is never printed.
+
+**Risks:** a timing-shaped test that passes or fails only under the 4-CPU cap. Shared host disk filling
+from container layers (see the btrfs and tmpfs notes in the owner's memory). The machine sleeping
+while `TN_RUNNER` is set: jobs queue until the switch is cleared.
+
+## Acceptance Criteria
+
+- [x] AC-1 [shared]: proof: CI run id plus `runner_name` per job from `gh api …/runs//jobs`. A non-draft
+ PR's `CI` run executes every Linux job except `supply-chain` on a `tn-local` runner, and `ci-required`
+ passes. Evidence: pending.
+ Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): conclusion success, `ci-required` success. 39 jobs succeeded: 33 on `tn-local`, 5 on `tn-local-light`, 1 hosted (`supply-chain`); `native-platforms` skipped by scope.
+- [ ] AC-2 [shared]: proof: per-job `started_at − created_at` summed with the baseline's script. Total time
+ jobs spent waiting for a runner on a full-board PR drops below 60 min (baseline 239 min, run
+ 37043413533). Measured, not met: run 37070815769 waited 317 min in total across 40 jobs on 5 heavy +
+ 1 light slots, with 42 min wall against the baseline's 39 min wall and zero hosted minutes. A 40-job board on
+ six slots queues by construction, so this criterion needs either more slots or overflow to hosted
+ runners. Owner decision pending.
+
+## Blocked on
+
+- Fork PR workflow approval set to "Require approval for all outside collaborators" (the API reads
+ `first_time_contributors` on 2026-10-02; it must read `all_external_contributors`) — unblocked by João.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+|---|---|---|---|
+| Linux CI job runs on the owner's machine | PR push → `ci.yml` job `runs-on` expression → `tn-local` container; fill `file:line` | Hosted `ubuntu-*` stays as the fallback branch of the same expression | AC-1 |
+| Runner lifecycle and kill switch | `scripts/ci-runners.sh up/down` → compose stack + `TN_RUNNER` variable | New | Phase 1 |
+
+## Decisions
+
+- 2026-10-03 (measured): the pool is overflow capacity, not the default. The repository is public, so
+ hosted runners cost nothing and run ~20 jobs at once; routing every Linux job to 5 local slots made
+ boards slower (run 37099132853: 20 min in, 15 jobs queued locally). A balancer advertises
+ `TN_RUNNER`/`TN_RUNNER_LIGHT` only while a runner is idle (two polls in a row) and withdraws them at once
+ when none is.
+
+- 2026-10-02 (João, during the first live runs): use more of the machine, but never at the cost of
+ desktop stability. Five pinned slots of two cores each, two cores reserved for the host, 12 GB caps
+ with CI as the OOM victim, and a reserved light lane (review by Astra the same day).
+
+- 2026-10-02 (João): the runner registration token exists. It is a fine-grained token on
+ `ThreeNativeHQ/threenative` only, with Administration read & write, stored in the operator's untracked
+ runner env file. Verified by listing the repository's runners (0).
+
+- 2026-10-02 (João): self-hosted runners rather than local runs vouching for a commit. A self-hosted
+ runner gives the same offload with no forgeable status and no stale merge-ref verdict, and it keeps
+ "Never cache test verdicts".
+- 2026-10-02 (João): the cost is accepted. Actions minutes on public repositories stay free on
+ self-hosted runners; GitHub's announced $0.002/min self-hosted charge is postponed and exempted
+ public repos.
+- 2026-10-02 (João): the runner must also remove the duplicate run, not only the queue. Agents stop
+ running the full board locally while `TN_RUNNER` is set. Skipping a CI job because an identical tree
+ already passed it would break "Never cache test verdicts", so it is out of scope; it would be its own PRD.
+
+## Execution Phases
+
+#### Phase 1: The runner stack comes up and takes jobs
+**Status:** COMPLETE — `pnpm ci:runners:install` runs it as a boot service
+**Files:** NEW `tools/ci-runners/Dockerfile`, NEW `tools/ci-runners/entrypoint.sh`, NEW
+`scripts/ci-runners.sh` (`up [N]`, `down`, `status`; default N = 4, read from the env file).
+
+Deviation from the plan below, owner-approved 2026-10-02: **no compose.yml.** A host-side loop of
+`docker run --rm` replaces the compose service, because `restart: always` restarts the *same*
+container — same filesystem, same hostname — and the ephemeral claim here is a fresh one of both
+per job.
+
+**Implementation:** Ubuntu 24.04 runner image with the tool set above. One loop per slot
+(`setsid nohup`), `cpus 4`, `memory 16g`, and ephemeral repo-scoped registration with labels
+`tn-local`. `up` waits for N online runners via `gh api repos/{owner}/{repo}/actions/runners`
+before `gh variable set TN_RUNNER --body tn-local`. `down` deletes the variable first, then stops
+the stack. The script fails closed when the env file or token is missing.
+
+
+- [x] `scripts/ci-runners.sh up` brings 4 runners online with label `tn-local`. proof:
+ `scripts/ci-runners.sh status` lists 4 online runners. Evidence: 2026-10-02, `up` printed "4 online;
+ TN_RUNNER=tn-local" and `status` listed tn-23e70acfd553, tn-365ab3636a2e, tn-4d83d32a4c75 and
+ tn-6071a30e949e, all online. The first `up` timed out with 0 online (wrong WORKDIR), cleared the
+ variable and stopped the pool, which is the fail-closed path working. Not started: `status` reports
+ `TN_RUNNER=unset`, 0 containers, 0 online runners on 2026-10-02.
+- [x] A runner container recreates itself after its job and keeps no state from it. proof: two
+ consecutive dispatched jobs report different container hostnames and an empty `/tmp`.
+ Not started: the image's `/tmp` is empty in a fresh container (`ls -A /tmp | wc -l` → 0), which
+ is one container's worth of the claim.
+ Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): 38 `tn-local*` jobs ran on 38 distinct runner names (`tn-`) from 6 slots, so every job got a fresh container; each starts from the image, whose `/tmp` is empty (`ls -A /tmp | wc -l` = 0 in the smoke).
+
+#### Phase 2: `ci.yml` and the integration workflows route through the switch
+
+**Status:** COMPLETE
+**Files:** EDIT `.github/workflows/ci.yml`, `.github/workflows/integration-*.yml`,
+`scripts/__tests__/ci-structure.spec.ts`; the `integration-*.yml` template wherever agents copy it from;
+`AGENTS.md` ("Nearest lane first, CI last") plus its regenerated `CLAUDE.md` mirror.
+**Implementation:** Replace each movable Linux `runs-on` with the routing expression. `supply-chain` keeps
+`ubuntu-latest`. Extend `ci-structure.spec.ts` so every Linux job in these workflows either uses the
+exact expression or is on a named hosted allow-list (`supply-chain`), and no macOS, Windows or arm64
+job ever does. 23 lines routed (18 in `ci.yml`, one each in five `integration-*.yml`); `supply-chain`
+and `integration-decals.yml`'s `ubuntu-24.04-arm` job are the only Linux `runs-on` left hosted, and
+`native-platforms.yml` is untouched until Phase 3.
+
+- [x] Structure spec enforces the routing. proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts`
+ — 142 passed, 0 failed. Red first: with no workflow edited the new test failed on
+ `.github/workflows/ci.yml scope runs on 'runs-on: ubuntu-latest'`; after routing, setting `budgets`
+ (ci.yml:1051) back to `ubuntu-latest` failed it again on that job and it was restored. Also green:
+ `pnpm lint` (exit 0), `pnpm check:docs` (2496 links), `pnpm sync:agents --check` (22 mirrors).
+ One pre-existing assertion had to be narrowed: `template-nonvisual` forbade the substring
+ `pull_request` anywhere in the job, which the routing expression mentions without gating on it.
+
+- [x] With `TN_RUNNER` set, a PR run lands its Linux jobs on `tn-local`. proof: AC-1 run.
+ Evidence: CI run 37070815769 (PR #404, head 94732de65, 2026-10-02), as AC-1.
+- [x] `AGENTS.md` says focused checks then push, not the full board, while `TN_RUNNER` is set. proof:
+ `pnpm sync:agents --check` and `pnpm exec vitest run scripts/__tests__/sync-agent-docs.spec.ts` pass.
+ Land it only after the AC-1 run is green.
+ Evidence: landed after AC-1; `pnpm sync:agents` + sync-agent-docs, primary-docs and instruction-budget specs, 24 passed (2026-10-02).
+- [x] Light jobs never wait behind heavy ones (proof: `ci-structure.spec.ts` case, plus a full-board run
+ whose `ci-required` starts within 60 s of its last `needs` finishing): `scope`, `golden-path`, `build`,
+ `ci-required` and `run-summary` route to `tn-local-light`, and no heavy job can.
+ Evidence: ci-structure.spec.ts routing case (red with `typecheck` on the light lane), and CI run 37070815769 (PR #404, head 94732de65, 2026-10-02): `ci-required` started 22:48:50Z, 3 s after the last job it needs finished at 22:48:47Z.
+- [x] With `TN_RUNNER` unset, the same workflow runs fully hosted. proof: `workflow_dispatch` run id with
+ every `runner_name` hosted.
+ Evidence: `workflow_dispatch` run 37093694594 on `develop` with both variables deleted: all 43 jobs that started
+ ran hosted (38 `ubuntu-24.04`, 1 `ubuntu-latest`, 1 arm64, 2 macOS, 1 Windows), none on `tn-local`. Cancelled
+ after that proof so it would stop holding the hosted pool.
+
+#### Phase 3: The Linux `native-platforms` legs move too
+
+**Status:** COMPLETE — Linux legs on `tn-local`; the Android emulator stays hosted (measured exception)
+**Files:** EDIT `.github/workflows/native-platforms.yml`, `tools/ci-runners/Dockerfile` (JDK 17, Android
+SDK, emulator, `/dev/kvm`), `scripts/__tests__/ci-structure.spec.ts`.
+**Implementation:** Route `web-reference`, `desktop-parity`, `android-emulator-parity`, `release-reports`,
+the remaining `ubuntu-24.04` jobs and the `linux-x64` rows of the `desktop` and `starter-linux` matrices.
+Keep `macos-15`, `windows-2025`, `ubuntu-24.04-arm` and `ios-simulator` hosted. The `android-emulator-runner`
+action needs `/dev/kvm` access inside the container.
+
+Decisions this phase had to make, 2026-10-02:
+
+- **`publish-android-v8` stays hosted.** It is the release write itself — `permissions: contents: write`
+ and a `gh release upload` — and no pool container is handed a token that can publish.
+ `android-v8-source` does move: it needs no secret, it is a published-asset download on every run but
+ a cold one, and its own action records a two-core cold build finishing inside the 210-minute ceiling,
+ which is this pool's slot shape.
+- **Light lane: `scope` and `networking-matrix` only.** Both are a script with no `pnpm install` and no
+ toolchain. `performance-coverage` and `release-reports` are joins too, but each installs the workspace
+ and `performance-coverage` has a five-minute ceiling, so they take a heavy slot.
+- **`desktop` has no Linux row.** Its matrix is `macOS` and `Windows` only; the movable Linux matrix row
+ is `starter-linux/linux-x64`.
+- **The KVM step had to stop asserting udev.** In the container there is no udev and `/sys` is read-only,
+ so `udevadm control --reload-rules` and `udevadm trigger` both exit 1 and took the whole emulator lane
+ with them. They are now `|| true`, which is what the step's own "report the mode, never assert it"
+ rule already says; `scripts/ci-runners.sh` passes `--device /dev/kvm` and the image's entrypoint sets
+ the node's mode before the job can read it.
+- **The image carries the pinned SDK, not the emulator.** `cmdline-tools` 16.0 with licenses accepted,
+ `platform-tools`, `platforms;android-35`, `platforms;android-36` (`compileSdk` in
+ `android/app/build.gradle.kts`), `build-tools;35.0.0` and `ndk;28.2.13676358` (`ndkVersion` there,
+ and what the workflows' own `sdkmanager` line installs), owned by `runner` because sdkmanager writes
+ into `ANDROID_HOME` at job time. `android-emulator-runner` installs `emulator` and the system image
+ itself, which is the only job that wants them. Image: 9.28 GB against the pool's 5.25 GB.
+
+- [x] A `native-platforms` run passes with its Linux legs on `tn-local`. proof: run id plus per-job
+ `runner_name`.
+ Exception, measured: `android-emulator-parity` stays hosted. SwiftShader renders its GPU on the CPU, and on a
+ 4-thread slot run 37082733117 spent 41 of the job's 45 minutes still running APKs, where hosted takes ~30.
+ Evidence: CI run 37089715252 (PR #404, merged d99a6281c), latest attempt: every native-platforms leg success.
+ On `tn-local`: starter linux-x64, Android V8 source payload, web conformance reference, desktop web/native
+ parity, release evidence reports, collector coverage. On `tn-local-light`: caller selection, networking
+ matrix. Hosted as designed: macOS, Windows, iOS, linux-arm64, and the emulator exception above.
+- [ ] Waiting time meets AC-2. proof: the AC-2 measurement on a full-board PR after this phase.
diff --git a/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md b/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md
new file mode 100644
index 0000000000..35cee21c0b
--- /dev/null
+++ b/docs/PRDs/CI/PRD-481-ci-does-each-piece-of-work-once.md
@@ -0,0 +1,209 @@
+---
+prd_contract: v1
+---
+
+# PRD-481 — CI does each piece of work once
+
+**Status:** PARTIAL — all three phases landed (#405) and the merge queue is on; live reuse, merge-group and cache-hit proofs pending; AC-1 audits the 7 days from 2026-10-03
+**Complexity:** 5 (HIGH)
+**Owner:** CI tooling
+**Depends on:** None ([PRD-480](PRD-480-linux-ci-runs-on-the-owner-machine.md) and
+[PRD-380](PRD-380-a-pull-request-never-starves-the-runner-pool.md) are complementary, not prerequisites)
+
+Complexity: 5 → MEDIUM (about 8 files +2, verdict reuse is new logic +2, GitHub API +1); risk override → HIGH
+because a wrong reuse key passes an untested tree.
+
+## Context
+
+Audit of `ThreeNativeHQ/threenative` Actions history from 2026-09-18 to 2026-10-02: 1,651 runs, 35,168 jobs,
+115.5k runner-minutes (about 58k a week). Waste by bucket:
+
+| Bucket | Runner-min (14 days) | Evidence |
+|---|---|---|
+| PR runs cancelled by a newer push | 43.1k (37%) | 349 runs, median 25 min before cancellation; 56 of the cancelling pushes were Markdown-only (37 PRD ticks) |
+| Same tree tested again on the way to `main` | 10.1k | 27 promotion-PR reruns; 9 of 10 `push: main` runs repeat a tree the promotion PR passed (36095260574, 36134527157, 36960030428) |
+| Nightly on an unchanged tree | 4.2k | 14 of 15 scheduled runs; `af60e210` red 7 nights running (36230464827 to 36847832850) |
+| Runs still going after the PR merged or closed | 5.0k | 41 runs; 36372446379 ran 382 min after merge |
+| Pipeline cache verification on unrelated PRs | 2.4k | android-arm64 ABI job, 31 min median, 41 runs; still lists dead branch `feat/prd-368-persistent-pipeline-cache` |
+
+Within one full run (37043413533):
+
+- The workspace dist compiles about 9 times. Every consumer `needs: scope` only, so all miss the cache
+ `build-artifacts` saves (`.github/actions/workspace-dist/action.yml:54`, `ci.yml:953`). That is 71–95 s each.
+- Shards are mis-sized. `template-nonvisual (shooter)` takes 967 s in one shard, `rain` 654 s and
+ `sailing` 542 s, while `starter` is split into three ~210 s shards that each pay about 90 s of setup
+ (`ci.yml:730-747`). `test-unit (3/3)` takes 605 s against 340 s for 1/3 (`ci.yml:206`).
+- `test-native` compiles the Linux host every run. Its cache key hashes `ci.yml` (`ci.yml:~354`), and
+ `develop` never runs CI on push, so no PR ever restores a shared cache. Three PRs each hold a 1.35 GiB copy.
+
+Already owned elsewhere, not repeated here: the queue (PRD-480); release proof on PRs, the native
+matrix on PRs, and cancelling merged-PR runs (PRD-380 phases 1–3). The ~3.5k/week of
+`Native runtime release` PR runs is PRD-380 phase 1.
+
+**Why "Never cache test verdicts" exists:** PR #33 (2026-09-01) let `golden-path` skip on a hash of a
+hand-listed set of inputs. PRD-373 (2026-09-11) removed it, because a key that misses one input
+reuses a stale pass. The same failure happened earlier with `native-platforms` path filters, which left three reds on
+`main`. The danger is a partial key, not reuse itself.
+
+## Solution
+
+**Tree reuse.** The `scope` job computes the tree SHA of the candidate it checked out (for a PR, the
+merge ref). It then looks for a completed, successful `CI` run whose checked-out candidate has that
+exact tree, found through the Actions API, never by a name a job can write. An identical tree is not
+the same validation, so a source run also has to cover what this run proves. All five must hold or
+the board runs:
+
+1. the checked-out candidate trees are identical;
+2. the source is a completed CI run of this repository, read through the Actions API, and not a
+ `workflow_dispatch` — an audit a person asked for is never the evidence another run leans on;
+3. the source ran the full board: every board job and matrix leg this run requires concluded
+ `success` there, none skipped. `native-platforms` arrives from the Actions API as one check,
+ because a reusable workflow's legs stay inside the caller;
+4. the source's validation profile is equal or stronger, axis by axis — the target tier
+ (`main` > `develop` > unproven) and the native matrix tier (`full` > `reduced` > `none`). A run
+ record in this repository reports no `base_ref`, so a source's target comes from the pull requests
+ its commit belongs to, and an unprovable one ranks lowest;
+5. the source's jobs ran on this run's own runner class (hosted or `tn-local`, from the jobs API), and
+ its `ci-required` concluded `success` — the one part of a verdict no repository can compute for
+ itself.
+
+On a hit the job emits `selection: reused` with the source run id and the profile that justified it.
+Every board job skips, and `ci-required` re-reads that run and repeats 3–5 before it passes.
+
+- **Applies to:** the `develop → main` promotion PR, `push: main`, re-pushes and rebases with an
+ unchanged tree, and the merge group below.
+- **Never applies to:** `workflow_dispatch`, which stays an explicit full audit as a reuse target and
+ as a reuse source. Nightly reuses Monday to Saturday; Sunday's nightly is always full, because its
+ subject is the runner image and the network, which an unchanged tree cannot vouch for.
+
+**Merge queue on `develop`.** `ci.yml` gains `merge_group`. GitHub then tests the exact result a merge
+would produce before it lands, which closes today's gap: two PRs merged back to back put a tree
+on `develop` that no run tested. When `develop` has not moved, the merge group's tree equals the
+PR's tested tree, and tree reuse makes the queue instant.
+
+**Work once per run.** Consumers download the `build-artifacts` dist instead of rebuilding it. The
+native host cache key drops `ci.yml`, and a build-only job on `develop` pushes warms it. Shards are
+sized to land at about 4–6 min each.
+
+**Pushes that cancel a running board.** With PRD-480 the board finishes in about 15–20 min instead of
+40+, which shrinks the cancellation window. The rule that cuts the rest is in PRD-482.
+
+## Acceptance Criteria
+
+- [ ] AC-1 [shared]: proof: the audit script in the PR body re-run over the 7 days after the last phase lands.
+ Weekly runner-minutes outside the PRD-380 and PRD-480 buckets drop by at least 7.5k against the
+ 2026-09-18 to 10-02 baseline. Evidence: pending.
+
+## Blocked on
+
+- Merge queue enabled on `develop` in the repository ruleset — unblocked by João (org admin).
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+|---|---|---|---|
+| Tree reuse | `scope` job → `scripts/ci-change-scope.mjs` → `selection: reused` → `ci-required` | Replaces nothing; full board stays the miss path | Phase 1 |
+| Merge queue | `merge_group` event → `ci.yml` | `develop` merges go through the queue instead of direct squash | Phase 1 |
+| Shared dist | `build-artifacts` upload → consumers' download step | Per-job `workspace-dist` builds are deleted | Phase 2 |
+
+## Decisions
+
+- 2026-10-02 (João): verdict reuse is allowed when it stays reliable. Reuse is keyed only on the
+ whole-repo tree, recorded only by a successful CI run, and never on a hand-listed set of inputs. AGENTS.md
+ "Never cache test verdicts" becomes "Reuse a verdict only for an identical whole-repo tree that CI
+ itself passed".
+- 2026-10-02 (review by Astra, adopted): same tree is not same validation. A reduced pass must never
+ satisfy a fuller requirement — PRD-380 gives an ordinary pull request a reduced native matrix, and
+ this planner already lets a clean develop pull request skip `native-platforms` — so reuse needs an
+ identical tree **and** a source run whose validation profile covers this run's. A git tree also
+ carries neither repository variables (`vars.TN_CI_FORCE_FULL`, `vars.TN_DEVELOP_CI_ENABLED`) nor a
+ mutable runner image, so the profile is read back from the Actions API and anything unreadable is a
+ miss that runs the board. The weekly full run is the backstop for what a tree cannot see, not a
+ substitute for it.
+
+## Execution Phases
+
+#### Phase 1: The same tree is tested once
+
+**Status:** IN PROGRESS — code landed, live proofs pending
+**Files:** EDIT `scripts/ci-change-scope.mjs`, `scripts/ci-required.mjs`, `.github/workflows/ci.yml`
+(`merge_group`, schedule weekday/Sunday split), `AGENTS.md` (+ mirror), `scripts/__tests__/ci-structure.spec.ts`.
+**Implementation:** lookup by tree via `gh api` with the job's `GITHUB_TOKEN` (`actions: read`). A miss,
+an API error or an ambiguous match falls back to the full board. Fail closed means run the work, never
+skip it.
+
+- [ ] A promotion PR whose tree already passed reports `reused`, under 2 min. proof: CI run id plus the
+ source run id it cites.
+- [x] (proof: `pnpm exec vitest run scripts/__tests__/ci-needs.spec.ts`) A tree that changed by one byte
+ runs the full board, and a tree that already passed is reused only when a source run covers this run's
+ validation profile; the spec carries a case where the trees differ by one
+ file, a case where the API errors, and the four coverage cases: a develop pull request's pass cited
+ for a promotion, a source that never ran a matrix leg this run requires, a source whose jobs ran on
+ another runner class, and a promotion pass satisfying a develop pull request. Evidence: 2026-10-02,
+ 30 passed (those four plus the reused verdict were red before the implementation); the same run also
+ clears `ci-structure`, `ci-efficiency`, `sync-agent-docs` and `primary-docs`. The suite stubs `gh` on
+ `PATH`; the miss reasons are asserted too, so a full run that never looked cannot read like one that
+ looked and found nothing.
+- [ ] `develop` merges go through the merge queue, and an unchanged-base merge group reuses. proof: merge
+ group run id.
+
+#### Phase 2: Each run does each piece of work once
+
+**Status:** IN PROGRESS — dist built once and shards under 6 min proven; the cache box needs a `develop`-warmed key
+**Files:** EDIT `.github/workflows/ci.yml`, `.github/actions/workspace-dist/action.yml`,
+`.github/workflows/native-platforms.yml` (the Linux host built once), `scripts/__tests__/ci-structure.spec.ts`.
+
+- [x] Only `build-artifacts` compiles the workspace dist; every other job downloads it. proof: a full CI run
+ where no consumer's log contains the dist build step.
+ Evidence: hosted CI run 37071464562 (PR #405, 2026-10-02): of every non-native job, only `build-artifacts`
+ logs `scripts/workspace-packages.ts build`; typecheck, test-unit, test-browser and test-playtest log
+ "Take the compiled workspace from this run's producer" (download-artifact).
+- [ ] The native host cache restores on a PR from a `develop`-warmed key. proof: a `test-native` log with a
+ cache hit and a build time under 60 s.
+- [x] No template or unit shard runs longer than 6 min. proof: per-job durations of one full CI run.
+ Measured on run 37071464562, two legs were over: `test-unit (4/4)` at 481s and
+ `template-nonvisual (sailing, 2/2)` at 467s, with `rain (3/5)` 358s and `rain (2/5)` 299s next and
+ every other unit and template leg under 360s. This commit takes sailing from two slices to three
+ — its `float-after-the-run-ends` scenario measured 247s and sat alone on the second slice with
+ two more scenarios, 404s of work against a ~63s overhead — and splits
+ `template-assets-compile.spec.ts`, 317s of test time in one file, into the two halves of the
+ template list, which is the one thing `vitest --shard` cannot divide. Replaying vitest's own
+ `sha1(path)` partition over that run's per-file durations puts every unit leg at ~137-240s of
+ test time and sailing's worst slice at ~335s. The box stays unticked: its proof is a full CI run's
+ per-job durations, and nothing here has run on GitHub.
+ Evidence: hosted CI run 37078784853 (PR #405 head 8e1436307, 2026-10-02): slowest legs test-unit (1/4) 329 s,
+ rain 4/5 319 s, sailing 2/3 289 s; every template and unit shard under 360 s.
+
+#### Phase 3: Triggers fire only when they prove something
+
+**Status:** COMPLETE
+**Files:** EDIT `.github/workflows/pipeline-cache.yml`, DELETE `.github/workflows/integration-*.yml` for
+`.github/workflows/integration.yml`, `scripts/workspace-packages.ts` (a hand-listed package set is one
+run block, not one file), `scripts/__tests__/ci-structure.spec.ts`,
+`scripts/__tests__/workspace-packages.spec.ts` (workflow-file allow-list, so per-branch workflows cannot
+land unreviewed).
+
+- [x] `pipeline-cache.yml` fires only on paths its proof reads, with no dead branch. proof:
+ `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts`. Evidence: 2026-10-02, 126 passed;
+ the new assertion fails on the pre-change file and passes after `feat/prd-368-persistent-pipeline-cache`
+ is gone from `push.branches`.
+- [x] Integration workflows skip drafts and fire once per commit. proof: `pnpm exec vitest run
+ scripts/__tests__/ci-structure.spec.ts`, rejecting a `push` plus `pull_request` pair. Evidence:
+ 2026-10-02, 126 passed; the pair case is a fixture in the spec and the assertion fails on the
+ pre-change files. `integration-csg`'s unfiltered `push` and `integration-decals`' per-branch `push`
+ (pull request #394 merged 2026-10-02) are both removed; every integration job now carries
+ `!github.event.pull_request.draft`, and each trigger gained `ready_for_review` so that guard cannot
+ silence the lane.
+- [x] (proof: `pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts`) The per-feature
+ `integration-*.yml` files fold into one `integration.yml`, one job per feature with its own `paths`
+ gate, draft guard and routing expression kept per job — its allow-list lists one integration
+ workflow and rejects a new `integration-*.yml`. Evidence: 2026-10-02, 131 passed, each of the two
+ new assertions red first: a lane without `needs: paths` and a lane gate without
+ `!github.event.pull_request.draft` both fail, and adding `integration-newlane.yml` fails the
+ allow-list. The five lanes' steps, permissions, timeouts, runners, checkout refs and artifact names
+ are unchanged; `dorny/paths-filter` (pinned to the v4.0.3 commit) reads the pull request's own
+ changed paths once, so a lane this commit does not touch is skipped rather than given a runner, and
+ the csg and decals supersession groups become one. What changes: a job id's check name
+ (`test` becomes `csg` / `ik` / `vegetation`, which were three indistinguishable `test` checks), a
+ ~30-second gate job on every pull request, and cancellation applying to the three lanes that queued
+ behind a superseded run instead of cancelling it.
diff --git a/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md b/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md
new file mode 100644
index 0000000000..752539a8be
--- /dev/null
+++ b/docs/PRDs/PRD-476-fluid-lab-particle-water-at-60-fps.md
@@ -0,0 +1,208 @@
+# PRD-476 — Fluid Lab: particle water on the GPU at 60 fps
+
+**Status:** PROPOSED
+**Complexity:** 9 (HIGH); risk override: none.
+**Owner:** Engine implementation agent
+**Depends on:** None
+**Progress:** 0/8 required boxes verified
+
+## Context
+
+Absorb `/home/joao/Downloads/fluid-lab-v2.html` ("Fluid Lab 02", 238,714 bytes, SHA-256
+`e389fe7f5198c3ce4365b343281c1e34317c55082514741815e9217ad14f4ef7`) as engine capabilities, remove
+the frame-rate wall that holds it near 30 fps, and rebuild it as a sandbox game on those
+capabilities. This request authorizes the PRD only; nothing below has started.
+
+The file is a raw Three.js 0.180 (WebGL2) + Rapier 0.19 lab with eight experiments: dam break,
+splash tank, buoyancy, waterfall, fountain, whirlpool, viscosity, and ocean & rain. Its readable
+source is embedded as a JSON map at `fluid-lab-v2.html:69` (`window.FLUID_LAB_SOURCES`); lines
+70–1280 are the bundled module. The Downloads file is an input, never a runtime dependency.
+
+### What the source does, and where it goes
+
+| Source (`FLUID_LAB_SOURCES` path) | Behaviour | Destination |
+| --- | --- | --- |
+| `src/physics/fluid.js` | CPU Position Based Fluids: uniform-grid neighbours, density constraints, XSPH viscosity, cohesion, vorticity confinement, box/sphere collider projection, emit/drain/stir | **New** `FluidParticles3D` in `packages/core`, on GPU compute |
+| `src/render/surface.js` | CPU density lattice + surface nets mesh, gradient normals, per frame | **New** density volume on `FluidParticles3D` (data); the surface look is game `src/render/` |
+| `src/physics/coupling.js` | Rapier bodies, sampled Archimedes lift and drag, particle→body reaction impulses | Existing `Buoyancy3D` (`@threenative/physics`) on the fluid's height source; colliders feed the solver |
+| `src/physics/waves.js` | Damped heightfield wave equation, rain/pointer impulses | Existing `RippleField` (+ `WaveField` swell) — no new engine code |
+| `src/physics/secondary.js` | Spray droplets, crown sheets, advected foam, bubbles | Existing `GPUParticles3D` with a game process function; foam and crown shading in game `src/render/` |
+| `src/render/shaders.js`, `water-renderer.js`, `stage.js` | Refraction, absorption, Fresnel, exit-depth pass, debug views | Game `src/render/` (rule (b): it decides the look) |
+| `src/simulation.js`, `presets.js`, `ui.js`, `main.js` | Eight scenes, actions, inspector, fixed-step loop | Sandbox game scenes, HUD, and the engine's fixed-step dispatch |
+
+### Why it runs at ~30 fps (measured)
+
+`src/physics/fluid.js` and `src/render/surface.js` are dependency-free, so they were timed directly
+in Node 20.19.6 on this machine (splash-tank fill, 60 steps after a 30-step warm-up):
+
+| Quality | Particles | `FluidSolver.step` | `FluidSurface.update` |
+| --- | --- | --- | --- |
+| Light | 760 | 14.3 ms | 4.2 ms |
+| Balanced (default) | 1,625 | 29.9 ms | 6.6 ms |
+| High | 2,175 | 40.9 ms | 9.6 ms |
+
+At the default quality the CPU solver alone is 1.8× the 16.7 ms frame, before the surface rebuild
+and four scene renders per frame (`water-renderer.js:118–123`: scene target, back faces, composite,
+quad). `main.js` runs up to three 1/60 s steps per frame and caps the accumulator at three steps,
+so when a step outruns the frame the display drops to the 30 fps vsync bucket and the simulation
+falls into slow motion. Light quality still misses the budget once rendering is added. Making the
+CPU code a few times faster would not leave room for rendering on a phone; the neighbour search
+and constraint passes are data-parallel work that belongs on the GPU.
+
+### Existing mechanisms reused
+
+Capability search and detail were run for the full request and for each mechanic.
+
+- `FluidField2D` (`packages/core/src/fluid-field.ts`, 748 lines; PRD-249): the precedent shape —
+ TSL compute passes, `IComputeDriven`, `ctx.add` lifetime, numeric samplers, game-owned look,
+ conformance case `77-fluid-field`, example `examples/prd249-fluid-field` with web and desktop
+ playtests. `FluidParticles3D` copies this shape; it does not replace the 2D field.
+- `ComputeDrivenRegistry` / `IComputeDriven` (`packages/core/src/compute-driven.ts`): pass order,
+ warm-up, release.
+- `GPUReadback`: throttled GPU→CPU copy with `staleFrames`, for stats and the height source.
+- `Buoyancy3D` (`packages/physics/src/Buoyancy3D.ts`): hull points, density, drag, any height
+ source. Covers the lab's buoyancy and splash-slowdown.
+- `RippleField`, `WaveField`: the ocean scene, unchanged.
+- `GPUParticles3D`: spray and bubbles. A search for "splash spray droplets with lifetime and
+ gravity" returned verdict `none`; this is a manifest recall gap, fixed in Phase 3.
+- `WaterSurface3D` is a level-plane refraction/reflection helper; it does not fit a free-form
+ particle surface and is not used here.
+
+No 3D particle fluid, SPH/PBF solver, or isosurface extraction exists in `packages/`.
+
+Complexity: 2 for 6–10 implementation files, 2 for a new system, 2 for GPU pass ordering and
+cross-frame readback state, 2 for crossing the native host and the sandbox tarball boundary,
+1 for none of the rest → 9.
+
+## Solution
+
+```mermaid
+flowchart LR
+ G[game scene] -- fill / emit / drain / stir / force Fn --> F[FluidParticles3D]
+ R[Rapier bodies] -- collider transforms each fixed step --> F
+ F -- positions, velocities storage --> SP[GPUParticles3D spray process Fn]
+ F -- density volume --> M[game src/render water material]
+ F -- column heights via GPUReadback --> B[Buoyancy3D]
+ B -- forces --> R
+ F -- stats via GPUReadback --> H[HUD]
+```
+
+**`FluidParticles3D`** (`packages/core/src/fluid-particles.ts`, exported from the core index with
+`@situation` tags) is a GPU Position Based Fluids solver that draws nothing:
+
+- Options: `capacity`, `spacing`, `bounds`, `iterations`, `viscosity`, `cohesion`, `vorticity`,
+ `gravity`, plus an optional game TSL `force` function (the whirlpool's tangential drive). Every
+ option has a default measured from the source's balanced preset; invalid values throw.
+- Per fixed step, as ordered compute passes: predict, grid hash + counting sort, density/λ,
+ position delta (repeat `iterations`), collider projection, velocity update with XSPH viscosity,
+ cohesion and vorticity confinement.
+- Game API: `fill(min, max, velocity)`, `emit(position, velocity)`, `drain(region)`,
+ `stir(point, strength, radius)`, `setColliders([...])` (spheres and oriented boxes, updated each
+ step from Rapier transforms; static and dynamic).
+- Data out: `positions` / `velocities` storage nodes; a `density` 3D texture splatted each step;
+ `heightAt(x, z)` from a column-height grid copied through `GPUReadback` (reports `staleFrames`);
+ `stats` (`count`, `meanCompression`, `maxSpeed`) through the same throttled readback.
+- Fails closed: on a renderer without compute (WebGL2 backend) the constructor throws with a
+ named error rather than drawing nothing.
+
+**The look stays in the game.** The sandbox game's `src/render/` raymarches the density volume
+(refraction, absorption, Fresnel, debug views ported from `shaders.js`) and owns spray, foam and
+crown shading. A game can replace all of it without touching `packages/`.
+
+**The sandbox game** `../sandbox/fluid-lab/` is scaffolded and installed from tarballs like a
+user's, with all eight experiments as scenes, the lab's actions and keyboard, a HUD with the four
+metrics, and a `FRICTION.md` logged as it is built.
+
+**Risks.** 3D storage textures and atomics in TSL on the native Dawn host (proved by the
+conformance case before the sandbox depends on them); readback lag making floating bodies bob
+(`Buoyancy3D` already runs on fixed steps; `staleFrames` is reported, not hidden); particle
+tunnelling at the lab's 18 m/s velocity clamp (keep the clamp as an option).
+
+## Acceptance Criteria
+
+Phase boxes below are the acceptance criteria; each names its proof.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| GPU particle fluid | `ctx.add(new FluidParticles3D(...))` in `examples/prd476-fluid-particles/src/game.ts` and the sandbox scenes | New; the lab's CPU `FluidSolver` is not ported | Phase 1 boxes |
+| Fluid → body buoyancy | `new Buoyancy3D({ surface: fluid, ... })` in sandbox buoyancy/splash scenes | Replaces `coupling.js` sampling and reaction impulses | Phase 2 box 1 |
+| Native host | Conformance registry case on the desktop host | New case beside `77-fluid-field` | Phase 2 box 2 |
+| Discovery | `engine_search_capabilities` → manifest built by `pnpm build` | Adds `@situation` tags; no hand-edited JSON | Phase 3 box 1 |
+
+## Decisions
+
+- 2026-10-01 (planning agent, proposed; owner may overturn): GPU compute, not an optimised CPU
+ port. Measured 29.9 ms per balanced step on a desktop CPU; even a 3× gain leaves no frame budget
+ on a phone, and the charter makes a backend the game cannot write portably the engine's job.
+- 2026-10-01 (planning agent, proposed): a density volume the game raymarches, not GPU surface
+ nets. Surface nets need stream compaction and indirect draws on both hosts; the volume is data,
+ which keeps the look in `src/render/` like `FluidField2D`'s dye.
+- 2026-10-01 (planning agent, proposed): no particle→body reaction impulses. `Buoyancy3D` lift
+ and drag already slow and float bodies; add impulses when a scene shows a body passing through
+ water without slowing.
+- 2026-10-01 (planning agent, proposed): name `FluidParticles3D`, pairing `FluidField2D` and
+ `GPUParticles3D`; Godot has no fluid node to borrow.
+- Out of scope: Android/iOS frame-rate claims (no result here claims a platform it did not run
+ on); a follow-up PRD takes the Pixel 8 measurement once Phase 3 lands.
+
+## Execution Phases
+
+#### Phase 1: `FluidParticles3D` runs a dam break on the GPU at 60 fps
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/fluid-particles.ts` (new), `packages/core/src/index.ts` (export +
+JSDoc tags), `packages/core/__tests__/fluid-particles.spec.ts` (new),
+`examples/prd476-fluid-particles/` (new, copied from the `prd249-fluid-field` layout: `game.ts`,
+`main.ts`, `render/`, `conformance.js`, `playtests/`).
+**Implementation:** Port the `fluid.js` algorithm pass for pass into TSL compute behind
+`IComputeDriven`; keep the source's kernel constants and parameter defaults so behaviour can be
+compared. The example renders particles as points from `positions` (debug look only) and exposes
+`GameState` with `stats`, `steps`, and a front-position probe.
+
+- [ ] Option validation fails closed, passes dispatch in the documented order, `fill`/`emit`/ `drain` respect capacity, and scene removal releases buffers. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts`
+- [ ] A released dam-break column runs across the tank and settles with mean compression ≤ 0.05 and every particle inside `bounds` (GameState resource assertions). proof: `node packages/playtest/dist/runner/cli.js examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json --url http://127.0.0.1:5173 --server-command "pnpm --filter prd476-fluid-particles dev --host 127.0.0.1" --browser-recipe webgpu`
+- [ ] With 6,000 particles (the source's High capacity) the splash tank holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter (`adapter.info` recorded, not SwiftShader). proof: `measure-steady-state-fps` skill lane against the example
+
+**Verification:** run the three proofs above; record particle count, p95 frame time and adapter
+beside the third box.
+
+#### Phase 2: bodies splash and float, and the native host runs the solver
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/fluid-particles.ts` (colliders, column heights, `heightAt`),
+`examples/prd476-fluid-particles/src/game.ts` (a sphere and two boxes),
+`packages/runtime-native/conformance/registry.json` (new case),
+`examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json` (new).
+**Implementation:** `setColliders` uploads sphere/box transforms from Rapier each fixed step and
+projects particles out of them; the column-height grid feeds `heightAt` through `GPUReadback`;
+`Buoyancy3D` consumes it unchanged. Register a conformance case next to `77-fluid-field` with
+`desktopGate: true`, using the example's `conformance.js`.
+
+- [ ] A 1,900 kg/m³ sphere dropped into the tank displaces the surface and comes to rest on the floor, while a 550 kg/m³ box ends floating with its centre within 0.2 m of `heightAt` after 5 s (GameState assertions). proof: the Phase 1 playtest command with the coupling scenario
+- [ ] The desktop host runs the solver and matches the web capture within the case tolerance. proof: `pnpm parity` (case `fluid-particles`) and the `fluid-particles-desktop` playtest
+
+**Verification:** both proofs; regenerate the census in the same commit as the registry change
+(`pnpm census`).
+
+#### Phase 3: agents find it, and the sandbox Fluid Lab runs all eight experiments at 60 fps
+
+**Status:** NOT STARTED
+**Files:** `packages/core/src/index.ts` (`@situation` tags for `FluidParticles3D`; spray/droplet
+situations on `GPUParticles3D`), `packages/create-threenative/templates/sailing/AGENTS.md` or the
+capability reference the scaffold reads (one line, inside the template caps),
+`../sandbox/fluid-lab/` (new game: scenes, `src/render/`, HUD, playtests, `FRICTION.md`).
+**Implementation:** `pnpm build` regenerates both capability manifests. Pack core and physics to
+a private staging directory with content-hashed tarball names (never `pnpm sandbox`, which wipes
+the shared `.packages`), scaffold `fluid-lab`, port the eight experiments onto
+`FluidParticles3D`, `Buoyancy3D`, `RippleField`/`WaveField` and `GPUParticles3D`, and the optics
+from `shaders.js` into `src/render/`. Commit and push the game to the sandbox remote as soon as it
+runs, per the sandbox `AGENTS.md`.
+
+- [ ] `engine_search_capabilities` returns `FluidParticles3D` first for "pour water into a tank and drop a ball in it" and `GPUParticles3D` for "splash spray droplets with lifetime and gravity"; template caps still pass. proof: `pnpm build && pnpm test`, then both searches
+- [ ] A playtest cycles all eight scenes through keys 1–8 and asserts each is non-blank, changes over time, and reports a non-zero particle or wave state. proof: `node packages/playtest/dist/runner/cli.js ../sandbox/fluid-lab/playtests/experiments.playtest.json --url --browser-recipe webgpu`
+- [ ] The sandbox Splash Tank at the High preset holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter — the case the source drops to 30 fps. proof: `measure-steady-state-fps` skill lane against the sandbox build + preview
+
+**Verification:** the three proofs; `score-build-experience` on the finished sandbox build goes
+in the PR body, not a box.
diff --git a/docs/PRDs/UI/PRD-native-overlay-utility-styling.md b/docs/PRDs/UI/PRD-native-overlay-utility-styling.md
new file mode 100644
index 0000000000..8dc475c7ea
--- /dev/null
+++ b/docs/PRDs/UI/PRD-native-overlay-utility-styling.md
@@ -0,0 +1,425 @@
+---
+prd_contract: v1
+---
+
+# PRD — Standard Tailwind and CSS, rendered as native UI
+
+**Status:** NOT STARTED — revised design; no implementation or runtime parity is claimed.
+**Date:** 2026-10-01 (America/Vancouver).
+**Audit baseline:** `develop` at `49ba2c49d6867ce075a9ebe42b994e3464ec0283`.
+**Delivery:** Replace the unimplemented proposal in draft PR #388; target `develop`.
+**Scope:** An opt-in, native CSS-capable HUD backend with a tested compatibility profile.
+**Product requirement:** Use the same Tailwind and CSS authoring interface, but output native UI.
+
+## Decision and correction
+
+**Agree with the product goal; revise the proposed implementation.** Run the real Tailwind build
+pipeline, retain ordinary CSS as the styling contract, and render a React element tree through a
+native CSS/layout/text/paint backend. Do not invent Tailwind-looking utilities, require conversion
+to `IOverlayStyle`, or replace CSS with a public ThreeNative style language.
+
+Native means an in-process, GPU-composited UI surface, without a WebView, Chromium/CEF, a browser
+renderer process, or an HTML screenshot service. It does not mean platform-default widgets:
+authored CSS still determines the appearance. A native stylesheet engine and an internal
+DOM-like element tree are allowed. They are necessary implementation machinery, not a browser
+JavaScript DOM exposed to game code.
+
+The previous requirements for no CSS at runtime, mandatory static style IDs, and a Tailwind-to-
+proprietary-style compiler are withdrawn. Parse CSS when loaded or changed; compute styles when
+state changes. Build-time optimization is welcome only when it preserves the cascade, variables,
+responsive rules, dynamic classes and interaction behavior. No parsing on every game frame.
+
+This PRD delivers the **Core HUD profile** below, not all CSS or arbitrary web applications.
+Additional profiles require their own PRDs and native proof; implementing this profile must not be
+marketed as complete Tailwind/browser compatibility. There are no speculative coverage percentages.
+
+## Existing architecture and fit
+
+These sources were inspected at the audit baseline; observations describe current code, not this
+proposal's future API.
+
+| Source | Observed behavior | Design consequence |
+| --- | --- | --- |
+| [Workspace catalog](../../../pnpm-workspace.yaml) | Tailwind and its Vite plugin are both pinned to `4.3.3`. | Use these actual packages and lockfile versions, not a hand-maintained utility table. |
+| [React entry](../../../packages/core/src/react.ts) | Optional React subpath; `View`/`Text` accept native `style` objects. | Keep existing callers working; standard JSX needs a separate host adapter. |
+| [React host](../../../packages/core/src/react-host.ts) | Custom reconciler, CanvasLayer quads and bitmap glyphs; other element types fail. | Reuse lifecycle patterns, not the limited drawing model as a CSS target. |
+| [Layout](../../../packages/core/src/react-layout.ts) | Fixed/shrink-wrapped boxes, simple row/column placement, no CSS parser; mobile WASM is explicitly refused. | A utility parser cannot supply Flexbox, CSS paint or font semantics. Native dependencies must not require mobile WASM. |
+| [UI contract](../../../packages/ui/AGENTS.md) | Shared UI reads published game state and emits intents; the game owns gameplay. | Keep the state/intent boundary and portable-entry isolation. |
+| [Charter](../../architecture/CHARTER.md) | Web-standard UI defaults to a platform WebView; native quads are a limited opt-in; public IRs and a second scene renderer are excluded. | Add a bounded UI-only architectural exception explicitly; do not claim this is already permitted/shipped. |
+
+The framework owns the portable mechanism; the game owns every appearance choice. Three.js keeps
+ownership of the 3D scene, camera and game rendering. Neither React nor the CSS backend takes over
+the scene graph or game loop.
+
+### Required architectural amendment
+
+The implementation must amend the Charter and affected native-overlay documentation before the
+new backend is presented as supported. The proposed exception is: an optional, third-party native
+HTML/CSS UI engine may maintain a UI-only element tree and paint an offscreen HUD surface; it may
+not render the 3D game, expose a game/scene IR, replace Three.js, or impose a framework-owned look.
+This document proposes that exception; it does not silently amend the binding Charter.
+
+Keep existing `ui.renderer: "web"` and `"native"` semantics unchanged. Add a proposed
+`ui.renderer: "native-css"` opt-in rather than silently changing the existing bitmap-native path.
+No default switch, completed-PRD rewrite, or mandatory dependency cost for other applications.
+
+## User-facing contract
+
+The example below is a **required future acceptance fixture**, not a currently available API.
+The component and stylesheet are identical in browser and native-CSS builds; only the renderer
+selection/bootstrap differs. Do not make users replace `div` with `View` or rewrite styles per target.
+
+```tsx
+import "./hud.css";
+
+export function Inventory({ onClose }: { onClose: () => void }) {
+ return (
+
+
Inventory
+
12 items
+
+
Medkit
+
Battery
+
+
+
+ );
+}
+```
+
+```css
+@import "tailwindcss";
+
+@font-face {
+ font-family: "HudSans";
+ src: url("./fonts/hud-sans.ttf") format("truetype");
+ font-weight: 100 900;
+}
+
+@theme {
+ --font-sans: "HudSans", sans-serif;
+ --color-brand: #2563eb;
+}
+
+.inventory {
+ font-family: var(--font-sans);
+ --inventory-border: var(--color-zinc-700);
+ border-color: var(--inventory-border);
+}
+
+.inventory > h2 {
+ letter-spacing: -0.02em;
+}
+```
+
+The fixture must supply a licensed variable TTF supporting the declared weight range; the example
+path is an asset requirement, not a claim that this font exists in the repository.
+
+Required interfaces are ordinary JSX intrinsics, `className`, `id`, `data-*`, appropriate `aria-*`
+attributes, CSS imports and the React CSS-style object convention, including unitless properties
+and custom properties. `style={{ width: "50%" }}` must retain percentage semantics. The native
+host implements the supported React property/event contract; it does not load `react-dom` against
+the game's compatibility `document` stub.
+
+Custom CSS must work without Tailwind. Tailwind's `@theme`, `@utility`, `@apply`, `@source` and
+`@custom-variant` remain upstream build-time features; ordinary CSS is their output. Supported
+plugins are those whose emitted CSS stays within the declared native profile. Do not promise
+arbitrary plugin or DOM-dependent component-library compatibility.
+
+## Selected approach and alternatives
+
+| Approach | Assessment |
+| --- | --- |
+| Existing WebView path | Preserves the broad web contract, but does not meet the requested no-WebView output. Retain it for existing users. |
+| Utility parser plus the current quad renderer | Cannot preserve CSS layout, cascade, typography or interaction. Rejected for this goal. |
+| Taffy plus a new hand-built style/paint system | Layout is only one subsystem; leaves ThreeNative owning most of a CSS engine. Not the default plan. |
+| Reuse a modular native HTML/CSS engine behind the existing UI boundary | Recommended: retain real CSS and concentrate new work on React, native-host integration and conformance. |
+
+**First candidate: Blitz's modular core, not a switch from React to Dioxus.** Its upstream design
+combines Stylo for CSS, Taffy for box layout and Parley for text, with a separate renderer. Its
+README still describes a beta, missing features and no ready-made JavaScript bindings. The HTML
+preview wrapper is not an interactive React backend. Its texture example is evidence of an
+integration direction, not proof of compatibility with ThreeNative's GPU device or input system.
+See the [upstream architecture and status](https://github.com/DioxusLabs/blitz).
+
+[Taffy](https://github.com/DioxusLabs/taffy) is a useful layout dependency, not a replacement for
+CSS resolution, text, paint and events. A maintained native engine is preferred over assembling
+those subsystems from scratch. Backend selection is provisional until the first runnable slice
+passes below; a failing candidate does not authorize silently shrinking the product requirement.
+
+## Technical design
+
+```mermaid
+flowchart TD
+ A["Shared React JSX and CSS sources"] --> B["Existing Vite build and real Tailwind"]
+ B --> C["Ordinary CSS, fonts, images and source maps"]
+ A --> D["Native React host: UI element mutations"]
+ C --> E["Native CSS engine: cascade and computed styles"]
+ D --> E
+ F["Viewport, theme, focus, pointer and scroll state"] --> E
+ E --> G["CSS layout and text shaping"]
+ G --> H["Native UI paint into composited GPU surface"]
+ H --> I["Existing game presentation: Three.js remains scene renderer"]
+ B --> J["Browser React DOM reference using identical CSS"]
+```
+
+### Build and stylesheet ownership
+
+Use the workspace Tailwind/Vite pipeline and preserve the resulting CSS, source order and assets.
+Do not call private Tailwind candidate APIs as the public integration, hardcode spacing/palettes,
+or transpile utility strings into proprietary style objects. CSS Modules may use the existing
+bundler transformation; their final class names still enter the same stylesheet engine.
+
+Bundle fonts/images offline through existing asset plumbing. CSS imports in a native build become
+registered stylesheet assets, not `document.head` calls. Development stylesheet updates replace a
+sheet atomically, invalidate affected styles, and preserve component state. Production loads the
+same styling artifact without invoking the Tailwind compiler. Pin the Tailwind version and engine
+revision used by each conformance run.
+
+Follow upstream [class detection](https://tailwindcss.com/docs/detecting-classes-in-source-files):
+conditional complete strings work; arbitrary `bg-${color}-500` interpolation does not gain new
+runtime CSS generation. Users register extra candidates through ordinary Tailwind sources.
+A class with no matching rule is legal CSS and must not automatically throw: it may be a custom
+selector hook. Report missing generated candidates diagnostically without inventing utilities.
+
+### Native host and composition
+
+Keep a UI-only retained element tree with tag, attributes, ordered children, text, interaction
+state and inline declarations. Batch React mutations at commit boundaries. Preserve keyed-node
+identity, selector-relevant ancestry and sibling order. Runtime style resolution remains necessary;
+a single static style ID cannot replace context-dependent computed styles.
+
+Use a coarse, versioned native binding with bounded message buffers and explicit ownership for
+create/update/remove, stylesheet loading, input, resize and disposal. This is an internal ABI, not
+a new public scene/style format. Callbacks return to JavaScript after native work, avoiding
+re-entrant mutation. Rust, where selected, compiles to a native library behind the C++ host; no
+mobile WASM, additional game-language requirement, or Dioxus authoring requirement.
+
+The first slice must demonstrate the actual UI texture/surface path with the host's active GPU
+backend. A Rust `wgpu` texture and a Dawn texture are not interchangeable handles. Validate device
+ownership, native-handle interoperability, synchronization, formats, premultiplied alpha, color
+space, resize and device-loss teardown. An explicit GPU-side copy may be acceptable if measured;
+a per-frame GPU readback and CPU re-upload is not the production solution. Do not force an
+unrelated 3D-renderer migration to make the UI backend fit.
+
+Retain the state/intent contract: UI presents game-owned state; user actions become intents.
+Theme changes, scrolling, transitions and hover must update without rerendering the game scene or
+reconciling every React component each frame. Reuse current publication semantics; do not impose
+a new fixed 10 Hz bridge on the strength of older Charter examples.
+
+### CSS semantics are the contract
+
+Preserve cascade layers, selector specificity, inheritance, source order, inline declarations and
+`!important`, including reversed layer precedence for important declarations. Class-string order
+is not a generic last-token-wins rule. Normal inline styles do not beat important stylesheet rules.
+Shorthands/longhands must resolve like CSS, not object-spread order.
+
+Include the real Preflight output and an appropriate supported-element default style sheet.
+Reset/default display, box sizing and text metrics are observable behavior. Do not disable
+Preflight or swap fonts to make a visual test pass. See [Preflight](https://tailwindcss.com/docs/preflight)
+and [theme variables](https://tailwindcss.com/docs/theme).
+
+Resolve `var()` with inheritance, fallbacks and invalid/cyclic-value behavior; support the
+registered custom-property behavior required by emitted Tailwind `@property` rules. Retain
+`calc()`, `min()`, `max()` and `clamp()` when values depend on layout or runtime variables. Modern
+color functions and alpha modifiers must retain authored semantics; theme values are not all hex.
+Tailwind's [compatibility documentation](https://tailwindcss.com/docs/compatibility) makes these
+CSS dependencies part of the integration, not optional decoration.
+
+Measure layout in CSS pixels, not framebuffer pixels or Three.js world units. Map DPR once at
+rasterization/input conversion. `rem`, `em`, percentages and viewport units retain their respective
+reference contexts. Responsive media-query units follow CSS rules, not a hardcoded spacing scale.
+Viewport resize, root/element font changes and inherited custom-property changes invalidate the
+right descendants. Test non-default root font sizes and DPR 1/2/3.
+
+## Core HUD compatibility profile
+
+All rows marked **Core** are requirements, not statements of existing backend support. Each must
+have browser-reference fixtures. The implementation publishes the exact tested property/value/
+selector matrix, including restrictions, instead of claiming entire utility families blindly.
+
+| Area | Core requirement | Separate future profile |
+| --- | --- | --- |
+| Elements | `div`, `section`, `aside`, headings, `p`, `span`, `button`, `img`, ordinary text and fragments; inherited text styles and mixed inline runs. | Forms, editable inputs/IME, native select/dialog behavior, links/navigation, general HTML. |
+| Selectors/cascade | Type, class, ID, attributes, descendant/child/sibling combinators; `:is`, `:where`, `:not`, first/last/nth-child; layers, variables, inline styles and importance. | `:has`, complex generated content and unsupported selector constructs. |
+| Box layout | Block/inline flow, box sizing, per-edge margins/padding/borders, auto/min/max/intrinsic sizing, percentages, aspect ratio; static/relative/absolute/fixed positioning. | Floats, multicolumn, tables, sticky positioning and alternate writing modes. |
+| Flexbox | Row/column, wrapping, gaps, basis/grow/shrink, order, auto margins and cross/main alignment, with intrinsic-size interactions. | Any value/edge case not yet demonstrated must remain visibly unsupported, not approximated. |
+| Grid | Explicit/repeated tracks, `fr`, `minmax`, basic auto-placement, spans and row/column gaps. | Subgrid, masonry and dense-placement claims beyond tested cases. |
+| Paint | Solid colors including OKLCH/alpha, per-edge borders, per-corner radii, outlines/rings, layered shadows, linear gradients, raster images/object-fit, clipping and stacking contexts. | Filters, backdrop blur, masks, blend modes, arbitrary SVG and 3D CSS transforms. |
+| Typography | Bundled fonts and weights, shaping, fallback, baseline, line height, wrapping, whitespace, letter spacing, alignment and ellipsis. | Rich text editing, browser text selection and complete script/font feature coverage. |
+| State/environment | Hover/active/focus/focus-visible/disabled, data/ARIA selectors, group/peer variants through normal selectors, viewport breakpoints, dark mode and reduced motion. | Container queries and additional device/environment features. |
+| Motion | CSS transitions for colors, opacity and 2D transforms; real timing/delay/interruption, origin and hit-test transforms. | General keyframe animations and layout-property animation. |
+| Interaction | Button activation, tab navigation, visible focus, pointer/touch/wheel handling, nested scrolling, clipping-aware hit tests and pointer-events. | Drag/drop, portals, browser event/DOM API completeness and web-component libraries. |
+
+Group opacity composites the subtree; it is not copied independently to every child's paint.
+Rounded clipping applies to descendants and hit testing. Hover observes input capabilities rather
+than simulating sticky mouse hover on touch-only devices. Disabled buttons cannot emit activation;
+keyboard focus must not also trigger game controls. Preserve existing intentional HUD click-through
+and interactive-region conventions.
+
+Supported interactive nodes carry accessible names, roles, disabled state and focus through the
+backend's accessibility interface. Platform accessibility support is reported per tested target;
+an untested bridge is not an accessibility pass. Absence of a platform bridge blocks production
+accessibility claims, not documentation work. No claim of a general accessible forms toolkit.
+
+The Core profile is deliberately narrower than the complete product ambition. Advanced effects,
+forms/IME, extensive SVG, additional platforms and DOM-dependent UI libraries get separate PRDs,
+not hidden subtasks here. Grid, typography, custom CSS and interaction are not optional shortcuts
+for completing this Core profile.
+
+### Unsupported features and failures
+
+Emit a source-mapped compatibility report for properties, values, at-rules, selectors and element
+APIs outside the profile. CSS parsing must retain normal recovery and legal unknown-class behavior.
+Do not reinterpret syntactically invalid CSS as a new native language.
+
+Production native-CSS builds fail for unsupported active author rules unless the rule is behind an
+explicit, correctly evaluated `@supports` branch with an authored supported fallback. Compute
+feature queries from real capability, not parser recognition alone. Treat inactive vendor-specific
+Preflight rules and unsupported element defaults through a documented baseline audit; blindly
+rejecting every rule in upstream Preflight would make a normal Tailwind import impossible.
+
+Runtime-only failures name the source/element/property through the existing visible error path.
+Development HMR may retain the last good stylesheet while visibly reporting failure. Never
+silently drop paint, silently substitute a font, or automatically attach a WebView. Failed bundles
+must not be reported as native-compatible just because their JavaScript compiled.
+
+## Browser-reference verification
+
+The oracle is **the same JSX and emitted CSS rendered by a pinned browser**, not the old
+inline-native overlay. Capture the exact component source, CSS hash, font/image hashes, viewport,
+DPR, platform, graphics backend and tool versions. Wait for fonts/assets and freeze time/randomness.
+The browser reference may use browser rendering; native acceptance may not.
+
+Use the existing playtest and native-conformance infrastructure. Planned fixtures should cover:
+
+| Fixture family | Required observations |
+| --- | --- |
+| Tailwind plus custom CSS | Example above, custom theme, `@apply`/custom utility, stylesheet import/HMR and a plain-CSS-only arm. |
+| Cascade and selectors | Reverse class-string order; layers and important overrides; inherited/cyclic variables, structural/group/peer/data rules. |
+| Layout | Flex shrinking/wrapping and long text; grid tracks; relative/absolute/fixed boxes; root-font, viewport and DPR changes. |
+| Paint and fonts | Rounded clipping, shadows/rings, gradients, subtree opacity, image fitting; weights, multiline text, Portuguese and mixed-direction sample text. |
+| Input and motion | Mouse and touch states, focus traversal, disabled activation, nested scrolling, transformed hit tests, interrupted transitions, reduced motion and no game-input leakage. |
+| Lifecycle and rejection | Class/style removal, theme updates, keyed reordering, mount/dispose, asset reload, unsupported CSS and device-loss recovery. |
+
+Proposed correctness thresholds: every measured box edge and text baseline within **1 CSS pixel**
+of the reference; identical line breaks, visible strings, state outcomes and focus order. Compare
+the full UI crop at a fixed time, targeting **SSIM >= 0.99**. Any text-rasterization tolerance must
+be fixed in advance and accompanied by text metrics; do not mask missing glyphs, wrong wrapping,
+missing shadows or interactive regions. A high image score cannot override semantic failures.
+These are acceptance targets, not measured results; disclose any necessary tolerance amendment.
+
+Run a native desktop lane and Android-emulator lane separately. Require nonblank actual captures,
+correct interaction output, and instrumentation showing the selected native-CSS backend with no
+WebView creation/load. Test stubs and offscreen HTML screenshots are not native proof. Report the
+specific desktop OS; one desktop run proves nothing about other desktop platforms or iOS.
+
+### Performance and resource boundaries
+
+Benchmark a deterministic 1,000-node HUD, 120 warm-up frames followed by 1,000 measured frames:
+unchanged, 10% changed at 10 Hz, and an active transition/scroll case. Record UI CPU work, GPU time,
+upload/copy cost, total game-frame p50/p95/p99, input-to-present latency, heap/RSS and GPU resources.
+Compare enabled native-CSS to UI-off and the existing web backend on the same hardware. Measure
+startup and binary/package size separately. An emulator is functional evidence, not phone-speed proof.
+
+An unchanged UI performs no repeated stylesheet parse, full-tree style/layout pass, text shaping
+or React reconciliation. Cache reuse is observable; dirty updates invalidate only affected work
+where semantics permit. Rendering/composition may still occur each game frame. Bound caches and
+release per-root resources on unmount; repeated mount/dispose must not grow retained resources.
+
+Opted-out applications initialize no new engine and import no new UI dependencies. Packaging must
+avoid mandatory native-CSS binaries for builds that do not select the feature. No claim that this
+backend is faster, smaller or lower-power than WebView is made in advance. Freeze any numerical
+release budget against named reference hardware before declaring performance readiness; report
+actual data rather than selecting a flattering FPS threshold afterward.
+
+## Integration boundaries
+
+Use `packages/ui` for shared authoring/state/intent plumbing, `packages/runtime-native` for the
+optional native binding/surface, and `packages/playtest` for proofs. Discover actual build-loader
+entry points through the capability manifest before writing source. Package placement is subject
+to the existing dependency-isolation rule; this proposal does not invent a shipped
+`@threenative/tailwind` package. The existing Tailwind dependency already owns that interface.
+
+Do not expand `IOverlayStyle` into a second CSS dialect or redirect existing `View`/`Text` callers.
+Keep the game portable entry free from `react-dom`, CSS-injection DOM calls and new browser shims.
+The native UI host and browser React DOM host consume the same authored component tree through
+separate bootstrap adapters. Cross-target state-store identity and subscription behavior must be
+preserved. Public authoring documentation changes only when the corresponding API ships.
+
+Backend admission must demonstrate the unchanged acceptance component, native surface integration,
+a native Android build, input dispatch and usable license/dependency boundaries before extending
+the profile. Inspect upstream and transitive licenses, including font redistribution; do not
+assert that the top-level renderer license covers every dependency. Failure triggers a documented
+backend decision or upstream fix, not an unrequested return to the utility-only proposal.
+
+## Execution phases
+
+All test paths and fixture names below are **planned**; none are claimed to exist or pass today.
+These three phases implement only the Core HUD profile. Larger follow-on profiles require separate
+PRDs/PRs rather than extending this checklist indefinitely.
+
+### Phase 1 — Standard CSS pipeline and native admission slice
+
+- [ ] Standard UI sources produce the same Tailwind/CSS styling artifact for both targets. proof: planned `packages/ui/__tests__/native-css-build.spec.ts` covering the pinned upstream compiler, plain CSS, theme/custom utilities, assets, source maps and dependency isolation.
+- [ ] The unchanged acceptance component mounts on an actual native CSS backend. proof: planned `native-css-admission.playtest.json` through the existing desktop runner, including native surface ownership, button dispatch, no WebView and required architectural/documentation updates.
+
+### Phase 2 — Core HUD semantics
+
+- [ ] Core cascade and layout match the browser oracle. proof: planned `native-css-layout.playtest.json` plus focused selector/cascade unit cases covering the Core matrix, geometry, text wrapping and viewport/font changes.
+- [ ] Core paint and typography match the browser oracle. proof: planned `native-css-paint.playtest.json` with the documented image/metric thresholds, real bundled fonts, alpha/clip/shadow/gradient cases and asset hashes.
+- [ ] Core interaction behavior matches the browser oracle. proof: planned `native-css-input.playtest.json` covering focus, disabled controls, mouse/touch/wheel, scroll, transition interruption and game-input isolation.
+
+### Phase 3 — Cross-target proof
+
+- [ ] The complete Core HUD corpus passes on a named native desktop platform. proof: the planned Core scenarios through the existing playtest runner with `--target desktop`, actual captures and backend identity; record OS and GPU backend.
+- [ ] The complete Core HUD corpus passes on an Android emulator without a WebView. proof: the same planned scenarios through the existing runner with `--target android`; record emulator/API/graphics configuration and actual interactions.
+
+## Acceptance criteria
+
+- [ ] Native-CSS lifecycle and invalidation meet the resource contract. proof: planned `native-css-lifecycle.playtest.json`, instrumented unchanged/dirty/mount-dispose tests and the matched resource/performance run described above; document measured costs without claiming unrun platform speedups.
+
+Core delivery additionally requires the documented strict compatibility diagnostics and public
+support matrix: each feature claim points to a passing fixture. Checklist completion cannot waive
+the normative source-parity, no-WebView, dependency-isolation or supported-profile requirements.
+
+## Blocked on
+
+No external dependency blocks reviewing or editing this PRD. No implementation has been attempted.
+Backend selection/interop, the bounded Charter amendment and the Core engine gaps are work inside
+this proposal, not facts already established by a dependency README.
+
+Physical-device performance and platform accessibility readiness require the corresponding
+hardware/OS test lane; the implementing agent must try the available lane and name the actual
+missing prerequisite before reporting it blocked. iOS and other untested desktop platforms have
+no readiness claim from the desktop/Android-emulator checks in this PRD.
+
+## Decisions and rollout
+
+On 2026-10-01 the owner clarified: the same Tailwind and CSS interface must output native UI.
+That requirement supersedes the previous bounded utility-parser design. Its eight unchecked
+items are replaced by this Core HUD plan because they proved the wrong contract; no completed
+work or evidence is removed. The existing filename is retained to preserve PR #388 links.
+
+This proposal recommends a reused standards-oriented native UI engine, with CSS allowed at
+runtime and no public proprietary style IR. The candidate library and UI-only Charter exception
+remain design proposals pending their stated implementation evidence; the owner did not select
+a specific Rust library or authorize a silent default-renderer change.
+
+Ship as experimental opt-in after the native proofs. Preserve the web and lightweight-native
+backends and their existing tests. Rollback changes renderer selection/bootstrap, not authored
+JSX/CSS. Rollback must be explicit: no hidden WebView fallback. Default promotion, advanced CSS
+profiles, forms/IME and additional platform certification are separate decisions and PRDs.
+
+## Verification of this documentation change
+
+Only this Markdown PRD is changed. Repository inspection is pinned to the audit commit, with
+external references checked on the document date. Structural/progress-check results belong in the
+PR body. Keep all implementation boxes open and the draft at `prd:0%` until real evidence exists.
+No full repo build, native execution, visual parity, performance improvement or CI-green claim is
+implied by creating this document.
diff --git a/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md b/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md
index 34f5237cc9..f4324fc13d 100644
--- a/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md
+++ b/docs/PRDs/architecture/PRD-309-android-conformance-runs-on-every-commit.md
@@ -14,7 +14,7 @@ last execution covered **0 rows**.
**Depends on:** nothing to start. Overlaps
[PRD-295](../done/PRD-295-the-native-platform-lane-has-never-been-green.md) (which made the lane
advisory and is fixing its desktop legs) and
-[PRD-303](../CI/PRD-303.md) (which pays for added CI coverage from measured minutes). **This PRD
+[PRD-303](../done/PRD-303.md) (which pays for added CI coverage from measured minutes). **This PRD
owns the Android leg only**; it does not promote the lane to required — that is PRD-295's call —
and it must not duplicate PRD-303's nightly-schedule work.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md b/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md
new file mode 100644
index 0000000000..17a273b524
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md
@@ -0,0 +1,53 @@
+# Execute this batch
+
+## Agent handoff
+
+```text
+Read AGENTS.md, docs/PRDs/AGENTS.md and this folder's README.md.
+Work from current origin/develop in an isolated branch/worktree.
+Choose the highest-value unblocked item from the execution queue.
+For an existing owner, read and update its canonical PRD rather than writing a replacement.
+Reconcile the current code, tests and merged/open PRs before treating any older claim as missing.
+Search capabilities and inspect the installed Three pin and its patches before adding a system.
+Keep appearance in generated game source. Reuse the renderer, mixer, loader and frame lifecycle.
+Open one implementation PR for that PRD, not one PR for this entire implementation batch.
+Implement the smallest bounded phase, run its named tests and playtest, and update actual results inline.
+Do not tick another platform's box from browser proof or a green metadata/build check.
+Rebase/reconcile shared files between dependent tasks; do not merge parallel changes blindly.
+Stop at a failed admission gate with the measured reason, or complete/archive the PRD honestly.
+```
+
+## Proof setup
+
+Each new PRD names future test/scenario paths. Those paths are not claimed to exist today. Add the smallest opt-in fixture to the existing `examples/abyss-framework` example, or reuse a better existing fixture and change the proof path in the canonical PRD. Its normal scene must remain unchanged. Do not create a new engine package merely to hold an example.
+
+`VQ_URL` must point to the built/served fixture. `VQ_NATIVE_EXECUTABLE` must name that same fixture's packaged Linux executable. `VQ_ANDROID_DEVICE` is the chosen online emulator/device serial for VQ-01. Build/serve/package using the repository's existing commands; variables with `:?` deliberately refuse an unset value. Capture the fixture route, exact cohort/commit and adapter with the result. Do not run a scenario against an unrelated default scene and report success.
+
+For new public mechanisms, use the existing capability manifest/MCP and update generated guidance. Inspect the installed pin (`three@0.185.1` at the audit baseline) before using an addon seen in online documentation. An incompatible addon can be declined, scoped down, or introduced through an explicitly qualified upgrade; none licenses an automatic fork of the renderer.
+
+## Checks and progress
+
+Run `pnpm prd:progress ` before work and after each phase. New specs have three phases, six phase boxes and two acceptance boxes, with `proof:` on each. All start at zero; a written plan is not implementation progress. Existing PRDs may need their old checklist shape repaired **without deleting completed work**.
+
+For implementation, use the root AGENTS.md runtime gates and the exact feature playtests. For this documentation-only collection, use the repository's prose lane:
+
+```sh
+pnpm check:docs
+pnpm exec vitest run scripts/__tests__/check-doc-links.spec.ts scripts/__tests__/evidence-budget.spec.ts scripts/__tests__/evidence-citations.spec.ts scripts/__tests__/sync-agent-docs.spec.ts scripts/__tests__/ci-structure.spec.ts scripts/__tests__/ci-needs.spec.ts
+```
+
+Those commands are required execution guidance, not a claim they were run while filing this collection. No runtime test, new benchmark result or human visual approval is supplied by this planning change.
+
+## Concurrency and shared ownership
+
+| Workstream | Can proceed independently | Serialize or reconcile |
+|---|---|---|
+| Build/runtime correctness | VQ-01 | Runtime artifact capability contract and loader/cook changes |
+| Native output correctness | VQ-02 | `renderer.ts`, output attachments and native replay |
+| Temporal/exposure | PRD-269, then PRD-455; PRD-339 after output is reliable | `chain.ts`, `velocity.ts`, `worldEnvironment.ts`, quality source |
+| Animation | VQ-04 followed by VQ-05 | One mixer/action owner; do not let both reset action weights |
+| World transitions | PRD-460, then proxy/impostor follow-ups | CPU and GPU WorldCells selection, per-instance identity, shadow alpha |
+| Lighting | VQ-03 and upstream admission for VQ-06 | Material lighting contribution, probe capture budgets and PRD-457 |
+| Optional look work | Decals and material fixture authoring | Shared generated render plumbing and temporal/alpha semantics |
+
+Hardware access, rights to assets and human aesthetic approval are named blockers, not progress boxes. Browser and Linux-native qualification is not a promise of phone frame rate. Release scope is unchanged; this folder does not declare all visual experiments release-critical.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md b/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md
new file mode 100644
index 0000000000..48a86d9185
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/EXISTING-PRDS.md
@@ -0,0 +1,43 @@
+# Existing canonical PRDs in this batch
+
+These are **references to existing work, not duplicate specifications**. Their files, evidence, completed boxes and associated PRs stay at their canonical paths. This preserves history and in-flight branches while providing one execution entry point.
+
+Baseline inspected: `d72778382b134ef8763f58825cb4d4fd8cc0f6e3` on `develop`. Old status prose is not authoritative over newer code. Before execution, compare the relevant implementation and tests, preserve all proved work, add only the unresolved scope and normalize missing per-phase/proof markers in the canonical document. Never turn a stale `PROPOSED` heading into a rewrite order.
+
+| Queue | Owner | Outcome | Execution note | Dependency / coordination |
+|---|---|---|---|---|
+| Wave 0 | [PRD-269](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md) | Velocity/history correctness | Source exists; reconcile old PROPOSED text with current velocity code. Qualify gaps; do not rebuild it. | Before 455, VQ-13 and temporal hair/fog consumers. |
+| Wave 1 | [PRD-455](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md) | Temporal AA and dynamic-resolution reconstruction | Reuse existing owner. Same-resolution temporal qualification is its first bounded milestone; an upstream TRAA node is not by itself an upscaler. | 269; coordinate MSAA, jitter, camera cuts and instance identity. |
+| Wave 1 | [PRD-339](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md) | Auto exposure / eye adaptation | Reuse; adapt the current generated-source exposure seam, not a second tone mapper. | VQ-02 output correctness; include dark/bright transition traces. |
+| Wave 1 | [PRD-460](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-460-invisible-streaming-transitions.md) | Prop arrival and LOD crossfades | Reuse; terrain morphing already exists. Apply the remaining work to CPU and GPU-selected scatter routes. | Existing WorldCells and PR #375; coordinate temporal reactive/disocclusion behavior. |
+| Wave 2 | [PRD-456](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-456-distant-world-cell-proxies.md) | Distant cell HLOD / silhouettes | Reuse; do not confuse per-asset impostors or shadow proxies with cell HLOD. | Existing world-resource accounting; 460 handoffs. |
+| Wave 2 | [PRD-457](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-457-virtual-shadows-scale-by-measurement.md) | Shadow scheduling, sparse-page admission, local-light boundary | Reuse. Directional virtual shadows already exist; phase 3 also owns local-light work. | Coordinate VQ-06; preserve a correct cached-level fallback. |
+| Wave 2 | [PRD-344](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md) | Cheap baked contact occlusion | Reuse; distinguish this outcome from existing lightmaps and imported AO textures. | Use current cook/mesh data; retain mobile low-tier benefit. |
+| Wave 2 | [PRD-268](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md) | Static diffuse probes / off-screen lighting | Source exists; reconcile implementation and remaining proof before any extension. | Precondition for VQ-09; not a replacement for specular VQ-03. |
+| Cross-cutting | [PRD-341](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md) | Tone and luminance assertions | Implemented and archived by [PR #392](https://github.com/ThreeNativeHQ/threenative/pull/392), merged 2026-10-02. Reuse its captured-tone observations, assertions and CLI; a luminance score is not an aesthetic verdict. | Useful for 339, VQ-02 and matched material tests. |
+| Cross-cutting | [PRD-342](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-342-where-the-frame-goes-pass-cost-ablation.md) | Measured post-stage ablation | Reuse rather than build another profiler. Inspect current frame/pass instrumentation first. | Useful for 455 and every optional expensive effect. |
+
+## Canonical checkout paths
+
+```text
+docs/PRDs/lighting/PRD-269-motion-vectors-or-the-temporal-filters-lie.md
+docs/PRDs/unreal-like-features/PRD-455-temporal-reconstruction-from-dynamic-resolution.md
+docs/PRDs/AAA-visuals/PRD-339-the-frame-sets-its-own-exposure.md
+docs/PRDs/unreal-like-features/PRD-460-invisible-streaming-transitions.md
+docs/PRDs/unreal-like-features/PRD-456-distant-world-cell-proxies.md
+docs/PRDs/unreal-like-features/PRD-457-virtual-shadows-scale-by-measurement.md
+docs/PRDs/AAA-visuals/PRD-344-contact-occlusion-baked-from-the-geometry-it-ships-with.md
+docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md
+docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md
+docs/PRDs/AAA-visuals/PRD-342-where-the-frame-goes-pass-cost-ablation.md
+```
+
+## Companion work: resume, do not re-file
+
+- [PRD-454](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-454-worldcells-budget-real-resources.md): actual resource budgets; precondition to texture-residency extensions.
+- [PRD-458](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-458-open-worlds-hold-60-fps-by-default.md) and [open-world PRD-473](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md): preserve merged GPU/LOD/shadow work and finish remaining visible/performance qualification, including impostor coverage/shadows. Their merged [PR #375](https://github.com/ThreeNativeHQ/threenative/pull/375) still describes open acceptance work. A merge does not establish 60/120 fps.
+- [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md): VQ-03 is the bounded local-reflection child for row 5; VQ-08 covers the cloud/overhead-field outcome related to row 4. Record these child links when executing that parent's own admission work; filing a child does not mean its gate passed. Local-shadow row 8 remains coordinated through PRD-457.
+- [PRD-343](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-343-a-light-smaller-than-a-pixel-is-still-a-light.md) and [PRD-345](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/AAA-visuals/PRD-345-a-backlit-subject-is-not-a-hole-in-the-sky.md): optional existing work for subpixel emitters and authored backlit readability. Do not implement a compulsory rim light in core.
+- [Merged rain/snow PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382): reuse the kits and preserved completed PRDs. Only the reported native bloom regression is added here as VQ-02. Snow rolling resistance is nonvisual physics follow-up and is not silently included in this visual batch.
+
+The repository already has two different PRDs numbered 473. Use **full paths and titles**, not the number alone. New `VQ-01` through `VQ-15` identifiers intentionally avoid allocating more conflicting global integers.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md
new file mode 100644
index 0000000000..bd944151f2
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-01 — Native asset compatibility follows the selected runtime and actual decoders
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 0 / correctness.
+**Dependencies:** None. This is a build/runtime contract repair, not permission to remove compatibility guards.
+
+## Grounding and intended outcome
+
+[packages/create-threenative/src/build.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/create-threenative/src/build.ts) still rejects compressed assets by mobile target in `assertNativeAssetsCompatible`, while the runtime documentation identifies Android V8 as the default. The source establishes inconsistent capability selection; it does not prove that shipping V8 alone makes the Basis, Meshopt or Draco loading paths work. Workers, bundling, file access, decoder payloads and GPU formats are separate requirements.
+
+**Outcome:** A packaged game either decodes its declared assets on its selected runtime or is refused before publication with an accurate codec-specific reason. Android V8 and QuickJS must not be conflated; desktop QuickJS must not bypass checks simply because it is desktop.
+
+## Design and ownership
+
+Resolve one runtime capability record from the exact selected artifact/cohort before cooking. Thread it through asset cooking, the compatibility guard, native bundling and the runtime loader. Record engine, WASM support, packaged decoder versions and supported texture formats independently. An unknown capability takes an explicit decoder-free path or fails; it is never inferred from the build machine or from a desktop executable used to package Android. Keep QuickJS rollback supported. Do not silently change an authored asset or trust a manifest that disagrees with the packaged bytes.
+
+Reuse `runtimeHasWebAssembly`, `assertNativeAssetsCompatible`, asset compiler decoder options and `packages/runtime-native/scripts/bundle.mjs`. Extend the artifact capability contract only where necessary. No new renderer, codec implementation, or blanket Android whitelist. Preserve current WebGL and decoder-free fallbacks.
+
+## Required behavior
+
+- A table-driven suite covers web, desktop V8, desktop QuickJS, Android V8, Android QuickJS and unknown artifacts, with each codec independently allowed or refused.
+- The loader validates actual KTX2 block formats and fallback transcodes. Mesh compression is separately tested; smaller download bytes are not reported as smaller GPU vertex buffers.
+- Cook-cache keys include decoder capabilities and cohort identity. Switching engine or codec cannot reuse incompatible output.
+- Diagnostics identify the asset, codec, selected artifact and missing capability. Build failure must leave the previous packaged artifact intact.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — One capability decision
+
+- [ ] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+- [ ] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+
+### Phase 2 — The packaged bytes are authoritative
+
+- [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`.
+
+## Acceptance criteria
+
+- [ ] All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Record encoded bytes, decoded texture/geometry bytes and peak load memory per codec. Enabling a path requires correct pixels and bounded lifetime, not a promised compression ratio. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Physical Android performance and thermals require a named phone and are separate from emulator correctness. Missing codec qualifications must remain individual exclusions.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md
new file mode 100644
index 0000000000..4e8b3a9cad
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-02-native-postprocessing-parity.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-02 — A requested post-processing stage never turns the native world into a blank frame
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 0 / correctness.
+**Dependencies:** None. Can start alongside VQ-01.
+
+## Grounding and intended outcome
+
+The merged [PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) reports that bloom at threshold 0.92 blanked the native snow world while web worked; the kit shipped bloom disabled. This is a reported regression, not a reproduced root cause. [packages/core/src/render/chain.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/chain.ts) reports graph installation, which alone cannot prove visible output.
+
+**Outcome:** A minimal emissive fixture and the snow kit render correctly with bloom enabled on browser and native, or the stage is explicitly rejected before a blank frame is presented. The repair belongs to the identified failing layer, not to a hidden game-only workaround.
+
+## Design and ownership
+
+First reduce the reported snow case using its exact exposure, threshold, attachments and renderer cohort. Trace graph output, tone mapping, MRT allocation and native texture lifetime without assuming which is faulty. Keep one tone-map/output transform. A stage being constructed is not a visual success criterion. Preserve the no-post path, lazy attachment allocation and existing stage-disposal contract.
+
+Inspect the snow generated render source, shared `worldEnvironment.ts`, `RenderChain`, renderer output installation and native WebGPU replay only as the reproduction directs. No unrelated post-stack rewrite and no arbitrary brightness changes disguised as a bug fix.
+
+## Required behavior
+
+- Run bloom off/on, thresholds below/at/above 0.92, resize, scene switch and quality-tier transitions on the same fixture.
+- Assert finite pixels, a nonblank world region and a measurable halo around the emissive test patch; inspect the world separately from the HUD.
+- An unsupported stage reports its reason and preserves a valid world output; it must not be reported as visually qualified.
+- Keep enabled and disabled resource lifetimes bounded across at least 20 toggles.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Reproduce and repair the failing layer
+
+- [ ] Add the regression fixture and an assertion that detects the reported blank world independently of the HUD. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`.
+- [ ] Fix the demonstrated source of failure while preserving exposure, tone mapping and the authored snow appearance. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`.
+
+### Phase 2 — Keep graph replacement safe
+
+- [ ] Cover resize, stage replacement and tier transitions without stale targets or duplicate output transforms. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`.
+- [ ] Add effect-region assertions and allocation-lifetime checks, then restore bloom only on qualified paths. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The emissive fixture and snow scene pass the off/on and threshold matrix on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-postprocessing-parity.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same fixture and authored snow settings pass on a packaged Linux native host, with real world captures and zero GPU validation errors. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-postprocessing-parity.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The original reported case is either reproduced and fixed or explicitly reclassified with the exact non-reproducing cohort; no unrelated change is presented as its fix. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-native-postprocessing-parity.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Record bloom cost separately from world rendering. No unbounded render-target growth across 20 toggles and resizes. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Other native operating systems and physical Android require their own runs before extending the qualification claim.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md
new file mode 100644
index 0000000000..a394698848
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-03-local-reflection-probes.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-03 — Off-screen specular reflections come from a complete local probe
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / lighting.
+**Dependencies:** Coordinate with PRD-381 row 5; this is its bounded child specification. Do not duplicate its capture-completeness work elsewhere.
+
+## Grounding and intended outcome
+
+[packages/core/src/render/probe-volume.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/probe-volume.ts) owns diffuse irradiance, not local specular reflection. The existing [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md) already identifies a dynamic local reflection probe. SSR and a global environment remain useful incumbents.
+
+**Outcome:** A polished object in a room reflects an off-camera landmark from that room. Entering a second room changes the fallback smoothly, without a half-updated cubemap, self-capture recursion or global-environment replacement for unrelated objects.
+
+## Design and ownership
+
+Reuse Three cube capture and PMREM. Maintain separate capture, completed and published generations; a six-face capture is published only after prefilter completion. A scene/light revision change invalidates an inconsistent generation. Bound capture work and coalesce refresh requests. Exclude the reflective receiver and viewmodel explicitly. Supply box-projection data and completed textures as mechanism; the generated material owns roughness, intensity and the SSR-to-probe-to-global composition. SSR confidence must not double-add specular energy.
+
+One box probe first, then deterministic blending of two overlapping probes. New shared code is limited to capture scheduling, versioning and ownership and must beat the two-consumer direct baseline. Shader sampling and all look choices stay in generated `src/render/`.
+
+## Required behavior
+
+- A failed or cancelled capture retains the last complete result; disposal releases owned targets without disposing borrowed scene assets.
+- The two-room test uses a contrasting off-screen landmark and verifies a changed reflection without changing the visible room lighting.
+- Box projection handles translation and declared scale; unsupported transforms fail explicitly.
+- Material assignment is receiver-specific, and no enabled probe adds a second uncontrolled game loop.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Complete captures and bounded ownership
+
+- [ ] Implement generation-safe incremental cube capture and PMREM publication using the existing render lifecycle. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`.
+- [ ] Implement cancellation, coalesced invalidation and bounded two-probe resource ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`.
+
+### Phase 2 — Compose the local specular fallback
+
+- [ ] Add game-owned box-projected sampling and deterministic overlap blending. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`.
+- [ ] Integrate the existing SSR fallback without double-counting specular light and without capturing the viewmodel or receiver recursively. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The two-room off-screen-landmark scenario verifies local reflection, overlap continuity and refresh behavior on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-local-reflection-probes.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same package executes the two-room scenario on Linux native with complete-generation markers and matching probe selection. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-local-reflection-probes.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The off-screen reflected landmark survives a camera turn that removes it from screen space; every visible probe generation is complete. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-local-reflection-probes.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Report capture faces, prefilter cost, capture age and resident bytes. Bound active probes and skip redundant captures; no claim that dynamic probes are free. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Physical-device quality/performance tiers are qualified separately; native support must not be extrapolated from a browser cube render.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md
new file mode 100644
index 0000000000..74d5cca547
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-04-locomotion-blend-spaces.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-04 — Locomotion blends by speed and direction without restarting its gait
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / character motion.
+**Dependencies:** Uses existing AnimationPlayer, SkeletalMesh3D and stride synchronization. Coordinate its action ownership with VQ-05.
+
+## Grounding and intended outcome
+
+[packages/core/src/animation.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/animation.ts) already implements clip playback and crossfades. This PRD does not rebuild those. The remaining product outcome is continuously weighted, phase-coherent locomotion across speed and direction. Historical animation-state-machine restrictions remain in force: this is not a graph editor or serialized animation IR.
+
+**Outcome:** A licensed rig moves smoothly from idle through walking to running and strafes in a 2D direction space. Changing direction or speed does not repeatedly restart clips or introduce a one-frame pose pop.
+
+## Design and ownership
+
+Use ordinary Three AnimationActions with one authoritative updater. Put locomotion states, clip selection, thresholds and transitions in editable game TypeScript. Admit shared numerical weight/phase helpers only after two consumers demonstrate less total code. Use sorted intervals for 1D and a deterministic declared interpolation domain for 2D; handle points outside that domain explicitly. Preserve normalized phase for compatible gait clips, with an explicit opt-out for incompatible authored cycles. Do not confuse stride rate with root-motion extraction.
+
+Idle/walk/run and four-direction locomotion only. No motion matching, full Animator graph, visual editor, new asset format or automatic semantic inference from clip filenames.
+
+## Required behavior
+
+- Weights remain finite and nonnegative and sum to 1 within 1e-6; duplicate samples and degenerate 2D domains fail or use an explicitly documented fallback.
+- Identical input replay is deterministic, and no independent mixer loop is created.
+- A speed sweep preserves gait phase across compatible clips; a large instantaneous intent change has a bounded authored transition.
+- Test rapid idle/walk/run/idle reversal and returning to a still-contributing action, not just a fresh third clip.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Continuous weights with explicit boundaries
+
+- [ ] Implement and test deterministic 1D and 2D weight evaluation, including degenerate and out-of-domain inputs. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`.
+- [ ] Integrate weighted action ownership and phase synchronization without breaking existing play/once/stride behavior. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`.
+
+### Phase 2 — Two real locomotion consumers
+
+- [ ] Drive a first-person-visible body and a third-person rig through the same weight/phase mechanism with game-owned settings. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`.
+- [ ] Expose selected clips, weights and phase in existing gameplay observations and verify rapid reversals against sampled poses. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The real-rig speed/strafe sweep passes weight, phase and pose-continuity assertions on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-locomotion-blend-spaces.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The identical input trace passes on Linux native; inspect the transition sequence rather than a single still frame. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-locomotion-blend-spaces.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] Continuous speed/direction changes require no game-specific replacement mixer, and a same-rig trace produces matching clip weights across the two qualified runtimes. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-locomotion-blend-spaces.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Count active actions and allocations. Keep nonparticipants at the existing AnimationPlayer cost and document any extra per-rig work. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+A license-clear multi-clip rig is required. Use repository-owned fixtures where possible; downloading commercial animations is not assumed.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md
new file mode 100644
index 0000000000..7f1cbf3732
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-05-masked-additive-animation.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-05 — Upper-body actions and additive reactions compose over locomotion
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 1 / character motion.
+**Dependencies:** Coordinate with VQ-04 through one action owner. Existing constrained IK must remain after authored animation evaluation.
+
+## Grounding and intended outcome
+
+[packages/core/src/animation.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/animation.ts) exposes the Three mixer and existing clip transitions. Additive animation is an upstream Three capability, not missing mathematics. What needs qualification is masked layer composition, reference-pose handling, lifecycle and ordering for actual ThreeNative characters.
+
+**Outcome:** A character continues walking while aiming or reloading; recoil and breathing add to the pose without replacing the lower-body gait. Removing a layer restores the underlying animation with no accumulated drift.
+
+## Design and ownership
+
+Keep state selection and masks in game-owned TypeScript. Resolve masks against cloned rigs, filter or weight tracks through a single mixer owner, and reuse upstream additive-clip preparation against an explicit reference pose. Never modify shared source clips. Distinguish override layers from additive deltas and define quaternion normalization. Order: locomotion/base, override/additive layers, IK, render; root movement retains its existing owner. A generalized animation graph is explicitly out of scope.
+
+One base layer, one masked override and two bounded additive layers; masks are explicit bone names with optional descendant expansion. Face-control and root-motion ownership conflicts must be refused or explicitly excluded, not silently mixed.
+
+## Required behavior
+
+- With layer weight zero, sampled bone transforms equal the base-only result within numeric tolerance.
+- Two cloned characters sharing source clips can use different masks and weights without cross-talk.
+- Missing bones, incompatible tracks and missing additive reference poses are actionable errors.
+- Loop/once completion, interrupted reloads, repeated weight ramps and removal/disposal leave no residual pose or live action.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Correct layer data and ownership
+
+- [ ] Implement mask resolution and immutable additive preparation using ordinary Three clips/actions. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`.
+- [ ] Add deterministic composition and reference-pose tests, including independent cloned characters. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`.
+
+### Phase 2 — Integrate locomotion and IK ordering
+
+- [ ] Compose reload/aim and recoil over the VQ-04 or existing locomotion consumer with one updater. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`.
+- [ ] Verify root ownership, IK-after-animation ordering, cancellation and layer removal through pose observations. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] A walking/reloading/recoiling character passes lower-body preservation and layer-removal assertions on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-masked-additive-animation.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The identical layered trace passes on Linux native with sampled bone transforms and retained source-clip hashes. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-masked-additive-animation.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] Layer weight zero is a base-only identity, and removing all added layers restores base animation without pose drift or shared-rig mutation. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-masked-additive-animation.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Bound layer and action counts. Measure CPU per rig; no mandatory overhead for characters using only the existing player. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Physical-device crowd scaling and additional character rigs need separate qualification; these do not block the two-runtime correctness slice.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md
new file mode 100644
index 0000000000..6627765051
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-06-many-local-lights.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-06 — Many local lights use a qualified upstream tiled or clustered path
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / scalable lighting.
+**Dependencies:** Use PRD-457 for local shadow work. This PRD owns direct-light selection, not a new shadow atlas.
+
+## Grounding and intended outcome
+
+The reviewed render surface does not expose a qualified many-light route. Current upstream documents [TiledLightsNode](https://threejs.org/docs/pages/TiledLightsNode.html), [ClusteredLightsNode](https://threejs.org/docs/pages/ClusteredLightsNode.html) and [DynamicLightsNode](https://threejs.org/docs/pages/DynamicLightsNode.html). [pnpm-workspace.yaml](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/pnpm-workspace.yaml) pins Three 0.185.1: online availability is not proof that an addon exists or works in this pin.
+
+**Outcome:** A corridor/city-night fixture with 256 unshadowed point lights renders the same intended lighting at a lower measured cost than the ordinary per-light baseline on a workload where light-list culling helps.
+
+## Design and ownership
+
+Inspect the installed Three pin and patches before selecting a donor. Prefer its supported tiled/clustered or dynamic-light implementation. Use an upstream-compatible upgrade only with regression qualification; do not write a parallel Forward+ renderer from the audit wording. Unsupported light types, cookies and shadowed lights retain an explicit stock route. Cluster overflow must preserve illumination through a correct fallback rather than dropping lights. Game source owns light intensities, colors and geometry.
+
+Point-light throughput first; explicitly enumerate support for spots, directional lights, transparency and shadow-casting lights. Keep the existing renderer, material system and directional shadow path.
+
+## Required behavior
+
+- A correctness fixture compares the optimized and reference direct-light results before temporal or post effects.
+- Overflow, camera clipping, light movement, addition/removal and depth-separated lights are covered.
+- A missing addon or unsupported native feature is reported, not replaced with an unqualified backend.
+- The existing PBR, animated/instanced and probe-consuming material fixtures keep their intended lighting.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Admit the installed upstream path
+
+- [ ] Integrate the smallest compatible upstream lighting route with explicit per-light support and a correct fallback. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`.
+- [ ] Cover light-list correctness, overflow and scene-lifetime changes against the ordinary Three path. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`.
+
+### Phase 2 — Connect real materials and measured selection
+
+- [ ] Exercise the route on animated, instanced and transparent receiver fixtures without replacing their material ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`.
+- [ ] Add direct-light work counters and a measured opt-in/admission decision for the representative many-light workload. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The 256-light scene passes image/correctness checks and a paired GPU-cost comparison on a named WebGPU adapter. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-many-local-lights.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same fixture passes on Linux native and records its own cost result; a browser speedup is not reused as a native result. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-many-local-lights.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The optimized route preserves the reference illumination and wins on the declared many-light workload; otherwise keep it opt-in or decline it with the measurement. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-many-local-lights.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Measure direct-light and shadow cost separately. No claim that 256 shadowed lights or every spot-light feature is supported by a point-light cluster implementation. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Additional hardware and Three upgrades require their own compatibility runs before default-on promotion.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md
new file mode 100644
index 0000000000..89257e20a3
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-07-volumetric-fog.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-07 — Local volumetric fog composes with depth, lights and existing atmosphere
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / atmosphere.
+**Dependencies:** Independent of clouds. Reuse VQ-06 only for any local-light feature it has actually qualified.
+
+## Grounding and intended outcome
+
+ThreeNative already has atmosphere LUTs and god-ray composition. Current upstream [VolumeNodeMaterial](https://threejs.org/docs/pages/VolumeNodeMaterial.html) and volumetric lighting machinery are reuse candidates, not proof of a finished ThreeNative fog product. Recheck the installed pin and the merged rain kit before introducing a new algorithm.
+
+**Outcome:** A bounded mist volume in an interior/exterior fixture responds to the main light and declared local lights, is occluded by solid geometry and blends into the existing atmosphere without becoming a uniformly bright screen overlay.
+
+## Design and ownership
+
+Start with the supported upstream volume/material or existing kit implementation. Density functions, phase function, color and artistic controls remain generated TSL/game source. Share only depth access, lifetime and ordered dispatch that the current render loop cannot already supply. Composite radiance with transmittance in the scene-linear graph before its one output transform; specify the relationship with aerial perspective and god rays so the same medium is not added twice. Keep history optional until its disocclusion behavior is qualified.
+
+A bounded volume and height-dependent density, one directional source and an explicitly supported local-light subset. No mandatory froxel engine, general weather manager, or promise that all point/spot shadow types work.
+
+## Required behavior
+
+- Zero density produces the no-fog image; an opaque foreground wall hides fog behind it.
+- The fixture tests the camera both outside and inside the volume, a light toggle and overlapping density bounds.
+- Step count and resolution are quality controls, with an explicit off/unsupported route and no dormant GPU allocations.
+- Resizes, camera cuts and streamed occluder changes do not reuse invalid depth/history.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Depth-correct participating volume
+
+- [ ] Implement game-owned volumetric composition using the admitted upstream/kit mechanism and shared scene depth. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`.
+- [ ] Cover zero density, inside/outside camera positions and opaque occlusion with analytic or reference fixtures. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`.
+
+### Phase 2 — Compose with the existing environment
+
+- [ ] Integrate directional and the declared local-light subset without double-applying atmosphere or god rays. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`.
+- [ ] Add bounded quality settings, history invalidation where used and deterministic resource disposal. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The fog fixture verifies occlusion, light response and zero-density identity on browser WebGPU with fixed-camera captures. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same authored volume runs on Linux native and remains valid through resize and scene teardown. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-volumetric-fog.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] Fog is depth-correct and light-responsive, and turning it off restores the original graph and allocation baseline. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-volumetric-fog.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Record pixels, ray steps, light count and cost on the same fixture. Choose tier admission from measurements, not an arbitrary AAA label. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Phone performance and a wider local-shadow set are separate qualification work.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md
new file mode 100644
index 0000000000..39bb5aa638
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-08-clouds-and-overhead-transmittance.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-08 — Clouds and their ground attenuation share one authored field
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / atmosphere.
+**Dependencies:** Reuse the merged rain/snow kits and PRD-381 row 4. Coordinate atmosphere composition with VQ-07; do not recreate the kits.
+
+## Grounding and intended outcome
+
+[PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) is merged and owns rain/snow starter outcomes. [PRD-381](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/feature-mining/PRD-381-nine-mineable-seams-in-eanpa-sky.md) already identifies an overhead attenuation-field seam. The remaining target is coherent cloud rendering and moving cloud shade, not a claim that weather or atmosphere is absent.
+
+**Outcome:** An authored cloud layer moves above an outdoor scene and the associated ground attenuation follows the same field and wind. A sun/coverage change does not leave stale shadow tiles or replace the sky with a detached flat overlay.
+
+## Design and ownership
+
+Inventory the actual rain render source first; keep everything that already meets the outcome. Reuse the atmosphere data and upstream volume tools. The game owns the density field, noise assets, optical controls and motion. Only if duplication justifies it, add the PRD-381 numeric transmittance capture/lifetime seam: texel-snapped world coordinates, bounded coverage, complete generations and declared update age. The visible cloud and ground sampler share parameters, not separately tuned animations.
+
+One regional cloud layer and one directional attenuation field. No global weather simulation, lightning rewrite, astronomical ephemeris rewrite or universal multi-scattering solver. An optional 2D fallback must be named as such.
+
+## Required behavior
+
+- Zero coverage preserves the existing sky and unit ground transmittance; density is finite and transmittance stays in [0,1].
+- A deterministic wind trace moves a known cloud and its ground footprint consistently in world coordinates.
+- Teleport, sun-direction changes and quality changes invalidate stale coverage without showing an incomplete generation.
+- Occluded sky pixels and fog/atmosphere ordering remain correct; cloud attenuation does not erase local artificial lighting.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — One cloud field with reusable inputs
+
+- [ ] Reuse or extend kit cloud rendering so density, wind and lighting are driven by one declared game-owned parameter set. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`.
+- [ ] Produce bounded directional transmittance from that same field, admitting shared capture machinery only when it reduces duplicate code. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`.
+
+### Phase 2 — Stable world-space composition
+
+- [ ] Integrate cloud radiance and direct-sun attenuation with the existing atmosphere and lighting graph. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`.
+- [ ] Implement complete-generation publication, coverage invalidation and quality/resource limits for moving viewpoints. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The cloud/ground-footprint trace passes on WebGPU, including zero coverage, sun rotation and teleport. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-clouds-and-overhead-transmittance.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same trace and parameter set pass on Linux native; record the actual cloud path and attenuation generation. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-clouds-and-overhead-transmittance.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] Visible cloud motion and ground attenuation agree on the authored field, while no-cloud mode preserves the preexisting scene. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-clouds-and-overhead-transmittance.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Measure the visible volume and overhead-field capture separately. Do not enable a costly cloud tier by default without the paired frame budget. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+The optional shared overhead-field extraction is gated by PRD-381 reuse/admission rules; missing artistic noise assets need license-clear substitutes.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md
new file mode 100644
index 0000000000..82db4fe160
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-09-incremental-diffuse-relighting.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-09 — Diffuse probes refresh bounded changed regions without a visible half-bake
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / gated GI extension.
+**Dependencies:** First reconcile and qualify existing PRD-268. The incumbent ProbeVolume remains the single diffuse-probe owner.
+
+## Grounding and intended outcome
+
+[packages/core/src/render/probe-volume.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/render/probe-volume.ts) already performs static diffuse capture and exposes rebaking. Existing [PRD-268](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/lighting/PRD-268-light-that-comes-from-off-screen.md) owns that path. This proposal is only for measured gaps in incremental relighting; manual rebaking must be evaluated before new code is admitted.
+
+**Outcome:** Changing an off-screen lamp or opening a door updates the affected diffuse lighting within a declared latency budget while unaffected regions retain a complete valid result. This is bounded probe relighting, not Lumen parity.
+
+## Design and ownership
+
+Use the existing ProbeVolume capture/sample infrastructure and explicit scene/light revision invalidation. Coalesce dirty regions and schedule work through the existing lifecycle. Publish completed coefficient generations atomically; no reader sees a half-updated atlas. Define previous-versus-current bounce inputs to avoid energy accumulation. Keep artistic response and application in generated materials. If the existing rebake path already satisfies the tests and budget, close this proposal as covered rather than adding a scheduler.
+
+A small indoor volume, one changed lamp and one door/occluder case. No hardware RT dependency, whole-world fully dynamic GI, infinite bounces, or replacement of SSGI.
+
+## Required behavior
+
+- The off-screen source test changes diffuse response even when the lamp remains outside the camera.
+- A completed refresh converges toward a full rebake reference within the declared fixture tolerance.
+- Unchanged regions do not continually rebake, requests coalesce and cancelled generations never publish.
+- Total energy remains bounded over repeated lamp toggles and repeated bake cycles.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Bounded dirty-region updates
+
+- [ ] Extend only the demonstrated gaps in existing rebaking with revision-aware dirty regions and bounded scheduling. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`.
+- [ ] Implement atomic atlas/coefficients publication and cancellation without a second diffuse-probe cache. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`.
+
+### Phase 2 — Convergence and stable composition
+
+- [ ] Verify changed-lamp and door response against the full-rebake reference, including repeated bounce cycles. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`.
+- [ ] Integrate the refreshed diffuse term with the existing material and SSGI composition without duplicate contribution. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The lamp/door scenario records refresh latency, unchanged-region work and radiance convergence on WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-incremental-diffuse-relighting.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same scenario passes on Linux native with its own measured refresh budget and complete-generation observations. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-incremental-diffuse-relighting.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The admitted incremental route performs less work than a full rebake for localized changes while meeting a predeclared image and latency bound; otherwise retain the incumbent. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-incremental-diffuse-relighting.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Pin probe count, density, bounce count and changed region before comparison. A 500 ms response goal is an initial fixture target, not a claimed current engine capability. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+PRD-268 qualification and a representative dynamic-lighting consumer precede default admission; hardware-specific quality tiers remain separate.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md
new file mode 100644
index 0000000000..55c0f8415a
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-10-texture-mip-residency.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-10 — Texture residency releases real GPU memory rather than only biasing sampling
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / memory-gated.
+**Dependencies:** Reconcile PRD-454 resource budgets and VQ-01 decoder selection first.
+
+## Grounding and intended outcome
+
+Existing asset cooking and world-cell residency already manage models, compressed textures and lifetimes. [PRD-454](https://github.com/ThreeNativeHQ/threenative/blob/develop/docs/PRDs/unreal-like-features/PRD-454-worldcells-budget-real-resources.md) owns truthful world-resource budgeting. This slice addresses a measured residual texture-memory bottleneck; it does not assume that lack of virtual texturing is itself a defect.
+
+**Outcome:** A detailed world uses high-detail textures near the player, evicts unnecessary high-detail representations under a hard byte budget and restores them without black materials, stale binds or unbounded cache growth.
+
+## Design and ownership
+
+Begin with separately loadable cooked resolution variants or an available portable mip-loading mechanism. Merely changing LOD bias or sampler LOD limits does not count as memory reclamation. The existing asset owner holds shared references; replace representations atomically and retire GPU resources only after in-flight use is safe. Prioritize current view demand with hysteresis and cancellation, preserve a valid low-detail fallback, and report both committed and pending GPU bytes. No sparse-texture assumption or full runtime virtual-texturing system in v1.
+
+Color/normal/roughness texture families in a representative streamed world; preserve color-space, normal-map and material binding semantics. No tile-feedback renderer or unrelated geometry streaming rewrite.
+
+## Required behavior
+
+- Shared textures are counted once, and active materials never reference disposed textures.
+- A hard budget includes pending replacement overlap and a reserved fallback; over-budget pressure is reported rather than hidden.
+- Teleport, cancellation and two out-and-back routes return resident resources to the expected baseline.
+- Improvement is shown in real allocation/residency accounting, not only smaller files or a visually blurrier frame.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Cooked variants and truthful ownership
+
+- [ ] Extend the existing cook/manifest only with the required variant records and capability-aware cache identity. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`.
+- [ ] Implement shared variant lifetime and byte accounting including pending uploads and in-flight retirement. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`.
+
+### Phase 2 — Bounded demand and safe replacement
+
+- [ ] Add budgeted demand, hysteresis and cancellation while retaining a valid fallback representation. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`.
+- [ ] Wire atomic material replacement and verify repeated streaming/teleport cycles release actual high-detail allocations. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] A fixed world route stays inside the declared allocation budget and demonstrates near-detail restoration on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-texture-mip-residency.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same cooked package and budget contract pass on Linux native, with explicit decoder/format reporting. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-texture-mip-residency.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The route reduces measured resident texture bytes while preserving near-field quality and avoiding missing-texture frames; no demonstrated bottleneck means defer the feature. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-texture-mip-residency.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Use a fixed 256 MiB test budget only for the synthetic fixture, not as a shipping default. Include overlap and fallback bytes; measure upload hitches separately. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+A world with an observed texture-memory bottleneck is required for admission. Mobile claims require decoder qualification and a named device run.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md
new file mode 100644
index 0000000000..1cf6e4ea52
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-11-bounded-decals.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-11 — Impact and environment decals use a bounded portable lifecycle
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 2 / high-payoff detail.
+**Dependencies:** Independent. Share receiver/base-geometry selection with existing LOD and picking paths.
+
+## Grounding and intended outcome
+
+Upstream [DecalGeometry](https://threejs.org/docs/pages/DecalGeometry.html) already provides mesh projection. The earlier audit must not be interpreted as permission to rewrite that algorithm. The useful product gap is qualified receiver handling, pooling, lifetime and game-owned material composition for real impacts.
+
+**Outcome:** Repeated bullet impacts and environment marks remain attached to their receivers, respect normals and occlusion, and stay within a fixed resource cap as the player moves or the scene unloads.
+
+## Design and ownership
+
+Start from ordinary Three decal geometry and generated materials. Reuse existing hit results, object transforms and authored LOD0 geometry rather than raycasting the whole world again. Admit a shared lifecycle owner only when at least two consumers need it. Keep material, atlas choice, color, depth bias and fade curves in generated source. A world-static mark and a rigid-moving receiver are separate supported cases; skinned or deforming decals are explicitly refused in v1.
+
+Surface-color impacts and environment marks on static/rigid opaque receivers. No deferred G-buffer decal renderer, normal/roughness channel rewrite, skinned tattoo system or virtual-texture painting.
+
+## Required behavior
+
+- A cap of 256 decals in the fixture is enforced with deterministic eviction, and receiver teardown removes its marks.
+- A mark stays on a rigid receiver under translation/rotation; nonuniform or unsupported transforms have a specified behavior.
+- Receiver isolation avoids marks projecting through nearby unrelated geometry; corner distortion is documented.
+- LOD changes do not alter the gameplay hit surface or strand a decal on disposed geometry.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Reuse projection and bound lifetime
+
+- [ ] Integrate upstream decal projection with existing picking/base-geometry data and explicit receiver support. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`.
+- [ ] Implement deterministic pool limits, eviction, receiver cleanup and borrowed-resource ownership. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`.
+
+### Phase 2 — Preserve authored appearance and receiver motion
+
+- [ ] Add game-owned decal material/atlas composition with bounded bias and fade controls. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`.
+- [ ] Verify rigid receiver motion, LOD changes and repeated scene teardown without detached marks or allocation growth. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The impact sequence covers the 256-mark cap, moving receiver and LOD boundary on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-bounded-decals.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same sequence runs on Linux native with matching pool/receiver observations and visible marks. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-bounded-decals.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The supported receiver cases retain their marks under motion and teardown, and resource use is bounded independently of total shots fired. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-bounded-decals.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Measure draw calls and geometry bytes at the cap. Batch only compatible marks when it improves the measured cost; do not trade correctness for an advertised one-draw system. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Skinned/deforming receivers require a separate demand-backed extension and are not silently treated as rigid meshes.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md
new file mode 100644
index 0000000000..35ce8b0598
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-12-transparent-effects-composition.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-12 — Overlapping transparent effects have an explicit correct or bounded-approximate path
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / scene-gated transparency.
+**Dependencies:** Coordinate attachment/history ownership with VQ-02 and PRD-455. Preserve existing alpha-cutout foliage behavior.
+
+## Grounding and intended outcome
+
+[packages/core/src/world-cells.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/core/src/world-cells.ts) distinguishes blended scatter from alpha cutout because individual instances are not ordinarily sorted. This is not a lack of all transparency support. A demand-backed improvement must compare sorting, cutout/alpha hashing and an optional OIT route before adding another pass.
+
+**Outcome:** A deterministic scene of intersecting smoke cards no longer visibly changes just because emitter insertion order changes, while glass/transmission and foliage keep their explicitly chosen existing paths.
+
+## Design and ownership
+
+Use upstream supported sorting/alpha techniques first. If the fixture still demonstrates a material gap, admit a bounded weighted-blended OIT route for eligible nonrefractive effects, with independent accumulation/revealage attachments and explicit composition. This is an approximation, not physically exact multi-layer refraction. Define eligibility, color space, premultiplied alpha and depth semantics. Unsupported materials stay sorted or fail with a reason; never silently route refractive glass into a smoke approximation.
+
+Nonrefractive smoke/particle cards first. No universal hair/glass/water fix, depth peeling, arbitrary material substitution or new renderer backend.
+
+## Required behavior
+
+- Insertion-order permutations produce equivalent eligible-effect output within a declared floating-point tolerance.
+- Opaque geometry occludes eligible effects; alpha zero is an identity and dense overlap remains finite.
+- Water/transmission, cutout foliage and custom depth materials retain their original route unless explicitly admitted.
+- Resize, MSAA changes, scene switch and temporal-history invalidation release/recreate the right attachments once.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Eligibility and the smallest useful technique
+
+- [ ] Implement explicit material-route selection using the existing renderer and test the cheaper sorted/cutout controls. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`.
+- [ ] Only where needed, add bounded accumulation/composition for eligible effects with numerical alpha and order-permutation tests. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`.
+
+### Phase 2 — Compose safely with the world
+
+- [ ] Integrate opaque depth, premultiplied color and the existing output graph without altering excluded materials. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`.
+- [ ] Cover target lifetime, resize, quality fallback and temporal-history invalidation for the admitted route. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The overlapping-effects scene passes order permutations, occlusion and exclusion checks on browser WebGPU. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-transparent-effects-composition.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same material-route and composition cases pass on Linux native with no unsupported attachment fallback hidden. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-transparent-effects-composition.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The technique materially improves the demonstrated artifact and stays within its declared cost; otherwise retain the simpler existing routes and record a decline. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/core/__tests__/vq-transparent-effects-composition.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Record attachment bytes, fill rate and total effect cost. A 50-card stress fixture is a test, not a universal scene limit. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+A real overlapping-effects consumer is the admission gate. Correct multi-layer refraction and strand hair remain out of scope.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md
new file mode 100644
index 0000000000..e9a870b3be
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-13-cinematic-stage-integration.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-13 — Depth of field and motion blur are qualified optional stages, not a second camera pipeline
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / optional cinematics.
+**Dependencies:** PRD-269 motion correctness and PRD-455 history/reset rules; VQ-02 output lifetime qualification.
+
+## Grounding and intended outcome
+
+Current upstream supplies [DepthOfFieldNode](https://threejs.org/docs/pages/DepthOfFieldNode.html); the repository already lists motion-blur/temporal stage names and velocity infrastructure. The missing work is coherent generated-source integration and qualification, not implementing blur mathematics from scratch.
+
+**Outcome:** A cinematic route changes focus between a near and far target and applies motion blur to actual moving world geometry without smearing a camera cut, the HUD or an explicitly excluded first-person viewmodel.
+
+## Design and ownership
+
+Reuse installed upstream nodes after verifying the pinned versions. Keep focus, bokeh scale, shutter controls and enablement in generated render source. Reuse scene depth and velocity; specify stage ordering relative to reconstruction, bloom and the single output transform. Focus distance uses the node API's documented world units, not an invented millimeter lens equation. Camera cuts and projection/size changes reset any history. Disabled stages allocate and dispatch nothing.
+
+One optional DOF stage and one velocity-driven motion-blur stage in an existing generated-source consumer. No sequencer editor, autofocus AI, camera-physics package or forced gameplay blur.
+
+## Required behavior
+
+- A depth-target fixture keeps the focus plane sharp while the out-of-focus region changes, rather than blurring the entire image.
+- Stationary geometry under a stationary camera has no motion blur; a moving rig and instance use their real motion history.
+- The HUD remains outside the world post graph, and viewmodel inclusion is an explicit game decision.
+- Off/on, cut, resize and tier switching preserve a valid frame and bounded targets.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Reuse the existing stage seams
+
+- [ ] Add generated-source factories for installed DOF and motion-blur nodes with documented controls and no duplicate scene pass. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`.
+- [ ] Validate depth, velocity, stage order and unsupported-camera/material conditions before installation. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`.
+
+### Phase 2 — Make transitions and exclusions correct
+
+- [ ] Implement history resets and explicit HUD/viewmodel composition boundaries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`.
+- [ ] Add focus-plane, stationary-motion and stage-off identity assertions plus repeated-toggle disposal checks. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The focus/motion/camera-cut route passes on browser WebGPU with effect-region image checks. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-cinematic-stage-integration.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same optional stages and camera route pass on Linux native, including disabled-path parity. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-cinematic-stage-integration.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] Both effects respond to the intended depth/motion inputs and disappear cleanly when disabled; no new rendering pipeline is introduced. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-cinematic-stage-integration.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Measure each stage independently. Keep optional/cinematic by default; no quality improvement is inferred from blur alone. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+Additional camera models and physical mobile performance are separate qualifiers, not assumed support.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md
new file mode 100644
index 0000000000..e4024ce504
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-14-skin-material-qualification.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-14 — Skin shading reuses upstream material support and preserves facial animation
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / character-quality gate.
+**Dependencies:** Use a license-clear rigged head. Preserve existing MetaHuman/GLTF binding and expression ownership.
+
+## Grounding and intended outcome
+
+Current upstream documents experimental [MeshSSSNodeMaterial](https://threejs.org/docs/pages/MeshSSSNodeMaterial.html). The prior audit's wording should not be read as absence of every subsurface-scattering primitive. Verify the installed pin; the product task is a controllable, portable skin look with real assets and explicit limits.
+
+**Outcome:** A rigged head retains expressions, normals and authored texture detail while thin regions respond plausibly to backlighting; the result is compared with its ordinary PBR baseline rather than declared AAA from an effect name.
+
+## Design and ownership
+
+Use the installed physical/SSS material or a supported upgrade, with appearance in generated source. Keep thickness, albedo, roughness, normal detail and artistic scattering controls asset/game-owned. Distinguish an approximate transmission term from a full diffusion model. Preserve skinning and morph-target paths and avoid counting the same lighting twice when probes or SSGI are active. No new mandatory core material class or MetaHuman-only render dependency.
+
+One textured, expression-capable head plus a synthetic thin-surface test. No offline path-traced skin, texture generation, pore synthesis, face-rig rewrite or claim that an approximate SSS node matches a production diffusion renderer.
+
+## Required behavior
+
+- The baseline and candidate share geometry, pose, exposure and lights; only the intended material route changes.
+- Thickness-zero and disabled-scattering controls reduce to the declared baseline behavior.
+- Expression/morph changes preserve texture bindings, tangent/normal continuity and independent character instances.
+- A low tier has an explicit PBR fallback and does not fetch unused scattering assets.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Admit the pinned material path
+
+- [ ] Implement the smallest generated-source skin route using compatible upstream support and declared thickness/texture inputs. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`.
+- [ ] Cover disabled/thickness controls, finite output and immutable borrowed asset data. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`.
+
+### Phase 2 — Preserve the animated character
+
+- [ ] Exercise expressions, skinning and morph targets with the candidate material and existing lighting composition. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`.
+- [ ] Add an explicit low-cost fallback and scene/material lifetime checks across two independent head instances. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The neutral/backlit expression sweep runs on WebGPU with matched baseline/candidate captures and numeric control cases. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-skin-material-qualification.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same head, expressions and generated material run on Linux native without feature loss or unexpected target errors. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-skin-material-qualification.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The authored scattering controls are effective, facial animation remains correct, and the human reviewer can compare matched images without an automatic aesthetic score being invented. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-skin-material-qualification.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Report added shader/texture cost and frame contribution. Keep experimental/opt-in until the visual and platform evidence supports broader use. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+The owner supplies or approves redistribution rights for any third-party head. Human aesthetic approval is a named external checkpoint, not a falsely ticked test.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md
new file mode 100644
index 0000000000..63ff644019
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-15-hair-card-material-qualification.md
@@ -0,0 +1,64 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-15 — Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers
+
+**Status:** PROPOSED — 2026-10-01. No implementation or qualification is claimed.
+**Batch:** [Visual quality execution batch](README.md). **Wave:** 3 / character-quality gate.
+**Dependencies:** Uses qualified temporal/alpha behavior from PRD-455; VQ-12 is optional and not assumed to solve hair.
+
+## Grounding and intended outcome
+
+Ordinary Three physical materials, anisotropy and alpha techniques are the first candidates. A dedicated strand renderer is not established as necessary by a manifest search. The bounded target is a license-clear hair-card asset that survives camera motion, lighting changes and native rendering.
+
+**Outcome:** A character's hair cards show authored directional highlights without losing silhouette coverage, sorting into obvious layers or casting an unrelated opaque block as their shadow.
+
+## Design and ownership
+
+Keep tangents, root-tip texture data, alpha thresholds, anisotropy and colors in generated materials. Prefer cutout/alpha-to-coverage or a qualified temporal technique when it preserves the asset. Do not silently use the smoke OIT approximation for hair, and do not promise order-independent physical scattering. Match visible and shadow alpha semantics. A low tier is an authored cheaper material/card representation, not an automatic deletion of random cards.
+
+Hair cards only, one rigged character asset and a synthetic crossed-card fixture. No procedural grooming, individual strands, hair simulation, multiple-scattering solver or new core material system.
+
+## Required behavior
+
+- Tangent and alpha inputs are validated; missing data has an explicit fallback or error.
+- The synthetic test exercises crossed cards, back/front views, moving highlights and shadow silhouettes.
+- Camera motion and quality switches are evaluated as frame sequences for coverage/flicker, not one screenshot.
+- Two character instances keep independent materials where controls differ and share immutable assets safely.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Coverage and directional shading
+
+- [ ] Implement a game-owned hair-card material from admitted upstream features with explicit alpha and tangent contracts. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`.
+- [ ] Add coverage, highlight-direction and visible/shadow agreement checks on the synthetic fixture. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`.
+
+### Phase 2 — Integrate the real character and tiers
+
+- [ ] Apply the material to the rigged hair-card consumer without breaking skinning or borrowed resource ownership. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`.
+- [ ] Implement explicit lower-cost tiers and temporal/cutout compatibility with scene-switch disposal tests. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts`.
+
+### Phase 3 — Qualify the visible result
+
+- [ ] The moving-camera/head/light trace passes coverage and shadow checks on browser WebGPU with matched sequence review. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-hair-card-material-qualification.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`.
+- [ ] The same hair asset and tier trace run on Linux native, reporting the actual alpha and temporal route. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-hair-card-material-qualification.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}`.
+
+## Acceptance criteria
+
+- [ ] The supported hair-card path preserves silhouette and shadow coverage through motion and tier changes; no strand-renderer support is implied. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
+- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-hair-card-material-qualification.spec.ts` plus the Phase 3 lifecycle scenario.
+
+## Performance and promotion
+
+Record alpha overdraw, card triangles, shader cost and temporal artifacts by tier. A visually worse but faster thinning is not accepted as a quality fix. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Blocked on
+
+A redistributable hair-card asset and human visual approval are external requirements. Grooming and physical-device scaling remain separate.
+
+## Completion record
+
+Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete.
diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/README.md b/docs/PRDs/batch-2026-10-01-visual-quality/README.md
new file mode 100644
index 0000000000..a8c8fda8f3
--- /dev/null
+++ b/docs/PRDs/batch-2026-10-01-visual-quality/README.md
@@ -0,0 +1,75 @@
+# Visual quality execution batch — 2026-10-01
+
+**Purpose:** close the functional gaps that most visibly affect ThreeNative games, without rebuilding working engine systems or turning a visual audit into a second renderer.
+
+**Contents:** 15 new bounded PRDs, 10 existing canonical execution owners in [EXISTING-PRDS.md](EXISTING-PRDS.md), companion-work links, and an [agent execution handoff](EXECUTE.md). Existing PRDs intentionally remain at their canonical paths; this folder is the single queue, not a second copy of their status/evidence.
+
+**Baseline:** `ThreeNativeHQ/threenative` `develop` at [d72778382b134ef8763f58825cb4d4fd8cc0f6e3](https://github.com/ThreeNativeHQ/threenative/commit/d72778382b134ef8763f58825cb4d4fd8cc0f6e3); Three is pinned to 0.185.1. Reconcile against current `origin/develop` before each task. This is a planning snapshot, not a runtime qualification report or an exhaustive fresh line-by-line audit.
+
+## Agreement with the audit, with corrections
+
+The visual priorities are useful. Several literal absence claims were too broad:
+
+- Current upstream has tiled/clustered lighting, volumetric material, DOF, additive animation and experimental skin-material primitives. Verify the installed pin and prefer integration/qualification over replacing them. Their existence upstream is not proof they are shipped in ThreeNative.
+- [PR #375](https://github.com/ThreeNativeHQ/threenative/pull/375) and [PR #382](https://github.com/ThreeNativeHQ/threenative/pull/382) are merged. GPU-driven open-world work and rain/snow are not new greenfield tasks. Their remaining limitations must be separated from their completed work.
+- A target-based compressed-asset guard remains in [build.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/create-threenative/src/build.ts#L140-L202), but V8 alone does not prove Basis/Meshopt/Draco work in a packaged Android app. VQ-01 repairs and tests the complete capability contract rather than simply deleting the guard.
+- The merged snow PR reports native bloom blanking the world. VQ-02 starts by reproducing that report; it does not pretend the root cause has already been found.
+- Existing velocity and diffuse-probe source are not greenfield even though their older PRDs still say PROPOSED. Reconcile the proof, not just the headings. Likewise, animation crossfades exist, but the previous broad claim of interruption correctness must be tested for rapid reversals and returning to an already weighted action.
+- Static probe relighting, texture residency, OIT, skin and hair are **gated extensions**, not mandatory replacements. No Lumen/Nanite/DLSS/Unreal-parity label is awarded by this folder.
+
+## First execution wave
+
+Start VQ-01 and VQ-02 independently. In parallel, qualify the unresolved PRD-269 motion/history cases. Then prioritize PRD-455 temporal reconstruction, PRD-339 exposure and PRD-460 visible prop transitions. For a character-heavy game, VQ-04/VQ-05 can run as a separate sequence; for reflective interiors, VQ-03 is the next lighting task.
+
+| Wave | New PRD | Player-visible outcome | Main dependency |
+|---|---|---|---|
+| 0 / correctness | [VQ-01](PRD-VQ-01-native-asset-capabilities.md) | Native asset compatibility follows the selected runtime and actual decoders | Selected runtime/cohort truth |
+| 0 / correctness | [VQ-02](PRD-VQ-02-native-postprocessing-parity.md) | A requested post-processing stage never turns the native world into a blank frame | Reproduce reported native bloom failure |
+| 1 / lighting | [VQ-03](PRD-VQ-03-local-reflection-probes.md) | Off-screen specular reflections come from a complete local probe | PRD-381 local-probe child; existing SSR |
+| 1 / character motion | [VQ-04](PRD-VQ-04-locomotion-blend-spaces.md) | Locomotion blends by speed and direction without restarting its gait | Existing animation/stride player |
+| 1 / character motion | [VQ-05](PRD-VQ-05-masked-additive-animation.md) | Upper-body actions and additive reactions compose over locomotion | Coordinate with VQ-04; IK ordering |
+| 2 / scalable lighting | [VQ-06](PRD-VQ-06-many-local-lights.md) | Many local lights use a qualified upstream tiled or clustered path | Installed upstream pin; PRD-457 for shadows |
+| 2 / atmosphere | [VQ-07](PRD-VQ-07-volumetric-fog.md) | Local volumetric fog composes with depth, lights and existing atmosphere | Existing atmosphere/kit and upstream volume path |
+| 2 / atmosphere | [VQ-08](PRD-VQ-08-clouds-and-overhead-transmittance.md) | Clouds and their ground attenuation share one authored field | Merged rain kit; PRD-381 overhead field |
+| 3 / gated GI extension | [VQ-09](PRD-VQ-09-incremental-diffuse-relighting.md) | Diffuse probes refresh bounded changed regions without a visible half-bake | Qualify PRD-268 first |
+| 3 / memory-gated | [VQ-10](PRD-VQ-10-texture-mip-residency.md) | Texture residency releases real GPU memory rather than only biasing sampling | PRD-454 resource accounting; VQ-01 |
+| 2 / high-payoff detail | [VQ-11](PRD-VQ-11-bounded-decals.md) | Impact and environment decals use a bounded portable lifecycle | Existing hit/base-geometry path |
+| 3 / scene-gated transparency | [VQ-12](PRD-VQ-12-transparent-effects-composition.md) | Overlapping transparent effects have an explicit correct or bounded-approximate path | Demonstrated overlap artifact; temporal/output contract |
+| 3 / optional cinematics | [VQ-13](PRD-VQ-13-cinematic-stage-integration.md) | Depth of field and motion blur are qualified optional stages, not a second camera pipeline | PRD-269/455; VQ-02 |
+| 3 / character-quality gate | [VQ-14](PRD-VQ-14-skin-material-qualification.md) | Skin shading reuses upstream material support and preserves facial animation | License-clear animated head; upstream material |
+| 3 / character-quality gate | [VQ-15](PRD-VQ-15-hair-card-material-qualification.md) | Hair cards preserve coverage, tangent highlights and shadow behavior across quality tiers | Hair-card fixture; temporal/alpha qualification |
+
+The ten reused owners are **269, 455, 339, 460, 456, 457, 344, 268, 341 and 342**. Their full paths, residual work and companion PRDs are in [EXISTING-PRDS.md](EXISTING-PRDS.md). Read it before starting either a new or reused item. PRD-341 is now implemented and archived by [PR #392](https://github.com/ThreeNativeHQ/threenative/pull/392); reuse its tone gate rather than reopening that work.
+
+## Dependency outline
+
+```mermaid
+flowchart TD
+ A[VQ-01 native asset capabilities] --> T[VQ-10 texture residency]
+ B[VQ-02 native output regression] --> U[PRD-455 temporal reconstruction]
+ V[PRD-269 motion/history qualification] --> U
+ B --> E[PRD-339 auto exposure]
+ U --> C[VQ-13 cinematic integration]
+ L[PRD-460 prop transitions] --> H[PRD-456 distant proxies]
+ W[VQ-04 locomotion] --> M[VQ-05 masked additive layers]
+ P[PRD-268 diffuse qualification] --> G[VQ-09 incremental relighting]
+ F[PRD-454 resource accounting] --> T
+```
+
+These arrows are actual integration dependencies, not a claim that all independent features must wait for the entire preceding wave. Shared render and world files still require reconciliation before merging.
+
+## Scope and admission rules
+
+The deliverable is a **visible result with a lifecycle and a qualified platform path**, not a class name or an `applied` marker. Retain ordinary Three.js APIs. Shader/material choices, fog/cloud appearance, camera controls and animation state choices stay generated game source. Shared core code is limited to justified mechanisms. No animation graph editor/IR, new scene format, ECS, second frame loop or global material package is authorized.
+
+Numbers in new PRDs are proposed fixture targets and must be pinned before implementation; none are measured results. Use matched input routes, the same adapter/cohort and separate CPU, GPU and presented-cadence observations. Effect-region and temporal-sequence checks complement human visual review; they do not replace it with an invented score.
+
+No implementation is included. Open **one implementation PR per canonical PRD** when executing it; this collection's planning PR is not an umbrella implementation PR. Keep existing work and evidence intact and archive through the repository's established PRD lifecycle.
+
+## Upstream references checked during filing
+
+- [Tiled lighting](https://threejs.org/docs/pages/TiledLightsNode.html), [clustered lighting](https://threejs.org/docs/pages/ClusteredLightsNode.html), [dynamic analytic lights](https://threejs.org/docs/pages/DynamicLightsNode.html).
+- [Temporal AA](https://threejs.org/docs/pages/TRAANode.html), [depth of field](https://threejs.org/docs/pages/DepthOfFieldNode.html), [decal geometry](https://threejs.org/docs/pages/DecalGeometry.html).
+- [Volume material](https://threejs.org/docs/pages/VolumeNodeMaterial.html), [experimental skin material](https://threejs.org/docs/pages/MeshSSSNodeMaterial.html).
+
+These links describe current upstream, not necessarily the pinned 0.185.1 distribution. Pin/patch compatibility is part of admission.
diff --git a/docs/PRDs/CI/PRD-303.md b/docs/PRDs/done/PRD-303.md
similarity index 97%
rename from docs/PRDs/CI/PRD-303.md
rename to docs/PRDs/done/PRD-303.md
index 52bb9ed80d..407a2ae7b5 100644
--- a/docs/PRDs/CI/PRD-303.md
+++ b/docs/PRDs/done/PRD-303.md
@@ -4,7 +4,8 @@ prd_contract: v1
# PRD-303 — CI closes its regression doors, and pays for the added coverage from measured minutes
-**Status:** OPEN, filed 2026-08-31 against `1c2f1550`. Planning only. Every number in §1 is a read
+**Status:** DONE
+**Superseded 2026-10-02:** no box here was ever ticked, but all five doors this plan named are now closed by other work, checked on `develop` (`d930716b9`): `template-nonvisual` scaffolds every template, `desktop-parity` runs in `native-platforms.yml`, `ci.yml` has a `schedule:`, `supply-chain` runs `dependency-review-action`, and `native-platforms` blocks the merge (root `AGENTS.md`). Its trims also landed: the KVM step, the `scaffold-from-tarballs` action, and the starter/platformer golden-path matrix. The open cache-key item moves to [PRD-481](../CI/PRD-481-ci-does-each-piece-of-work-once.md) phase 2. Kept for the rationale only.
of the workflow files or of two GitHub Actions runs of that same day — `33440190780` (green `ci.yml`
push run on `main`) and `33440190473` (`native-platforms`, red per PRD-295). No workflow was edited
while gathering them.
diff --git a/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md b/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md
new file mode 100644
index 0000000000..c89dbd9973
--- /dev/null
+++ b/docs/PRDs/done/PRD-341-a-frames-tone-is-a-number-and-the-number-is-a-gate.md
@@ -0,0 +1,245 @@
+---
+prd_contract: v1
+---
+
+# PRD-341 — a frame's tone is a number, and the number is a gate
+
+**Status:** DONE — implementation and all five acceptance criteria qualified on browser
+SwiftShader WebGPU at `0ea7e29a0ff18717d33b8bce3300ec0568fea842`; required full CI is
+still pending before merge. No native or hardware-performance qualification is claimed.
+Implementation started 2026-10-02 from `d7277838`; original proposal
+filed 2026-09-03, measured at `43d03e6a`. Batch:
+[docs/PRDs/AAA-visuals](../AAA-visuals/README.md). **Land this first** — it is what makes every other PRD in the
+batch judgeable, and it is the cheapest thing here. Source studied:
+[TheLongSilence](https://github.com/achimala/TheLongSilence) `tools/levels.mjs`, `tools/judgeset.mjs`.
+
+**Goal: "it looks flat" stops being an opinion.** The reference states the rule plainly and this
+repository already believes it — *playtest is the eyes, not screenshots* — but the eyes currently
+report two numbers where five are needed.
+
+**Complexity:** a histogram over pixels already decoded, a new assertion kind, a CLI report =
+**LOW**. An afternoon, if `assert.tone` reuses the existing capture path.
+
+## Implementation phases (2026-10-02)
+
+### Phase 1 — one measurement and a strict contract
+
+- [x] Compute the six display-luminance metrics in the existing PNG decode. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/capture.spec.ts` — 9 passed after two missing-metric failures in the fresh reconstruction.
+- [x] Reject empty, malformed and contradictory tone bounds at scenario load. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts` — 28 passed after 25 missing-feature failures in the fresh reconstruction.
+
+### Phase 2 — shared capture evidence and CLI
+
+- [x] Evaluate tone bounds against named captures and fail on missing evidence. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts` — 28 passed after 25 missing-feature failures in the fresh reconstruction.
+- [x] Record `TN_TONE` on browser/device captures and print identical six-number CLI rows plus an average. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers=1 packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts` — 33 passed; five missing-producer failures observed first.
+
+### Phase 3 — scenario proof and qualification
+
+- [x] Ship a discoverable GPU-lane scenario and document the exact statistic/capture contract. proof: `node --import tsx scripts/check-doc-links.ts` and scenario schema test in `tone.spec.ts` — 52 focused fixture/capture/doc tests passed; isolated production Vite bundle and playtest ESM/declarations built.
+- [x] Run the scenario with normal and two-stops-under exposure on a capture-capable GPU lane. proof: `pnpm test:tone`, hosted [run 36988379892](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892) at `0ea7e29a0ff18717d33b8bce3300ec0568fea842` — passed on browser SwiftShader WebGPU. Actual underexposed/restored PNGs inspected; underexposed mean 89.56 / p99 136 fails exactly four intended bounds, restored mean 170.50 / p99 254 passes with clean diagnostics. This is rendered-pixel correctness, not native or hardware-performance proof.
+
+## Implementation decisions
+
+- 2026-10-02: keep the canonical PRD and historical measurements. The current harness uses `assert: { kind: ... }`; implement `assert.tone` as an array of frame-bound requests within that established schema rather than the historical illustrative `assert: "tone"` shape.
+- Reuse the existing PNG decode without resampling; no new decoder, render policy, or GPU readback is introduced. A 256-bin display-referred Rec.709 luminance histogram uses rounded byte bins; wholly transparent pixels are excluded, consistently with the capture guard. No visible pixels means no tone observation.
+
+## The problem, measured at `43d03e6a`
+
+### 1. The capture path measures enough to detect a blank frame and nothing more
+
+`packages/playtest/src/capture.ts` computes `visiblePixels`, `maxLuminance`, `brightPixels`, a mean
+and a standard deviation, and spends all of it on `CAPTURE_GUARD_LIMITS` — is this frame blank, is
+it suspiciously dark. Those are liveness checks. None of them can tell a correctly exposed frame
+from one rendered three stops under, because a three-stop-under frame has plenty of variance and
+plenty of visible pixels. It just looks wrong.
+
+### 2. Every look PRD in this repository currently ends in an argument
+
+The batch's own history is the evidence: the recorded method for calibrating a render against a
+reference is *quantiles plus a one-build ablation*, written down after eyeballing picked the wrong
+cause four times. That method exists, it works, and it is not available as an assertion — so it gets
+re-hand-rolled per investigation and never becomes a gate.
+
+The reference names the exact shape that ends the argument: **mean, p1, p50, p99, clip%, black%**,
+per frame, with an average row. `0.00% of pixels clip and the 99th percentile is 165` is a sentence
+someone can act on, and it is what the reference measured immediately before fixing its highlight
+range.
+
+### 3. Fail closed, or it measures nothing
+
+The repository's own rule: an empty assertion set is a failure, a missing observation is a failure.
+A tone assertion whose capture never happened must go red, not skip — this is the exact shape of the
+v1 harness bug that reported green on scenarios asserting nothing.
+
+## What ships
+
+### `packages/playtest` — `assert.tone`
+
+A new assertion kind over a captured frame:
+
+```jsonc
+{
+ "assert": { "tone": [{
+ "atStep": "landed",
+ "mean": { "min": 60, "max": 140 }, // 0-255, display-referred, after tonemap
+ "p99": { "min": 150 }, // the frame has highlights
+ "p1": { "max": 40 }, // and it has shadows
+ "clipFraction": { "max": 0.005 }, // 0.5% of pixels at display white
+ "blackFraction": { "max": 0.25 }
+ }] }
+}
+```
+
+Every bound is optional; **an assertion with no bound at all throws at schema validation** rather
+than passing vacuously. Percentiles are computed from a 256-bin luminance histogram over the decoded
+frame — the reference decodes to a reduced size on the grounds that the statistics are unchanged and
+a full-res decode of twenty frames is pointlessly slow, and the same applies here.
+
+### `packages/playtest` — `tone` in the CLI
+
+`node packages/playtest/dist/runner/cli.js tone ` prints the reference's table verbatim in
+shape: one row per shot, an average row, no browser needed. This is the tool an agent runs on a
+directory of captures without writing a scenario, and it is what makes a set judgeable at once.
+
+### Reporting
+
+`TN_TONE` per captured frame, in the observation record, so a run that captured frames and asserted
+nothing still leaves the numbers behind for the next round.
+
+## What does not ship
+
+- No verdict. This measures; it does not decide that a frame is good. Pixel metrics never replace
+ semantic image review — the sculpt server's rule holds.
+- No colour statistics, no per-channel histograms, no saturation. Luminance only in v1.
+- No baseline comparison. `pnpm visuals` / `visuals:ab` already own A/B against a baseline; this
+ bounds a frame on its own terms, which is what a scenario with no baseline needs.
+
+## Acceptance criteria
+
+- [x] **A misexposed frame is red.** proof: `pnpm test:tone`. The example fixture renders a scene with `toneMappingExposure`
+ pinned two stops under; a `tone` assertion bounding `mean` and `p99` fails, and the failure text
+ names both the measured and the required value.
+ *Red-green:* the mutation **is** the pinned exposure — restore it and the same assertion passes.
+ Paste both. Verified: [run 36988379892](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892)
+ measured underexposed mean 89.55505316840278 / p99 136 versus required mean 140..210 /
+ p99 >=230; exactly four named/final bounds failed. Restored mean 170.49870985243055 /
+ p99 254 passed the same bounds. Both images and full failure text are retained below.
+- [x] **An empty bound set throws.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts`. A `tone` assertion with no bounds fails schema validation with a
+ named error before the run starts.
+ *Red-green:* soften the schema to allow it; `assertion-schema.spec.ts` goes red.
+ Verified in the current `tone.spec.ts` schema cases: temporarily removing the two empty-bound
+ guards caused three expected failures (`{}`, `{atStep: "landed"}`, `{mean: {}}`); restored
+ source passed. The built loader throws `TN_PLAYTEST_SCENARIO_INVALID` before execution.
+- [x] **A missing capture fails, it does not skip.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts packages/playtest/__tests__/tone-runner.spec.ts`. A `tone` assertion at a step that produced no
+ frame reports a failure naming the step.
+ *Red-green:* return `undefined` from the evaluator when the capture is absent; the fail-closed
+ spec goes red. Verified: replacing missing/invalid-capture handling with `return undefined`
+ caused all four missing-capture cases to fail. Restored source reports
+ `TN_PLAYTEST_TONE_UNOBSERVED` naming `landed`; removing observations from the real restored
+ report likewise names `rendered`. The registry fallback also remains fail-closed.
+- [x] **The CLI table matches the assertion.** proof: `pnpm exec vitest run packages/playtest/__tests__/tone.spec.ts`. `cli.js tone` on a frame and `assert.tone` on the same
+ frame report the same six numbers, to the printed precision.
+ *Red-green:* change the histogram bin count in one path only; the cross-check spec goes red.
+ Verified: temporarily selecting 128 bins only for the CLI made the cross-check fail
+ (p1 2.01 instead of 2.00; p50 126.50 instead of 127.00; clip/black 0.78% instead of 0.39%).
+ Restored built `cli.js tone` matched all six decoded and asserted values on all four hosted
+ PNGs, including the frame-average row, to printed precision.
+- [x] **It works where captures work.** proof: `pnpm test:tone` plus the frameless-lane exclusion test in `tone.spec.ts`. The delete-test lane rule applies — this needs a GPU lane, so
+ the scenario lives on `pnpm test:templates`, not on CI's frame-less template runner.
+ Verified: `test:templates` invokes `test:tone`; the tone-only exclusion regression passes,
+ and the dedicated hosted WebGPU lane produced inspected real captures at the source SHA above.
+
+## Out of scope
+
+Baseline diffing, perceptual metrics, and any automatic judgement of composition.
+
+
+## Reconstruction checkpoint — 2026-10-02
+
+The first executor was replaced before its implementation commit was published. This is new
+work from the surviving draft branch, with fresh tests; the lost local commit is not evidence for
+this branch. The histogram slice reuses the guard's existing PNG decode. Remaining phase boxes
+stay open until their implementation and proofs are rerun.
+
+## Required runtime screenshot proof
+
+The user explicitly requires screenshot proofs for every PR. Actual underexposed/restored WebGPU
+runtime captures, adapter provenance and source SHA must be inspected and linked before this PR
+leaves draft. Synthetic PNG unit-test inputs and pictures of test output do not satisfy this gate.
+
+
+Fresh schema/CLI checkpoint: 118 tests passed across six focused files; scoped playtest TypeScript
+passed. The shared evaluator now rejects missing/invalid captures and reports measured/bounded
+values. Offline CLI parity includes different-sized frames to prove equal per-frame averaging.
+Browser/device production of the observation remains the next open slice.
+
+
+Fresh capture-wiring checkpoint: 52 tests passed across histogram, tone, target-driver and runner
+orchestration. Scoped playtest TypeScript passed. All four target paths reuse the guard's decoded
+metrics; host observations survive report assembly. The driver tests are not runtime screenshots.
+
+
+Fresh screenshot-lane checkpoint: the maintained read-only `integration-tone` workflow builds this
+commit's public bridge, then uses the same headed WebGPU/canvas-capture approach as native
+conformance. The script requires both exposure verdicts, actual PNG files, nonempty adapter
+identity and WebGPU provenance; it never accepts a missing capture as proof. Local playtest and
+fixture typechecks pass; fixture/capture/primary-doc focused suite: 52 tests passed. Runtime
+screenshots must still be produced by the hosted run and inspected before promotion.
+
+Independent review and hosted retry checkpoint: the first hosted run (36986610377) built and
+passed 43 tests, then failed before capture because the source-imported runner's serialized
+callback referenced a tsx helper. The verifier now uses the built public runner and preserves
+attempt/failure diagnostics. Three driver regressions reproduced an earlier `after.png` capture
+incorrectly satisfying a final-frame assertion; newest matching final observations now win.
+The pixel-proof verifier separately rejects software-device-loss warnings and unrelated errors
+even in its expected-underexposed arm. Runtime screenshot qualification remains open until the
+corrected hosted run produces inspected evidence; none of these CPU checks substitutes for it.
+
+
+## Runtime screenshot evidence — 2026-10-02
+
+The dedicated [hosted run](https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892) passed at `0ea7e29a0ff18717d33b8bce3300ec0568fea842`. Both exact artifact images were visually inspected: identical sphere/cube/calibration-ramp geometry, visibly darker underexposed output, brighter restored output, and no blank or visibly corrupt frame. [Provenance and measured values](../../verification/prd341/provenance.json) retain source SHA, artifact digest, adapter and image hashes. Browser SwiftShader proves these pixel/statistic cases; native parity and hardware performance are not claimed. Required full CI is still pending.
+
+Underexposed (expected tone assertion failure):
+
+
+
+Restored (tone assertions pass):
+
+
+
+
+## Acceptance audit — 2026-10-02
+
+At exact source `0ea7e29a0ff18717d33b8bce3300ec0568fea842`, each mutation above was applied
+in an isolated worktree, observed failing for its intended contract, then removed.
+`node node_modules/vitest/vitest.mjs run --maxWorkers=1` over `capture.spec.ts`, `tone.spec.ts`,
+`tone-runner.spec.ts`, `generated-assertion-validators.spec.ts` (all under
+`packages/playtest/__tests__/`) and `scripts/__tests__/tone-capture-proof.spec.ts` passed
+56 tests after exact-source restoration. No mutation was published.
+
+The built offline CLI, histogram decode and six exact-bound assertions agreed on both named
+and final PNGs from each runtime arm:
+
+| Frame | mean | p1 | p50 | p99 | clip% | black% |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Underexposed (named and final) | 89.56 | 6.00 | 95.00 | 136.00 | 0.00 | 0.39 |
+| Restored (named and final) | 170.50 | 22.00 | 180.00 | 254.00 | 0.78 | 0.39 |
+| Four-frame average | 130.03 | 14.00 | 137.50 | 195.00 | 0.39 | 0.39 |
+
+The ordinary package build wrapper hit the local tsx IPC `EPERM` restriction. Its equivalent
+steps passed using `node --import tsx scripts/generate-assertion-validators.ts --check`,
+`pnpm --filter @threenative/playtest exec tsup --config tsup.config.ts`, and
+`pnpm --filter @threenative/playtest exec publint --strict`: 30 validators current, ESM and
+declarations built, strict package lint passed. This does not claim a full-root test pass.
+
+All original feature acceptance criteria are satisfied. Required full CI and final merge
+qualification remain pending; archiving the implemented PRD does not waive those gates.
+Earlier checkpoints above retain the state and blockers observed at each point in the work.
+
+Archive checks: `node --import tsx scripts/check-doc-links.ts` passed 2,398 links;
+`node --import tsx scripts/check-evidence-budget.ts` passed the unchanged byte caps; the citation
+scanner classifies both screenshots and provenance as `cited-by-done-prd`. The six-file prose
+regression lane passed 178 tests and hit the existing tsx IPC restriction in two evidence-budget
+subprocess cases (long evidence record and many small files); their `EPERM` exits are not passes.
+The real-tree budget regression passed, and required hosted CI retains responsibility for the
+restricted subprocess checks.
diff --git a/docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md b/docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md
similarity index 90%
rename from docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md
rename to docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md
index dcb025b0b5..e7bc3abeb6 100644
--- a/docs/PRDs/CI/PRD-379-native-release-follows-ci-completion.md
+++ b/docs/PRDs/done/PRD-379-native-release-follows-ci-completion.md
@@ -1,6 +1,6 @@
# PRD-379 — native-release follows CI completion instead of polling for it
-**Status:** PARTIAL (Phase 1 landed + verified locally; Phase 2 needs merge to main + observation)
+**Status:** DONE (Phase 2 observed on `main` 2026-09-25; recorded 2026-10-02)
**Complexity:** 3 → LOW; risk override: none (release-gate sensitivity is carried by fail-closed ACs, not extra process)
**Owner:** jonit-dev
**Depends on:** None (follows PRD-373, which owns the develop→main flow this plugs into)
@@ -30,7 +30,7 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so
- [x] AC-1 [local; actor: agent]: A main-push CI completion starts native-release via `workflow_run`, with no `push`-to-`main` trigger left on the evidence path — Evidence: `workflow_run: workflows: [CI], types: [completed], branches: [main]` in `.github/workflows/native-release.yml:10-13`; push block is tags-only; proven by `main evidence arrives via CI completion` spec + E1 green.
- [x] AC-2 [local; actor: agent]: No `gates` job polls or holds a runner beyond a short verdict (no `sleep 60` loop, no 150-min wait); it reads conclusion + head SHA from `github.event.workflow_run` and refuses anything but exact-candidate success — Evidence: `gates` timeout-minutes 5, `Require the triggering CI completion` step, `rg "sleep 60"` clean; proven by `the main prerequisite verdict reads the event` + 5 `runWorkflowRunGate` specs + E1 green.
- [x] AC-3 [local; actor: agent]: Tag publish path and PR proof-without-publication path behave exactly as before — Evidence: tag `push: tags` trigger, `validate-tag`/`publish`/`finalize` conditions untouched; `Require a green CI run for this commit` script byte-identical (single-shot, wait deleted); existing `runGate` accept/refuse specs unchanged + green. Host proof: native-release PR run 34673827042 (first push of PR #209) fully green — `gates`, all three desktop builds, `build-android`, `clean-consumer` success on the `pull_request` event with the new workflow file. (Second-push run 34673874789 hit a transient `fetch failed` on `libuv`/`quiche` dep downloads with identical code; assets verified reachable, failed jobs re-run.)
-- [ ] AC-4 [shared; actor: CI on merge to main]: After merge, one CI completion on `main` triggers exactly one native-release run whose `gates` job finishes in minutes with no polling step — Evidence: pending (run URLs).
+- [x] AC-4 [shared; actor: CI on merge to main]: After merge, one CI completion on `main` triggers exactly one native-release run whose `gates` job finishes in minutes with no polling step — Evidence: CI push run 36134527157 on `main` (`279adb2c`) completed twice (run_attempt 2); each completion triggered exactly one `workflow_run` native-release run: 36140281930 (attempt 1) and 36142531873 (attempt 2); `gates` in 36142531873 ran 39 s (13:41:18–13:41:57Z), and its steps contain no wait or poll.
- [x] AC-5 [local; actor: agent]: Main evidence runs no longer serialize behind superseded pushes in a shared `native-release-*` group — Evidence: `group: native-release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha }}-${{ github.ref }}` (per-SHA groups for evidence, per-ref as before otherwise); proven by extended concurrency spec + E1 green.
## Integration Ledger
@@ -67,7 +67,7 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so
#### Phase 2: Prove one real main completion flows through
-**Status:** NOT STARTED
+**Status:** DONE
**ACs:** AC-4
**Files:** none (observation only)
**Implementation:**
@@ -79,5 +79,5 @@ Risks: `workflow_run` always reads the workflow file from the default branch, so
**Checkpoint:** pending
- [x] Draft PR opened, `prd:` label applied from `pnpm prd:progress` — PR #209 (draft vs develop, `prd:50%`)
-- [ ] Post-merge CI completion triggered exactly one native-release run (URLs recorded)
-- [ ] Triggered run's `gates` finished in minutes with no polling step
+- [x] Post-merge CI completion triggered exactly one native-release run (URLs recorded) — CI push run 36134527157 on `main` (`279adb2c`) completed twice (run_attempt 2); each completion triggered exactly one `workflow_run` native-release run: 36140281930 (attempt 1) and 36142531873 (attempt 2).
+- [x] Triggered run's `gates` finished in minutes with no polling step — 39 s in 36142531873; steps run from "Require the triggering CI completion" to the end, with no wait or poll step.
diff --git a/docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md b/docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md
similarity index 60%
rename from docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md
rename to docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md
index 02baaba8a4..b14dbb0cb2 100644
--- a/docs/PRDs/PRD-469-icefield-snow-abstractions-and-starter-kit.md
+++ b/docs/PRDs/done/PRD-469-icefield-snow-abstractions-and-starter-kit.md
@@ -1,10 +1,10 @@
# PRD-469 — ICEFIELD snow abstractions and starter kit
-**Status:** PROPOSED
+**Status:** COMPLETE
**Complexity:** 9 (HIGH); risk override: none.
**Owner:** Engine implementation agent
**Depends on:** None
-**Progress:** 0/8 required boxes verified
+**Progress:** 8/8 required boxes verified
## Context
@@ -101,6 +101,9 @@ The eight countable criteria live in the owning phases below; this table maps pr
| AC-5 | local | The generated game demonstrates the same contact/surface contract on native Linux desktop |
| AC-6 / AC-7 / AC-8 | local | Packed starter works, MCP discovers the abstractions, and repository gates pass |
+- [x] Browser WebGPU lane: the packed kit's physics, footsteps, weather and touch scenarios pass on a real GPU adapter. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — exit 0, 7 scenarios, NVIDIA Turing (AC-3, AC-6)
+- [x] Native Linux desktop lane: the same snow-physics contract passes on the native host. proof: `pnpm exec threenative-playtest --scenario native-playtests/snow-physics.playtest.json --target desktop` in the packed `snow-proof` — 4/4 on the merged tree, 5/5 before the merge (AC-5)
+
Browser and native desktop behavior are required here. Android/iOS use the same portable source/backend seam and receive build checks where their SDKs are available; this PRD makes no mobile runtime claim without a target run. During implementation, record any unavailable required qualification under `Blocked on` with the actual attempted command and missing SDK/device. Build success cannot substitute for physics execution. Do not tick an unreachable proof or mark the PRD complete with a required platform result pending.
## Integration Ledger
@@ -119,47 +122,93 @@ Proposed file paths become actual entry-point references when their owning phase
- 2026-09-30 (João): extract the HTML's existing raw snow code into abstractions and include them in engine capability discovery; write the PRD now.
- 2026-09-30 (planning): physics acceptance explicitly includes spheres, not just character steps. Reuse the existing terrain, bodies, particles and scaffold mechanisms; the snow starter owns its look.
+- 2026-10-01 (implementation, measured): native snow surfaces are Rapier heightfields, not the planned canonical-sample trimesh. The trimesh adapter worked (web/native surfaces agreed within 1e-4 m) but rebuilding it on native took 101 ms at 321 samples and 179 ms at 401 (cargo micro-benchmark), so every footstep hitched the desktop host; a heightfield rebuild copies samples only (0.24 ms / 0.38 ms). Native now receives the same column-major samples the web backend hands Rapier, so both runtimes build one surface by construction.
+- 2026-10-01 (implementation, measured, withdrawn): powder takes no energy from what moves through it. Building the sandbox demo showed a 10 kg ball at rest on the kit's 7% glade slope rolls about 4 m off its tee, and a pushed ball is still moving at 0.7 m/s four seconds later. Three rolling-resistance models were added to `attachSnowPhysics` and measured on real Rapier (soft-ground `sqrt(sinkage/width)` drag at the centre, the same at the rolling lever, compaction work), plus a force-couple torque swept headless; none held on both backends (native desktop over-spun, roll ratio 5.9, reach 0.47, so the kit's native scenario failed 3/3), so the feature was reverted rather than shipped half-right. The binding deforms snow without taking energy; snow rolling resistance is open engine work. The attempt surfaced a real seam bug, now fixed on web and native with tests: `applyForceAtPoint`'s torque was never cleared, so it kept spinning a body up after its step.
+- 2026-10-01 (merge of develop PRD-470/471/472/474, root-caused and fixed): after the merge the kit's `production-performance` read frame p95 39.9–44 ms against its 33 ms ceiling. Develop's loop now leaves boot frames out of the window, which exposed an existing cost. The slow frames are the runner's `waitTicks` bursts, about 8–10 fixed steps per presented frame, and each step cost about 2.5–3 ms on web. Most of that was the explorer's `CharacterBody3D`: Rapier sweeps a character capsule against every collider triangle under it, so one capsule `castShape` costs 0.04 ms on a 51² heightfield and 0.63 ms on the kit's 401² (6 cm) snow. Squeezing 401² into 6 m costs 8 ms, and splitting it into 8×8 tiles changes nothing, so the cost is triangles under the sweep, not collider size. Disabling the explorer's movement alone took the physics step from about 1.3 to 0.4 ms. Fix, in the kit: the explorer collides with a coarse walk surface (ground plus fresh snow, 101², 24 cm; `walkSurface`/`WALK_SAMPLES` in `src/terrain.ts`), on its own collision layers that leave the snow collider to the ball, crate and log. Its drawn height and boots already came from the canonical snow, so nothing visible changes. Engine: the binding now compares only the window written since its last collider rebuild, through the new `Heightfield.trackChanges()`, instead of the whole field each step. Result, threshold unchanged: the scenario's p95 is 27.5 and 30.0 ms (27.8 on the first run); `TN_TEMPLATE_ONLY=snow pnpm test:templates` exit 0 with all 7 scenarios; native `snow-physics` 3/3 and `survives` pass.
+ - What a player gets, as distinct from the runner's tick bursts: a wall-clock run (`__THREENATIVE_PLAYTEST_CLOCK__ = "wall-clock"`, 10 s of walking at 1920×1080, NVIDIA Turing). Before: 130–195 fps, per-frame p95 14.2–17.1 ms, update p95 2.8–3.8 ms, 1,024-sample p95 16.7 ms. After: 158–248 fps, per-frame p95 12.8–14.3 ms, update p95 1.7–2.3 ms, 1,024-sample p95 15.2 ms. Both were already above 60 fps on this desktop; the step cost matters most on slower devices.
+ - Trade-off: the explorer's capsule no longer drops into its own prints or the ball's crater; it walks at fresh-snow level over a 24 cm surface. Its drawn body and boots never followed the capsule (they come from the canonical snow and the gait's IK), so the frame does not change. A re-capture of the walking kit (`snow-kit-webgpu-1440x900.jpg`, `snow-kit-webgpu-surface-view-1440x900.jpg`, refreshed) shows the same prints, and the boots plant in them. AC-3/AC-4 are untouched: the ball, crate and log still press the 6 cm snow through `attachSnowPhysics` (`snow-contacts.spec.ts` 14/14 unchanged), and footfalls still stamp it.
+ - `Heightfield.trackChanges()` stays because it is not a micro-win. On a kit-sized 401² field where the surface changes every step (a rolling ball), the binding cost 1.29–1.45 ms per step comparing the whole field and 0.89–0.91 ms comparing only the changed window, about 0.4 ms a step (node, real Rapier). Existing callers see no behaviour change: `updateHeights` only unions into live trackers, and `takeDirtyRegion` is untouched. Unit-tested in `world-heightfield.spec.ts`; the class's capability entry names it.
+- 2026-10-01 (implementation, unexplained engine issue): in the snow kit a bloom threshold of 0.92 left the native Linux desktop frame blank behind the HUD (Dawn/Vulkan, RTX 2080) while the web rendered it correctly; thresholds 0.2 and 0.7 rendered on native. Not root-caused. The kit ships with bloom off (its glints come from the snow material), recorded in `templates/snow/src/render/quality.ts`; the coordinator shared the finding with the rain lane, which also uses bloom natively.
+
+## Current state (this branch)
+
+Phases 1 and 2 are complete and verified. Phase 2 landed the persistent-contact and in-place
+collider-refresh operations on both backends (`readContacts`, `setColliderShape`; Rust
+`tn_physics_read_contacts`/`tn_physics_set_heightfield_shape`), `attachSnowPhysics` with
+`observe()`/`loadOf()`, and the generated snow game's `snow-physics.playtest.json` passing on
+browser WebGPU and on the native Linux desktop host. Phase 3's kit passes all seven packed web
+scenarios, capability discovery is verified through the shipped MCP server, and the repository
+gates pass on the final branch. The rain kit (PRD-473) is merged into this branch.
+
+Visual record, `docs/verification/PRD-469/` (JPEG, about 0.7 MB for the kit set): the kit at
+`snow-kit-webgpu-1440x900.jpg`, `-1024x768.jpg`, `-390x844-touch.jpg`, `-blizzard-1440x900.jpg`
+and `-surface-view-1440x900.jpg`; physics proof at
+`physics-sphere-track-box-capsule-dents-webgpu.jpg`, `physics-compaction-view-webgpu.jpg` and
+`physics-sphere-track-native-desktop-1280x720.jpg`; the supplied ICEFIELD document at the same
+three viewports as `reference-icefield-*.jpg`.
+
+## Sandbox demo
+
+**Snow Putt**, a small game built cold on the kit: `sandbox/snow-demo` in the shared sandbox
+repository, commit `08b84cb1ee55691a242aadee463123aa034dc295` (local, not pushed), scaffolded with
+`--template snow` from this branch's packed tarballs. Push the ball into a cup pressed into the
+`SnowField`, within six pushes, before a 90 s clock runs out; the cup and the tee lie are
+`snow.stamp` calls, the ball's roll and crater come from `attachSnowPhysics`.
+
+| Scenario | Target | Result |
+| --- | --- | --- |
+| `playtests/putt-win` | browser WebGPU (NVIDIA Turing) | pass, holed in 2, identical across 3 runs |
+| `playtests/putt-lose` | browser WebGPU | pass: clock runs out (lost, reason time), R opens a fresh round |
+| `survives`, `footsteps`, `weather`, `touch-controls`, `performance` | browser WebGPU | pass |
+| `native-playtests/putt-win` | native Linux desktop | pass 6/6 (holed in 3–5) |
+| `native-playtests/survives` | native Linux desktop | pass |
+
+Friction (the demo's `FRICTION.md`): powder takes no energy from a rolling ball (open engine work,
+see Decisions); `applyForceAtPoint` left its torque behind (fixed, `f2812dd60`); native runs of one
+build vary; the kit's own ball leaves its tee at load. Captures:
+`sandbox-snow-putt-won-webgpu-1280x720.jpg`, `sandbox-snow-putt-mobile-webgpu-390x844.jpg`,
+`sandbox-snow-putt-won-native-desktop-1280x720.jpg` (0.19 MB).
## Execution Phases
### Phase 1: Source-derived numerical snow abstractions
-**Status:** NOT STARTED
+**Status:** COMPLETE
**Files:** proposed `packages/core/src/snow-field.ts`; existing `packages/core/src/world.ts` and `world-package.ts`; focused `packages/core/__tests__/snow-field.spec.ts`; `docs/architecture/CHARTER.md` and core ownership instructions.
**Implementation:** Recover/type the source's state, sampling, deformation and sparse recovery. Add the smallest canonical heightfield mutation path. Separate boot geometry, movement tuning, texture encoding and weather appearance from numerical response. Preserve a minimal source-derived regression fixture. Add public export metadata with compilable examples; do not invent a new GPU solver.
-- [ ] AC-1 [local, actor: implementation agent]: Public `SnowField` reproduces the source's footprint-state and deposition behavior, including reset, depth changes and invalid-input rejection. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts packages/core/__tests__/world-heightfield.spec.ts` — Evidence: pending.
-- [ ] AC-2 [local, actor: implementation agent]: Generalized pressure/profile input produces distinct boot and circular imprints with area-dependent response and presentation-rate-independent integration. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts -t "contact profiles"` — Evidence: pending.
+- [x] AC-1 [local, actor: implementation agent]: Public `SnowField` reproduces the source's footprint-state and deposition behavior, including reset, depth changes and invalid-input rejection. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts packages/core/__tests__/world-heightfield.spec.ts` — Evidence: 32/32 pass (18 snow-field, 14 world-heightfield). `packages/core/__tests__/fixtures/snow-field-source.json` was produced by evaluating the supplied document's own `SnowField` (lines 85–193, sha256 `85b1708…02f7`) over one deterministic scenario; the public `SnowField` replays it to within 1e-9 on all four channels at 672 sample points with an identical active-cell count at every stage (995 stamps → 875 recovered → 875 depth-changed → 0 reset). Includes penetration equality per contact, reset/depth behaviour, canonical-surface composition, render/collider agreement < 0.001 m, and rejection of malformed area/load/duration/extent/depth.
+- [x] AC-2 [local, actor: implementation agent]: Generalized pressure/profile input produces distinct boot and circular imprints with area-dependent response and presentation-rate-independent integration. proof: `pnpm exec vitest run packages/core/__tests__/snow-field.spec.ts -t "contact profiles"` — Evidence: 5/5 pass. A source boot print and a `snowDiscFootprint` of equal area leave measurably different imprints; a `π/2` rotation transposes the boot's long axis; the same load concentrated over 0.02 m² digs > 0.05 m deeper than over 0.4 m² and > 0.02 m deeper than an eighth of the load; one 0.3 s contact equals eighteen 1/60 s contacts to 1e-6; a rotated box footprint leaves a rectangular print; and a rolling sphere leaves a connected track rather than one repeated hole.
**Verification:** Tests invoke the exported abstraction, compare source-derived expectations, and assert dimensional/edge handling and canonical-surface invariants. Write test-first missing-behavior assertions during implementation, not artificial failures during this planning task.
### Phase 2: Physical bodies deform and collide with the same snow
-**Status:** NOT STARTED
+**Status:** COMPLETE
**Files:** proposed `packages/physics/src/snow.ts`; existing physics exports, `simulation.ts`, `plugin.ts`, native `host.ts` and relevant runtime-native physics bridge/Rust implementation if the missing bulk operations require them; `packages/physics/__tests__/snow-contacts.spec.ts`; shared snow scene and `snow-physics.playtest.json` fixtures introduced for the starter.
**Implementation:** Wire solved supported contacts to `SnowField`; add minimal persistent-contact and dirty-collider refresh operations at the shared backend seam. Reuse native trimesh support first. Preserve ordering, collision filters, body identity, wakeup and cleanup. Exercise real Rapier with source-derived material values; do not mock the solver for acceptance. Include an airborne interval, drop, rest, lateral push, loaded recovery and a field reset.
-- [ ] AC-3 [local, actor: implementation agent]: A real sphere falls, settles within the stated surface tolerance and rotates through a continuous circular snow track; airborne intervals do not stamp. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t sphere` plus the browser `snow-physics.playtest.json` included in AC-6's generated-project run — Evidence: pending.
-- [ ] AC-4 [local, actor: implementation agent]: Box/capsule support uses shape-appropriate contacts and survives recovery/reset without stale colliders or cross-field deformation. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t "box|capsule|lifecycle"` — Evidence: pending.
-- [ ] AC-5 [local, actor: implementation agent]: The generated snow game meets the sphere/contact and canonical-surface assertions on the native Linux desktop host. proof: `pnpm build:desktop && pnpm exec threenative-playtest --scenario playtests/snow-physics.playtest.json --target desktop --executable dist-native/snow-proof` from the packed generated project named `snow-proof` — Evidence: pending.
+- [x] AC-3 [local, actor: implementation agent]: A real sphere falls, settles within the stated surface tolerance and rotates through a continuous circular snow track; airborne intervals do not stamp. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t sphere` plus the browser `snow-physics.playtest.json` included in AC-6's generated-project run — Evidence: 6/6 sphere tests pass against real Rapier 0.19.3 (no solver mock): 0 loads during 15 airborne steps, settled underside within 0.02 m of the surface it pressed, push rolls ≥ 1 m with turn×r/distance in 0.8–1.2 and every sample along the path pressed (connected, circular, nothing beyond 1.6 r), collider within 0.001 m of canonical after every step and ≤ 1 step behind a game-side reset, triangle interiors within 0.01 m by ray cast, a sleeping sphere re-seats after `setDepth`, 30/60/120 Hz presentation within 0.001 m, a sideways shove digs no pit. Browser: `TN_TEMPLATE_ONLY=snow pnpm test:templates` exit 0 (2026-10-01, NVIDIA Turing adapter on every run) — `snow-physics` passes: airborne at `falling`, settled at `settled`, 0 airborne loads, reach ≥ 1 m, roll ratio in band, 0 track gaps, `colliderError`/`renderError` ≤ 0.001, `loadProvenance` `solver-impulse-per-step`.
+- [x] AC-4 [local, actor: implementation agent]: Box/capsule support uses shape-appropriate contacts and survives recovery/reset without stale colliders or cross-field deformation. proof: `pnpm exec vitest run packages/physics/__tests__/snow-contacts.spec.ts -t "box|capsule|lifecycle"` — Evidence: 8/8 pass. A yawed box presses its own rotated face (1.0 m side along z, untouched 0.4 m off its 0.25 m half-width); a fallen capsule presses a trough along its spine and an upright one a disc; x- and z-sloped fields hold a box at surface + half-height/cos within 0.03 m (a transposed collider is > 0.09 m off); recovery and `reset` keep the same collider identity with the sphere re-seated within 0.02 m; two fields in one world route contacts only to the touched field (the other: 0 steps, 0 active cells, 0 contacts); scenery-supported bodies press nothing and a removed body stops stamping; unsupported shapes throw naming sphere, box, capsule.
+- [x] AC-5 [local, actor: implementation agent]: The generated snow game meets the sphere/contact and canonical-surface assertions on the native Linux desktop host. proof: `pnpm build:desktop && pnpm exec threenative-playtest --scenario playtests/snow-physics.playtest.json --target desktop --executable dist-native/snow-proof` from the packed generated project named `snow-proof` — Evidence: exit 0 on 5/5 consecutive runs (2026-10-01, after the native heightfield change; 3/3 before it) from a `snow-proof` scaffolded outside the workspace from packed tarballs; `runtime: native`, NVIDIA RTX 2080 via Vulkan; `native-playtests/survives.playtest.json` exit 0 on the same build. The packed runtime ships no prebuilt host, so `THREENATIVE_RUNTIME_BINARY` pointed `build:desktop` at this branch's `pnpm native:build` output (exit 0). Below the game: `cargo test --release` in `runtime-native/native/physics` green including `tests/snow_contacts.rs` (heightfield create/refresh, airborne → 0 records, resting load within 2× of m·g, sleeping → 0, a lowered surface wakes and re-seats the ball, malformed refreshes refused); `native-contract.spec.ts` 23/23 (heightfields cross as their own column-major samples; old runtimes fail closed). Native frame: `docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg`.
**Verification:** Use the same physics scenario/telemetry on browser and desktop, with captured nonblank frames and solver-derived poses. Report canonical/render/collider versions, maximum surface error, load provenance and supported-contact counts through the existing registry/state observation bridge. A changed snow surface alone does not prove physical support; assert both deformation and the sphere's actual solved height/rotation. Native behavior proof lands with its portable seam changes.
### Phase 3: Discoverable, packed snow starter kit
-**Status:** NOT STARTED
+**Status:** COMPLETE
**Files:** `packages/create-threenative/templates/snow/` with portable scene/entities, editable `src/render/`, controls, instructions and playtests; existing scaffold/playtest/look/convention tests and `scripts/visual-gate.ts` template inventory; manifest/reference generators, existing MCP search/server tests and recall corpus. Generated mirrors/manifests follow their generators.
**Implementation:** Recover the supplied scene/effects into the smallest useful kit, reusing the new public snow APIs rather than copying their implementation back into the template. Install local tarballs through the existing template harness. Extend capability situations and constraints, synchronize instructions/MCP files, and inspect browser/native captures against the supplied snow appearance. Keep weather transitions and sound/game feel editable in source.
-- [ ] AC-6 [local, actor: implementation agent]: A packed `--template snow` game boots and plays on browser WebGPU with source-derived footsteps/weather/powder controls and the sphere physics scenario. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — Evidence: pending; extend the existing harness to include the kit's nonempty snow scenarios and inspect its captures.
-- [ ] AC-7 [local, actor: implementation agent]: Snow mechanic queries discover the public field/binding through actual MCP search/detail, and returned examples compile against packed exports. proof: `pnpm build && pnpm capabilities:check && pnpm caps:recall` and `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts packages/engine-mcp/__tests__/server.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` — Evidence: pending; exercise the shipped manifest from the generated project as well.
-- [ ] AC-8 [local, actor: implementation agent]: The completed implementation passes repository checks without changing unrelated games or weakening native/assertion guards. proof: `pnpm typecheck && pnpm lint && pnpm test && pnpm budgets && pnpm check:docs` — Evidence: pending.
+- [x] AC-6 [local, actor: implementation agent]: A packed `--template snow` game boots and plays on browser WebGPU with source-derived footsteps/weather/powder controls and the sphere physics scenario. proof: `TN_TEMPLATE_ONLY=snow pnpm test:templates` — Evidence: exit 0 (2026-10-01, on the rain + snow merge; the kit is byte-identical to the final branch), NVIDIA Turing adapter on every scenario: `snow-real-frame-boot`, `survives`, `snow-footsteps`, `snow-weather`, `snow-touch-controls`, `snow-physics` and `production-performance` all pass. Native re-run on the merged tree: `native-playtests/snow-physics` 4/4 and `survives` exit 0 from a freshly packed `snow-proof`. Captures inspected: see the visual record above.
+- [x] AC-7 [local, actor: implementation agent]: Snow mechanic queries discover the public field/binding through actual MCP search/detail, and returned examples compile against packed exports. proof: `pnpm build && pnpm capabilities:check && pnpm caps:recall` and `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts packages/engine-mcp/__tests__/server.spec.ts packages/engine-mcp/__tests__/capability-examples.spec.ts` — Evidence: `pnpm build` 0; `pnpm capabilities:check` fresh (374 entries, 362/362 package-backed entries resolvable from scaffolds); `pnpm caps:recall` 0 (83 rows, recall 0.916, 9 snow rows); engine-mcp search/server/capability-examples 71/71. In the packed `snow-proof`, the shipped server (`node_modules/@threenative/core/mcp/engine.mjs` over stdio, as its `.mcp.json` launches it) answers "leave footprints in deep snow that stay where the player walked" with `attachSnowPhysics`, `SnowField` first and "drop a heavy ball into powder snow so it sinks and carves a track" with `attachSnowPhysics`, `SnowField`, `GPUParticles3D`, `snowDiscFootprint`; `engine_capability_detail` returns both with their `@threenative/core/world` / `@threenative/physics` imports, and the kit that calls both type-checks and builds against the packed exports.
+- [x] AC-8 [local, actor: implementation agent]: The completed implementation passes repository checks without changing unrelated games or weakening native/assertion guards. proof: `pnpm typecheck && pnpm lint && pnpm test && pnpm budgets && pnpm check:docs` — Evidence: all exit 0 on `de581effa` (2026-10-01, rain + snow merged). `pnpm test`: root 526 files / 6,567 tests passed (9 skipped), runtime-native 132 files / 1,558 tests; `pnpm budgets` includes the 72 MB evidence cap (docs/verification 67.4 MB before this PRD's 0.77 MB of captures) and the regenerated native coverage report; `pnpm sync:agents --check` and the MCP config check are clean. No other template's scaffold hash moved; no assertion or native guard was relaxed (the powder-resistance attempt that would have widened the kit's roll-ratio band was reverted, see Decisions).
**Verification:** Before runtime claims, scaffold `snow-proof` with the existing local-package overrides and run its browser/native scripts. Check source-independent installation and default-exported native game entry. Run `pnpm sync:agents` and `pnpm sync:mcp` for changed generated instructions/configs. Keep routine results beside these boxes; create no separate verification report. Run `pnpm prd:progress` after each phase; finish the implementation in one PR targeting `develop`, and move this PRD to `done/` only when every required result is verified.
diff --git a/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md b/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md
new file mode 100644
index 0000000000..0f8a7840fe
--- /dev/null
+++ b/docs/PRDs/done/PRD-476-fluid-lab-particle-water-at-60-fps.md
@@ -0,0 +1,242 @@
+# PRD-476 — Fluid Lab: particle water on the GPU at 60 fps
+
+**Status:** DONE
+**Complexity:** 9 (HIGH); risk override: none.
+**Owner:** Engine implementation agent
+**Depends on:** None
+**Progress:** 8/8 required boxes verified (the two frame-time boxes carry both frame cost and presented fps; see Presented fps)
+
+## Context
+
+Absorb `/home/joao/Downloads/fluid-lab-v2.html` ("Fluid Lab 02", 238,714 bytes, SHA-256
+`e389fe7f5198c3ce4365b343281c1e34317c55082514741815e9217ad14f4ef7`) as engine capabilities, remove
+the frame-rate wall that holds it near 30 fps, and rebuild it as a sandbox game on those
+capabilities. Phases 1-3 are implemented on PR #389; the two frame-time boxes carry measured frame cost and presented fps on the real display.
+
+The file is a raw Three.js 0.180 (WebGL2) + Rapier 0.19 lab with eight experiments: dam break,
+splash tank, buoyancy, waterfall, fountain, whirlpool, viscosity, and ocean & rain. Its readable
+source is embedded as a JSON map at `fluid-lab-v2.html:69` (`window.FLUID_LAB_SOURCES`); lines
+70–1280 are the bundled module. The Downloads file is an input, never a runtime dependency.
+
+### What the source does, and where it goes
+
+| Source (`FLUID_LAB_SOURCES` path) | Behaviour | Destination |
+| --- | --- | --- |
+| `src/physics/fluid.js` | CPU Position Based Fluids: uniform-grid neighbours, density constraints, XSPH viscosity, cohesion, vorticity confinement, box/sphere collider projection, emit/drain/stir | **New** `FluidParticles3D` in `packages/core`, on GPU compute |
+| `src/render/surface.js` | CPU density lattice + surface nets mesh, gradient normals, per frame | **New** density volume on `FluidParticles3D` (data); the surface look is game `src/render/` |
+| `src/physics/coupling.js` | Rapier bodies, sampled Archimedes lift and drag, particle→body reaction impulses | Existing `Buoyancy3D` (`@threenative/physics`) on the fluid's height source; colliders feed the solver |
+| `src/physics/waves.js` | Damped heightfield wave equation, rain/pointer impulses | Existing `RippleField` (+ `WaveField` swell) — no new engine code |
+| `src/physics/secondary.js` | Spray droplets, crown sheets, advected foam, bubbles | Existing `GPUParticles3D` with a game process function; foam and crown shading in game `src/render/` |
+| `src/render/shaders.js`, `water-renderer.js`, `stage.js` | Refraction, absorption, Fresnel, exit-depth pass, debug views | Game `src/render/` (rule (b): it decides the look) |
+| `src/simulation.js`, `presets.js`, `ui.js`, `main.js` | Eight scenes, actions, inspector, fixed-step loop | Sandbox game scenes, HUD, and the engine's fixed-step dispatch |
+
+### Why it runs at ~30 fps (measured)
+
+`src/physics/fluid.js` and `src/render/surface.js` are dependency-free, so they were timed directly
+in Node 20.19.6 on this machine (splash-tank fill, 60 steps after a 30-step warm-up):
+
+| Quality | Particles | `FluidSolver.step` | `FluidSurface.update` |
+| --- | --- | --- | --- |
+| Light | 760 | 14.3 ms | 4.2 ms |
+| Balanced (default) | 1,625 | 29.9 ms | 6.6 ms |
+| High | 2,175 | 40.9 ms | 9.6 ms |
+
+At the default quality the CPU solver alone is 1.8× the 16.7 ms frame, before the surface rebuild
+and four scene renders per frame (`water-renderer.js:118–123`: scene target, back faces, composite,
+quad). `main.js` runs up to three 1/60 s steps per frame and caps the accumulator at three steps,
+so when a step outruns the frame the display drops to the 30 fps vsync bucket and the simulation
+falls into slow motion. Light quality still misses the budget once rendering is added. Making the
+CPU code a few times faster would not leave room for rendering on a phone; the neighbour search
+and constraint passes are data-parallel work that belongs on the GPU.
+
+### Existing mechanisms reused
+
+Capability search and detail were run for the full request and for each mechanic.
+
+- `FluidField2D` (`packages/core/src/fluid-field.ts`, 748 lines; PRD-249): the precedent shape —
+ TSL compute passes, `IComputeDriven`, `ctx.add` lifetime, numeric samplers, game-owned look,
+ conformance case `77-fluid-field`, example `examples/prd249-fluid-field` with web and desktop
+ playtests. `FluidParticles3D` copies this shape; it does not replace the 2D field.
+- `ComputeDrivenRegistry` / `IComputeDriven` (`packages/core/src/compute-driven.ts`): pass order,
+ warm-up, release.
+- `GPUReadback`: throttled GPU→CPU copy with `staleFrames`, for stats and the height source.
+- `Buoyancy3D` (`packages/physics/src/Buoyancy3D.ts`): hull points, density, drag, any height
+ source. Covers the lab's buoyancy and splash-slowdown.
+- `RippleField`, `WaveField`: the ocean scene, unchanged.
+- `GPUParticles3D`: spray and bubbles. A search for "splash spray droplets with lifetime and
+ gravity" returned verdict `none`; this is a manifest recall gap, fixed in Phase 3.
+- `WaterSurface3D` is a level-plane refraction/reflection helper; it does not fit a free-form
+ particle surface and is not used here.
+
+No 3D particle fluid, SPH/PBF solver, or isosurface extraction exists in `packages/`.
+
+Complexity: 2 for 6–10 implementation files, 2 for a new system, 2 for GPU pass ordering and
+cross-frame readback state, 2 for crossing the native host and the sandbox tarball boundary,
+1 for none of the rest → 9.
+
+## Solution
+
+```mermaid
+flowchart LR
+ G[game scene] -- fill / emit / drain / stir / force Fn --> F[FluidParticles3D]
+ R[Rapier bodies] -- collider transforms each fixed step --> F
+ F -- positions, velocities storage --> SP[GPUParticles3D spray process Fn]
+ F -- density volume --> M[game src/render water material]
+ F -- column heights via GPUReadback --> B[Buoyancy3D]
+ B -- forces --> R
+ F -- stats via GPUReadback --> H[HUD]
+```
+
+**`FluidParticles3D`** (`packages/core/src/fluid-particles.ts`, exported from the core index with
+`@situation` tags) is a GPU Position Based Fluids solver that draws nothing:
+
+- Options: `capacity`, `spacing`, `bounds`, `iterations`, `viscosity`, `cohesion`, `vorticity`,
+ `gravity`, plus an optional game TSL `force` function (the whirlpool's tangential drive). Every
+ option has a default measured from the source's balanced preset; invalid values throw.
+- Per fixed step, as ordered compute passes: predict, grid hash + counting sort, density/λ,
+ position delta (repeat `iterations`), collider projection, velocity update with XSPH viscosity,
+ cohesion and vorticity confinement.
+- Game API: `fill(min, max, velocity)`, `emit(position, velocity)`, `drain(region)`,
+ `stir(point, strength, radius)`, `setColliders([...])` (spheres and oriented boxes, updated each
+ step from Rapier transforms; static and dynamic).
+- Data out: `positions` / `velocities` storage nodes; a `density` 3D texture splatted each step;
+ `heightAt(x, z)` from a column-height grid copied through `GPUReadback` (reports `staleFrames`);
+ `stats` (`count`, `meanCompression`, `maxSpeed`) through the same throttled readback.
+- Fails closed: on a renderer without compute (WebGL2 backend) `attachRenderer` throws with a
+ named error rather than drawing nothing (the constructor has no renderer to ask).
+
+**The look stays in the game.** The sandbox game's `src/render/` raymarches the density volume
+(refraction, absorption, Fresnel, debug views ported from `shaders.js`) and owns spray, foam and
+crown shading. A game can replace all of it without touching `packages/`.
+
+**The sandbox game** `../sandbox/fluid-lab/` is scaffolded and installed from tarballs like a
+user's, with all eight experiments as scenes, the lab's actions and keyboard, a HUD with the four
+metrics, and a `FRICTION.md` logged as it is built.
+
+**Risks.** 3D storage textures and atomics in TSL on the native Dawn host (proved by the
+conformance case before the sandbox depends on them); readback lag making floating bodies bob
+(`Buoyancy3D` already runs on fixed steps; `staleFrames` is reported, not hidden); particle
+tunnelling at the lab's 18 m/s velocity clamp (keep the clamp as an option).
+
+## Acceptance Criteria
+
+Phase boxes below are the acceptance criteria; each names its proof.
+
+- [x] Presented 60 fps on the real display: the sandbox Splash Tank (High) and the example's 6,000-particle stress scene read p50/p95/p99 16.7/16.8/16.8 ms against a 59.97 fps empty-page control. proof: the `Presented fps` section (rAF deltas on `:0`, vsync on, 30 s per scene)
+- [x] The water reads as water, not jelly or plastic: the raymarch is the example's default and the solver defaults are pinned. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts` (12/12, incl. "defaults to water-like damping, not a jelly") and the PR screenshots
+- [x] The gates pass on the final defaults. proof: `pnpm typecheck`, `pnpm lint` (0 errors), the example dam-break and coupling playtests, the lab playtest, and the desktop `fluid-particles` conformance case
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| GPU particle fluid | `ctx.add(new FluidParticles3D(...))` in `examples/prd476-fluid-particles/src/game.ts` and the sandbox scenes | New; the lab's CPU `FluidSolver` is not ported | Phase 1 boxes |
+| Fluid → body buoyancy | `new Buoyancy3D({ surface: fluid, ... })` in sandbox buoyancy/splash scenes | Replaces `coupling.js` sampling and reaction impulses | Phase 2 box 1 |
+| Native host | Conformance registry case on the desktop host | New case beside `77-fluid-field` | Phase 2 box 2 |
+| Discovery | `engine_search_capabilities` → manifest built by `pnpm build` | Adds `@situation` tags; no hand-edited JSON | Phase 3 box 1 |
+
+## Presented fps
+
+Measured 2026-10-02 on the real display (`:0`, HDMI-A-1 59.96 Hz, `nvidia/turing`, RTX 2080) after the owner approved one window on it: Chromium via Playwright, vsync on, 1280x720, `requestAnimationFrame` deltas over 30 s (about 1,800 frames) after a 12 s settle, an empty-page control first in the same window (59.97 fps, p50/p95/p99 16.7/16.8/16.8 ms, so the lane is valid), frame timestamps only, no screenshots of the desktop. Final code (core defaults 8e-3 / 0.03 / 0.015, raymarch through the glass), host load 4-18 from other sessions:
+
+| scene | p50 / p95 / p99 ms | mean fps | worst frame |
+|---|---|---|---|
+| control (empty page) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms |
+| sandbox Splash Tank, High (4,896 particles) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms |
+| sandbox Waterfall, High | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms |
+| sandbox Whirlpool | 16.7 / 16.8 / 16.8 | 59.93 | 33.4 ms (1 frame) |
+| sandbox Ocean and raft | 16.7 / 16.8 / 16.8 | 59.93 | 33.4 ms (1 frame) |
+| example stress (6,000 particles) | 16.7 / 16.8 / 16.8 | 59.97 | 16.8 ms |
+| example dam break | 16.7 / 16.8 / 16.8 | 59.70 | 150 ms (1 frame, load 18) |
+
+Headroom with the final shader and defaults (uncapped `requestAnimationFrame` intervals, vsync off, private Xvfb, host load 6-9, about 13,000 frames per scene): example 6,000-particle stress p50 1.0 / p95 4.8 / p99 7.8 ms and 6.9 ms on the rerun; sandbox Splash Tank High p50 1.0-1.1 / p95 4.6-4.7 / p99 8.8-9.1 ms; sandbox Waterfall High p50 1.0-1.1 / p95 4.9-5.0 / p99 8.7-10.3 ms. Mean 1.4-1.6 ms, so about 10x under the 16.7 ms budget with the through-the-glass raymarch included.
+
+Native: the `fluid-particles-desktop` playtest bundles the example, which now defaults to the raymarch, and passes on the native host (770 frames); it asserts state, not pixels. The `fluid-particles` conformance case renders its own scene through `conformance.js` and still matches the web capture (pixel mismatch 0, ΔE 0) with the new defaults.
+
+Known limits of the raymarch look (fresh-eyes review, not fixed): a ray that starts under water ends at the first sparse voxel (a spray gap) and shows the underside colour, so a splash seen through the glass can show holes; the sample clamp reads the lowest 0.26 m (1.2 spacings) as full-density, so a film thinner than that can vanish; rays that end on a side or back wall show a flat water colour. The surface seen from above is unaffected.
+
+An earlier pass with the previous defaults and shader gave the same p50/p95/p99 on Splash High, Dam, Ocean and the 6,000-particle stress scene (one 100 ms hitch in a first Splash High run, none on the rerun). Read the 16.8 ms p95 as the display period (59.96 Hz is 16.68 ms) plus rAF timestamp resolution of about 0.1 ms, not as a missed frame: every counted frame landed on a vsync, and the only misses are the three single-frame hitches below. Presented fps is display-locked at 60 Hz, so this proves the frame budget holds, not headroom beyond it; headroom is the frame cost above (about 1 ms GPU per frame). Single isolated hitches (33-150 ms) appeared three times in about 12,600 frames while other sessions loaded the host; not attributed to the solver.
+
+## Decisions
+
+- 2026-10-01 (planning agent, proposed; owner may overturn): GPU compute, not an optimised CPU
+ port. Measured 29.9 ms per balanced step on a desktop CPU; even a 3× gain leaves no frame budget
+ on a phone, and the charter makes a backend the game cannot write portably the engine's job.
+- 2026-10-01 (planning agent, proposed): a density volume the game raymarches, not GPU surface
+ nets. Surface nets need stream compaction and indirect draws on both hosts; the volume is data,
+ which keeps the look in `src/render/` like `FluidField2D`'s dye.
+- 2026-10-01 (planning agent, proposed): no particle→body reaction impulses. `Buoyancy3D` lift
+ and drag already slow and float bodies; add impulses when a scene shows a body passing through
+ water without slowing.
+- 2026-10-01 (planning agent, proposed): name `FluidParticles3D`, pairing `FluidField2D` and
+ `GPUParticles3D`; Godot has no fluid node to borrow.
+- 2026-10-01 (implementation agent): floating bodies carry one centre hull point (heave only).
+ Four to eight points fed splash noise into torque and tumbled the box (ejected to 27 m) because
+ Rapier angular damping is not on the body options; `sample` box-filters open columns for the
+ same reason.
+- 2026-10-01 (implementation agent): the lab's HUD is a DOM readout, not scene-drawn; spray is a
+ `GPUParticles3D` that borrows fast fluid particles' state; the volume raymarch is game source.
+- Out of scope: Android/iOS frame-rate claims (no result here claims a platform it did not run
+ on); a follow-up PRD takes the Pixel 8 measurement once Phase 3 lands.
+
+## Execution Phases
+
+#### Phase 1: `FluidParticles3D` runs a dam break on the GPU at 60 fps
+
+**Status:** DONE (3 of 3 boxes; frame cost and presented fps measured)
+**Files:** `packages/core/src/fluid-particles.ts` (new), `packages/core/src/index.ts` (export +
+JSDoc tags), `packages/core/__tests__/fluid-particles.spec.ts` (new),
+`examples/prd476-fluid-particles/` (new, copied from the `prd249-fluid-field` layout: `game.ts`,
+`main.ts`, `render/`, `conformance.js`, `playtests/`).
+**Implementation:** Port the `fluid.js` algorithm pass for pass into TSL compute behind
+`IComputeDriven`; keep the source's kernel constants and parameter defaults so behaviour can be
+compared. The example renders particles as points from `positions` (debug look only) and exposes
+`GameState` with `stats`, `steps`, and a front-position probe.
+
+- [x] Option validation fails closed, passes dispatch in the documented order, `fill`/`emit`/ `drain` respect capacity, and scene removal releases buffers. proof: `pnpm exec vitest run packages/core/__tests__/fluid-particles.spec.ts` — 9/9 pass
+- [x] A released dam-break column runs across the tank and settles with mean compression ≤ 0.05 and every particle inside `bounds` (GameState resource assertions). proof: `node packages/playtest/dist/runner/cli.js examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json --url http://127.0.0.1:5173 --server-command "pnpm --filter prd476-fluid-particles dev --host 127.0.0.1" --browser-recipe webgpu --headed` — pass on `nvidia/turing`: 1,638 particles, peak front x 2.9 m, final mean compression 0.0012, max speed 0.047 m/s, inBounds 1, 0 console errors (headless Chromium reports no adapter here, so `--headed` on the private Xvfb is required)
+- [x] With 6,000 particles (the source's High capacity) the splash tank holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter (`adapter.info` recorded, not SwiftShader). proof: `measure-steady-state-fps` skill lane against the example
+ Frame cost on a hardware adapter, plus presented fps on `:0` (see Presented fps: p95 16.8 ms, 59.97 fps): GPU timestamp queries (`?bench=1&stress=1`, `window.__fluidGpuMs(30)`, `nvidia/turing`) put one solver step at 0.30–0.32 ms of GPU time at 6,000 particles; uncapped frame deltas over 6,213 frames are p50 1.2 / p95 7.4 / p99 15 ms with the host at load average ~50 (a no-fluid control showed p99 14–16 ms). The private Xvfb cannot present a trivial WebGPU page above 17–20 fps, so presented fps was taken on `:0`.
+
+**Verification:** run the three proofs above; record particle count, p95 frame time and adapter
+beside the third box.
+
+#### Phase 2: bodies splash and float, and the native host runs the solver
+
+**Status:** DONE (2 of 2 boxes)
+**Files:** `packages/core/src/fluid-particles.ts` (colliders, column heights, `heightAt`),
+`examples/prd476-fluid-particles/src/game.ts` (a sphere and two boxes),
+`packages/runtime-native/conformance/registry.json` (new case),
+`examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json` (new).
+**Implementation:** `setColliders` uploads sphere/box transforms from Rapier each fixed step and
+projects particles out of them; the column-height grid feeds `heightAt` through `GPUReadback`;
+`Buoyancy3D` consumes it unchanged. Register a conformance case next to `77-fluid-field` with
+`desktopGate: true`, using the example's `conformance.js`.
+
+- [x] A 1,900 kg/m³ sphere dropped into the tank displaces the surface and comes to rest on the floor, while a 550 kg/m³ box ends floating with its centre within 0.2 m of `heightAt` after 5 s (GameState assertions). proof: the Phase 1 playtest command with the coupling scenario — pass x2 (`fluid-particles-coupling.playtest.json --url http://127.0.0.1:5173/?scene=coupling --headed`): sphere rests at y 0.299 m with speed 0, the 550 kg/m³ box ends 0.04 m from `heightAt` (limit 0.2), surface disturbance seen, 0 console errors. Hull is one centre point (heave only): off-centre points fed splash noise into torque and tumbled the box.
+- [x] The desktop host runs the solver and matches the web capture within the case tolerance. proof: `pnpm parity` (case `fluid-particles`) and the `fluid-particles-desktop` playtest — `run-conformance.mjs --target web` then `--target desktop --only-tests fluid-particles` against `build/tn-linux/mystral`: pass, pixelMismatchRatio 0, perceptualDeltaE 0 (tolerance 0.08 / 6.0), 0 GPU validation errors; `--target desktop` playtest passes (peak front 2.9 m, final max speed 0.09 m/s, stats readback works natively); web capture on `nvidia/turing`
+
+**Verification:** both proofs; regenerate the census in the same commit as the registry change
+(`pnpm census`).
+
+#### Phase 3: agents find it, and the sandbox Fluid Lab runs all eight experiments at 60 fps
+
+**Status:** DONE (3 of 3 boxes; frame cost and presented fps measured)
+**Files:** `packages/core/src/index.ts` (`@situation` tags for `FluidParticles3D`; spray/droplet
+situations on `GPUParticles3D`), `packages/create-threenative/templates/sailing/AGENTS.md` or the
+capability reference the scaffold reads (one line, inside the template caps),
+`../sandbox/fluid-lab/` (new game: scenes, `src/render/`, HUD, playtests, `FRICTION.md`).
+**Implementation:** `pnpm build` regenerates both capability manifests. Pack core and physics to
+a private staging directory with content-hashed tarball names (never `pnpm sandbox`, which wipes
+the shared `.packages`), scaffold `fluid-lab`, port the eight experiments onto
+`FluidParticles3D`, `Buoyancy3D`, `RippleField`/`WaveField` and `GPUParticles3D`, and the optics
+from `shaders.js` into `src/render/`. Commit and push the game to the sandbox remote as soon as it
+runs, per the sandbox `AGENTS.md`.
+
+- [x] `engine_search_capabilities` returns `FluidParticles3D` first for "pour water into a tank and drop a ball in it" and `GPUParticles3D` for "splash spray droplets with lifetime and gravity"; template caps still pass. proof: `pnpm build && pnpm test`, then both searches — `searchCapabilities` over the regenerated `packages/core/capabilities.json`: "pour water into a tank and drop a ball in it" → FluidParticles3D (0.70) first; "splash spray droplets with lifetime and gravity" → GPUParticles3D (3.60) first. `pnpm build` ok; `pnpm test` (create-threenative + scripts + the new spec, 2,457 tests) 2,456 pass; the one red (`quality-json.spec`, 3 unwaived suppressions in `fluid-particles.ts`) was fixed and rerun green; `pnpm typecheck`, `pnpm lint` (0 errors) and `pnpm budgets` exit 0
+- [x] A playtest cycles all eight scenes through keys 1–8 and asserts each is non-blank, changes over time, and reports a non-zero particle or wave state. proof: `node packages/playtest/dist/runner/cli.js ../sandbox/fluid-lab/playtests/experiments.playtest.json --url --browser-recipe webgpu --headed` — pass on `nvidia/turing` (`../sandbox/fluid-lab`, pushed as ThreeNativeHQ/examples 0cdeb86): per scene `experiment`, `alive` (particles or wave energy) and `moved` (speed over 0.2 m/s or wave energy) hold at every step; the eight per-scene screenshots are 5-11 % non-background pixels (ocean 90 %); 0 console errors
+- [x] The sandbox Splash Tank at the High preset holds steady-state frame p95 ≤ 16.7 ms on the Linux desktop browser with a hardware WebGPU adapter — the case the source drops to 30 fps. proof: `measure-steady-state-fps` skill lane against the sandbox build + preview
+ Frame cost on a hardware adapter, plus presented fps on `:0` (see Presented fps: p95 16.8 ms, 59.97 fps): on the High preset (4,896 particles) a fixed step is 0.26–0.33 ms of GPU time (`?bench=1&exp=2&quality=high`, `window.__fluidGpuMs(30)`, timestamp queries) and the raymarch render pass 0.28 ms (`TN_FRAME_BUDGET` `gpuMain`), under 1 ms of GPU per 16.7 ms frame; uncapped frame intervals read p95 8.5–13.4 ms over 5,000–6,500 frames per run with the host at load average 14–50, where a no-fluid control already read p99 14–16 ms. The p99 tail (30–60 ms) tracks host load, not the solver (it also appears with readback off). Presented p95 was taken on `:0` (see Presented fps).
+
+**Verification:** the three proofs; `score-build-experience` on the finished sandbox build goes
+in the PR body, not a box.
diff --git a/docs/PRDs/done/PRD-VQ-11-bounded-decals.md b/docs/PRDs/done/PRD-VQ-11-bounded-decals.md
new file mode 100644
index 0000000000..d3d576922e
--- /dev/null
+++ b/docs/PRDs/done/PRD-VQ-11-bounded-decals.md
@@ -0,0 +1,208 @@
+---
+prd_contract: v1
+---
+
+# PRD-VQ-11 — Impact and environment decals use a bounded portable lifecycle
+
+**Status:** COMPLETE — 2026-10-02 at `8f85fff19913c955a44b7b85d2cc117b972bd19c`. All six phase items and both acceptance criteria are verified by CPU ownership tests plus actual browser WebGPU and packaged Linux ARM64 QuickJS/wgpu captures. The merge with develop `924b92f8` changes the playtest runner and is undergoing the same browser/native qualification and required CI before promotion. Software-adapter correctness only; no mobile or hardware-performance claim.
+**Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 2 / high-payoff detail.
+**Dependencies:** Independent. Share receiver/base-geometry selection with existing LOD and picking paths.
+
+## Grounding and intended outcome
+
+Upstream [DecalGeometry](https://threejs.org/docs/pages/DecalGeometry.html) already provides mesh projection. The earlier audit must not be interpreted as permission to rewrite that algorithm. The useful product gap is qualified receiver handling, pooling, lifetime and game-owned material composition for real impacts.
+
+**Outcome:** Repeated bullet impacts and environment marks remain attached to their receivers, respect normals and occlusion, and stay within a fixed resource cap as the player moves or the scene unloads.
+
+## Design and ownership
+
+Start from ordinary Three decal geometry and generated materials. Reuse existing hit results, object transforms and authored LOD0 geometry rather than raycasting the whole world again. Admit a shared lifecycle owner only when at least two consumers need it. Keep material, atlas choice, color, depth bias and fade curves in generated source. A world-static mark and a rigid-moving receiver are separate supported cases; skinned or deforming decals are explicitly refused in v1.
+
+Surface-color impacts and environment marks on static/rigid opaque receivers. No deferred G-buffer decal renderer, normal/roughness channel rewrite, skinned tattoo system or virtual-texture painting.
+
+### Opt-in projection implementation slice
+
+Extend the existing game-owned shooter `DecalField`; preserve `place(point, normal, variant, scale)` and its authored default scene. Add `project(receiver, geometry, point, normal, variant, { depth, scale? })`, where the game passes its existing hit object and public `baseGeometryOf(receiver)` result. The render source remains ordinary Three.js with no framework import. Upstream `DecalGeometry` clips only that receiver's borrowed geometry; the field owns the resulting buffers, keeps forward-facing triangles, applies the authored normal offset, and transforms the result into receiver-local space.
+
+Reuse existing material/slot budgets and deterministic per-variant round-robin eviction. A fixture with one 256-slot family proves the required total cap. Release projected buffers on slot replacement, clear, receiver removal and field disposal. Direct receiver removal triggers cleanup; `update()` also cleans receivers whose ancestor subtree left the field's parent. Rigid translation/rotation and positive uniform scale are supported. Reject skinned, morph/deforming, instanced/batched, transparent/cutout, mirrored, singular, sheared or nonuniformly scaled receivers explicitly. Source geometry is borrowed, is never reassigned or disposed, and may differ from the current render LOD.
+
+CPU proof belongs in `packages/create-threenative/__tests__/shooter-decals-projection.spec.ts`, alongside existing lifetime proof. An opt-in fixture will exercise real framework picking/base geometry, receiver motion, cap/eviction and teardown. Projection corner distortion and same-receiver overlapping forward-facing surfaces remain upstream limitations; the caller supplies a narrow projection depth. Browser/native and actual relevant screenshot gates remain open until executed.
+
+### Authored atlas/fade continuation (2026-10-02, CPU-verified; runtime pending)
+
+Keep this extension in the existing generated `DecalField`. A projected mark may provide `uvRect: [u0, v0, u1, v1]`, normalized and ordered inside the borrowed atlas, plus `fade: { duration, opacity(progress) }`. Map only the owned projected UV buffer; do not clone or rewrite the borrowed texture. The optional duration is positive finite seconds; progress runs from 0 toward 1 through `update(dt)`, and the authored curve must return finite opacity in [0, 1]. At expiry, release only the projected geometry/listener and recycle the slot. Reuse resets opacity; omitted controls keep the existing persistent, full-map appearance. Projection bias must be nonnegative and at most half the supplied projector depth, rejecting invalid input before evicting a live mark.
+
+Capability searches inspected the installed atlas rewrite/texel helpers and mesh merger. Those are asset cooking/framework surfaces; generated render source cannot import the framework, and the owned projected buffer needs only a direct UV affine transform. The full-request search also inspected `SpriteAnimator3D`; this is projected surface geometry rather than a sequenced sprite, and the render-source import rule applies. No installed authored fade/lifetime or GPU residency capability matched. CPU proof covers UV isolation, opacity progression, expiry/reuse/removal, malformed input and repeated lifecycle ownership. The existing hosted real fixture has optional atlas/fade and repeated scene-reset observations while retaining its pinned visible-mark and hidden-material gates. Native execution remains a separate unverified platform gate.
+
+### Linux native qualification plan (2026-10-02)
+
+Use the supported hosted Ubuntu ARM QuickJS build and the built public `runDesktopPlaytest`. Bundle the same factory through explicit standard/atlas/hidden/hidden-during-fade native entries, then use the existing desktop packager to embed the unchanged cooked receiver GLB and manifest. Publish existing measured scene observations into the native-supported state resource seam. Map every authored component predicate to a resource predicate; native readiness uses startup assertions, and the original pixel assertions run unchanged against actual device PNG bytes through the maintained evaluator. Native browser-only assertion families are not advertised as supported.
+
+Before hosting, test each scenario against the real mailbox capability/preflight implementation using the captured minimal native handshake shape. Reject missing assertions, unready startup, wrong renderer/capture provenance, unnamed adapters, device loss and host console errors. Record source, runtime, bundle, packaged executable and asset hashes and verify them after execution. Software llvmpipe evidence is correctness-only. No native or full acceptance box closes until the actual packaged fixture and its repeated lifecycle have run.
+
+## Required behavior
+
+- A cap of 256 decals in the fixture is enforced with deterministic eviction, and receiver teardown removes its marks.
+- A mark stays on a rigid receiver under translation/rotation; nonuniform or unsupported transforms have a specified behavior.
+- Receiver isolation avoids marks projecting through nearby unrelated geometry; corner distortion is documented.
+- LOD changes do not alter the gameplay hit surface or strand a decal on disposed geometry.
+
+## Execution phases
+
+All proof paths below are **planned implementation targets**, not existing passing tests unless a result is explicitly recorded. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/docs/visual-quality-batch-2026-10-01/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates.
+
+### Phase 1 — Reuse projection and bound lifetime
+
+- [x] Integrate upstream decal projection with existing picking/base-geometry data and explicit receiver support. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts` — 20 tests passed, including a real cooked GLB through the asset loader, 128-to-2-triangle LOD selection, framework picking and `baseGeometryOf`; renderer proof remains Phase 3.
+- [x] Implement deterministic pool limits, eviction, receiver cleanup and borrowed-resource ownership. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts` — 23 tests passed; 1,024 impacts retained 256 slots, released 768 evicted projected buffers, and released the remaining 256 once at teardown while retaining borrowed source geometry/map.
+ - Partial, 2026-10-02: the existing generated shooter `DecalField` now releases each cloned slot material, family material and shared geometry once; its texture remains borrowed. `node node_modules/vitest/vitest.mjs run packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts` passed 4 tests after reproducing 4 failures on the original implementation. This repairs the existing 224-slot template; it does not implement the proposed 256-decal receiver/projection fixture or introduce an engine pool.
+ - Caller ownership follow-up, 2026-10-02: `Play` registers its procedural decal texture in the existing scene registry after the borrowing field. The real `Play.load`/`enter` and registry teardown regression proves all 224 material disposals precede one texture disposal on each of three scene lifecycles; repeated cleanup does not redispose it, and the borrowed sky texture is retained. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/template-runtime-cost.spec.ts -t 'releases the shooter-owned decal texture'` — missing-disposal failure before the fix, pass afterward.
+
+### Phase 2 — Preserve authored appearance and receiver motion
+
+- [x] Add game-owned decal material/atlas composition with bounded bias and fade controls. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts` plus the runtime fixture.
+ - Materials, tints, map, offset, projection depth and size remain game-authored. Optional atlas/fade controls and bounded projection bias are CPU-verified; actual atlas, visible partial fade, expiry and hidden-during-fade cases pass at `021e6223` in run 37003284983.
+- [x] Verify rigid receiver motion, LOD changes and repeated scene teardown without detached marks or allocation growth. proof: `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-projection.spec.ts packages/create-threenative/__tests__/shooter-decals-fixture.spec.ts` plus the Phase 3 lifecycle scenario.
+ - Receiver/ancestor rigid motion, positive uniform scale, direct and ancestor removal, explicit cleanup, legacy-slot reuse and actual loader/picker LOD independence are CPU-verified. [Run 37005420971](https://github.com/ThreeNativeHQ/threenative/actions/runs/37005420971), source `3bb9942c`, passes six actual rendered scene instances with identical 259 geometries, 4 textures and 6,522,894 GPU-tracked bytes; three stable completed-render intervals precede each sample.
+ - Partial, 2026-10-02: the lifetime regression exercises 20 field construction/teardown cycles, 2,000 pooled placements, clear/reuse, zero-slot teardown and repeated `dispose()`. Real Three disposal events/spies verify owned-resource release and retention of the borrowed map. Rigid receivers, LOD changes, GPU memory and game-scene transitions are not qualified by these unit tests.
+
+### Phase 3 — Qualify the visible result
+
+- [x] The impact sequence covers the 256-mark cap, moving receiver and LOD boundary on browser WebGPU. proof: `node --import tsx packages/create-threenative/__tests__/fixtures/bounded-decals/verify.ts` / `.github/workflows/integration-decals.yml` — [run 36991562324](https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324) passed at `4cfc3c20`, 960 × 540 Google SwiftShader WebGPU; four actual captures satisfy receiver-region pixel checks and pool/motion/LOD observations. The hidden-material control retains 256 live slots but fails exactly both mark-pixel assertions. Correctness only; native and hardware performance are not qualified.
+- [x] The same sequence runs on Linux native with matching pool/receiver observations and visible marks. proof: `node packages/playtest/dist/runner/cli.js packages/create-threenative/__tests__/fixtures/bounded-decals/motion.playtest.json --target desktop --executable ${VQ_NATIVE_EXECUTABLE:?}` — [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669) passes all ten native cases at `8f85fff19`, 960 × 540 Mesa llvmpipe, through the built public desktop runner and packaged QuickJS/wgpu host. State predicates, original external pixel gates, both hidden-material controls and strict startup/console/provenance guards pass.
+
+## Acceptance criteria
+
+- [x] The supported receiver cases retain their marks under motion and teardown, and resource use is bounded independently of total shots fired. proof: [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669), source `8f85fff19`, passes the complete bounded-decals browser/native sequence; six native generations retain 259 geometries, 4 textures and 6,522,894 tracked bytes with all growth measures zero. The CPU cap regression fires 1,024 impacts through 256 slots and proves exact eviction/final disposal. Rigid transform and removal tests plus actual motion/teardown PNGs verify receiver attachment.
+- [x] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: 57 focused decal/lifetime/native-proof and adjacent template tests pass, including legacy persistent `place()`, unsupported-receiver refusal, fade expiry/reuse and exact borrower/owner teardown; [run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669) verifies scene restart, expiry to zero marks/bytes and six repeated native lifecycles. The default shooter appearance remains unchanged.
+
+## Performance and promotion
+
+Measure draw calls and geometry bytes at the cap. Batch only compatible marks when it improves the measured cost; do not trade correctness for an advertised one-draw system. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source.
+
+## Qualification limits
+
+Skinned/deforming receivers remain an explicit v1 refusal and require a separate demand-backed extension. Hosted Chromium SwiftShader and packaged Linux ARM64 QuickJS/wgpu on Mesa llvmpipe qualify the requested browser/native correctness. Mobile and hardware performance are outside this proof. The local container cannot provide GPU/display or tsx Unix IPC execution; hosted exact-source gates supply the actual runtime evidence. Promotion still requires the combined develop-merge run and mandatory CI.
+
+## Completion record
+
+All original phase and acceptance items are verified at `8f85fff19`; archived individually under the batch filing rule while sibling PRDs remain active. The dated records below preserve earlier partial and failed attempts as history, not current status. The current qualification record follows them. Promotion gates are tracked separately and are not waived by archival.
+
+2026-10-02 initial bounded ownership repair at `d134451e` (historical results from the prior environment; not a fresh aggregate pass for later changes):
+
+- Red/green: the final four-test lifetime regression failed all four cases on the original `decals.ts` and passed all four with the repair. No Three disposal implementation is replaced; listeners and call-through spies observe the real methods.
+- `node node_modules/vitest/vitest.mjs run --maxWorkers 1 packages/create-threenative/__tests__/shooter-decals-lifetime.spec.ts packages/create-threenative/__tests__/world-environment-lifetime.spec.ts packages/create-threenative/__tests__/shooter-rig.spec.ts packages/create-threenative/__tests__/template-runtime-cost.spec.ts`: 4 files, 13 tests passed after building local package prerequisites. Existing Rapier/Three warnings were emitted.
+- `pnpm typecheck` and `pnpm --filter create-threenative build`: exit 0 after building package prerequisites. `pnpm lint`: exit 0 with 1,000 existing warnings. `node --import tsx scripts/check-doc-links.ts`: exit 0, 2,395 links checked. The doc-link/evidence-budget/evidence-citations suites passed 31 tests using the `node --import tsx` loader in place of the CLI's unavailable IPC socket.
+- Full `pnpm test` was attempted and stopped at the `tsx` IPC socket with `EPERM`; its loader-based retry was interrupted during aggregate builds to avoid running the batch's full suite concurrently. This is not a full-suite pass. No browser, native, GPU-allocation or visible-result claim is made.
+- All six phase boxes and both acceptance boxes remain open. The proposed 256-mark projection fixture, receiver motion/removal, LOD handling, appearance controls and platform scenarios are still to be implemented or qualified.
+
+2026-10-02 caller texture ownership follow-up, verified in the recovered environment:
+
+- Fresh red/green on the actual shooter caller: its generated map emitted zero disposal events after scene/registry teardown before the fix. After the fix, the new regression passes through three scene construction/teardown cycles, checks borrower-before-texture disposal ordering and idempotent cleanup, and leaves the borrowed sky map untouched.
+- The same four-file focused/adjacent command above passes 14/14 tests with `--maxWorkers 1`, including the original four field-ownership regressions. Fixture Rapier/Three warnings remain visible; no real disposal implementation is replaced.
+- `node node_modules/typescript/bin/tsc --noEmit -p packages/create-threenative/tsconfig.json`: exit 0. Biome check on the changed caller and test: exit 0 with six existing complexity warnings. `node --import tsx scripts/check-doc-links.ts`: exit 0, 2,395 links checked.
+- This follow-up does not rerun aggregate root build/typecheck/test lanes while parallel rendering work is active. Browser/native qualification, actual relevant runtime screenshot proof, required CI and independent review remain open; the PR stays draft. Projection/receiver feature expansion is not included in this ownership repair.
+
+2026-10-02 opt-in receiver projection milestone:
+
+- Initial projection tests: 17 failed because the opt-in API was absent; all passed after implementation. Additional invalid-normal regression failed before its validation repair; the final projection suite passes 19 tests. The real GLB loader/LOD/picking integration passes one additional test.
+- Focused and adjacent validation: 6 files, 34 tests passed with `--maxWorkers 1` (projection, fixture, lifetime, template-runtime-cost, shooter-rig, world-environment-lifetime). Package typecheck passes. Changed-file Biome check passes with the existing texture-generator complexity warning and two fixture complexity warnings. No root aggregate build/test pass is claimed.
+- The portable real engine fixture builds through Vite, and all four scenarios pass the maintained schema validator. The local pixel-producing run reaches the maintained runner but fails closed because no usable X display/Xvfb is available. No local screenshot is claimed.
+- The hosted workflow uses the built public runner and retains actual reports/PNGs before result checks. Exact source SHA and adapter accompany each capture; software rendering is correctness-only. Error diagnostics or `TN_PLAYTEST_SOFTWARE_DEVICE_LOST` prevent qualification even if a runner report says pass. Actual screenshot progress must be inspected and attached to the PR, including incomplete gates, before any readiness claim.
+
+2026-10-02 actual hosted screenshot progress, source `7396738b33a2b3018a2e6235a0e59ca07620a324`:
+
+- [Run 36989297734](https://github.com/ThreeNativeHQ/threenative/actions/runs/36989297734) passed all four authored scenarios at 960 × 540 using WebGPU, Google SwiftShader. Artifact `11218713068` SHA-256: `f432d6d5ca32cc09ee0633d3c3560445dc01ebc6e13b0280fa657774d2842652`. Software-adapter correctness evidence only; no hardware performance claim.
+- Exact unmodified captures: [static](../../verification/vq11-decals-36989297734/static.png), [rigid motion](../../verification/vq11-decals-36989297734/motion.png), [receiver teardown](../../verification/vq11-decals-36989297734/teardown.png). Both plates visibly carry marks, the right plate and its marks move together, and removing it leaves only the left plate/marks. [Raw summary](../../verification/vq11-decals-36989297734/summary.json) preserves capture provenance and observations: cap 256, 321 creations, 65 releases, 275,028 geometry bytes and 516 maximum reported draw calls; teardown leaves 128 marks/193 releases; restart reaches generation 2.
+- The summary's historical `qualified: true` means the then-current verifier passed. Independent review found that counters and whole-frame nonblank pixels alone do not prove visible decals. These images are human-inspected progress; a same-pose visible-effect discriminator and hidden-decal negative control are being added before automated visible qualification. Phase 3 remains open.
+- Native execution, authored atlas/fade controls, repeated projected-scene GPU residency, full acceptance and independent final review remain open. The PR stays draft.
+
+2026-10-02 automatic visible-mark gate repair:
+
+- A regression evaluated the real retained teardown PNG against the static scenario: the old whole-frame nonblank gate incorrectly accepted the missing right receiver. Five regression cases failed before adding receiver-region assertions and pass afterward. The checks reuse maintained playtest pixel measurement/evaluation; no screenshot is generated or edited by unit tests.
+- Each present receiver now has an interior ROI (left 270,185 / 170 × 125; right 550,190 / 95 × 90). At maximum luminance 0.35 the dark-pixel ratio must be between 0.01 and 0.20, excluding both empty bright plates and the dark background. Actual retained marked captures measure roughly 0.052–0.057. These thresholds are pinned before the new hosted run.
+- The verifier also runs the same static fixture with projected materials hidden. Picking, projection buffers, slot counts and LOD remain active; only real mark rendering is disabled. The control must fail exactly both receiver dark-pixel assertions while all other assertions pass and no unrelated diagnostics appear. At implementation commit `4cfc3c20`, hosted execution was pending; the result is recorded below.
+- Fresh scoped validation: 39/39 tests pass across six focused/adjacent suites; package typecheck and real Vite fixture build pass. Changed-file lint passes with two existing fixture complexity warnings. Root aggregate checks are not rerun here. Phase and acceptance boxes remain unchanged pending the hosted control and remaining PRD work.
+
+2026-10-02 visible-mark discrimination verified at `4cfc3c2024da1cb37533dc67fd07679e364c27f6`:
+
+- [Hosted run 36991562324](https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324) completed successfully, including all four real marked scenarios and the fifth hidden-material control. Artifact `11218994493` SHA-256 `a84190c40b3cdd6679ff9fea858dd6cfe2373646f090192ee531411fbaba1070` was independently verified after download. [Raw summary](../../verification/vq11-decals-36991562324/summary.json) records the exact source, 960 × 540 viewport, Google SwiftShader WebGPU adapter, diagnostics and pixel measurements.
+- Both marked receiver interiors measure dark-pixel ratios 0.0534 and 0.0566 in the static frame (moving right receiver 0.0518), passing the pinned 0.01–0.20 bounds. The [actual hidden-material frame](../../verification/vq11-decals-36991562324/hidden-decals.png) has the same lit plates and zero mark pixels in both regions. Its [original raw report](https://github.com/ThreeNativeHQ/threenative/blob/e1566472ee439650c74109a92e7fabae1770f71a/docs/verification/vq11-decals-36991562324/hidden-report.json) fails exactly `visual.1.region.darkPixels` and `visual.2.region.darkPixels`; all remaining assertions pass with 256 slots, 321 placements and real LOD selection. The expected two pixel errors are the only diagnostics; positive runs have none, and none report device loss.
+- All five PNGs were visually inspected. Static, motion, teardown and restart PNGs are byte-identical to the retained prior captures (restart equals static); the existing [static](../../verification/vq11-decals-36989297734/static.png), [motion](../../verification/vq11-decals-36989297734/motion.png) and [teardown](../../verification/vq11-decals-36989297734/teardown.png) files therefore also preserve the exact new-run bytes without duplicate binaries.
+- Browser Phase 3 box is now verified. Phase 2 atlas/fade controls, repeated scene GPU residency, Linux-native execution and both full acceptance criteria remain open. Required general CI is skipped while draft; this is not a full-CI or hardware-performance pass.
+- Proof-attachment doc checks: 2,405 links pass; the six-suite documentation run has 178 passes and two environment-blocked CLI tests (`evidence-budget`: long record and many small files). Both subprocesses fail at the `tsx` Unix IPC socket with `EPERM`, before evaluating their fixtures. The actual staged tree passes `node --import tsx scripts/check-evidence-budget.ts`; no aggregate documentation-suite pass is claimed.
+
+2026-10-02 atlas/fade local review checkpoint (not published or runtime-qualified yet):
+
+- Thirteen new projection tests failed on the prior implementation and pass with the controls. A further real Registry test spans 20 lifecycles/240 projected buffers; every eviction/expiry/removal/teardown buffer disposal occurs exactly once, and borrowed receiver geometry is retained. The projection suite now has 33 tests. The six focused/adjacent suites pass 53/53 after a non-force merge of develop `416ffd7c`.
+- Package typecheck and real Vite fixture build pass. Changed-file lint passes with six complexity warnings (existing texture generator, projection method, and fixture callbacks); no lint rule is relaxed. Eight runtime scenario files validate through the maintained schema. The original visible-mark and hidden-material bounds are unchanged. New atlas/fading/expiry cases use an authored two-cell atlas and a two-second linear curve; six scene instances sample actual renderer geometry/texture counts and total GPU-tracked bytes, requiring no growth across the five warmed instances. These new runtime observations have not run yet.
+- The actual combined scaffold test first failed only the shooter hash after merging develop; it measured `c035066c49f50a3c8ff894604a17ca6db3729179d6112e8470cb83f43d6eccc0`, now recorded. The full scaffold/looks/playtest/doctor group has 238 passes and one failure: the starter mobile-assets case cannot resolve Three's Basis transcoder from its temporary scaffold (`TN_ASSETS_TRANSCODER_MISSING`). This is a reported failed gate, not an aggregate pass; the byte-stability test itself passes.
+- Fresh `pnpm test` was attempted and fails before test execution at the `tsx` Unix IPC socket with `EPERM`. No root aggregate pass is claimed. The local actual browser run builds, then refuses capture because Xvfb/display is absent; doctor exits with missing `adb`. Current capability checks also find no `/dev/dri`, `/dev/kvm`, `Xvfb` or native host on PATH.
+- Native continuation: use the maintained desktop builder with `nativeEntry` pointing to the existing portable `native.ts`, stage the cooked receiver GLB and manifest from `public`, and execute the same cases through the built playtest runner against the resulting host. Native packaging/runtime has not been executed. No platform or residency box is ticked from CPU tests or older browser frames.
+
+2026-10-02 review correction before publishing atlas/fade:
+
+- Independent review reproduced a proof gap: the retained real hidden-material PNG passed all intermediate-fade visual assertions. A new regression reproduced that acceptance before the fix, then rejected exactly two missing positive-mark regions after it. The original full-opacity disappearance checks remain; two distinct inner regions now require intermediate-luminance mark coverage (left luminance ≤0.65, 1–30%; right ≤0.60, 10–65%). The real empty receiver PNG measures zero at both thresholds. Actual partially faded pixels remain pending the hosted run.
+- A tenth capture, `hidden-fading`, runs the same atlas/fading scenario but disables material rendering only when the fade input starts. Real opacity progression, geometry, pool and LOD observations stay active; the control must fail exactly the two positive intermediate-pixel assertions and have no unrelated diagnostics. The original four scenario JSON files are byte-identical to `e1566472`.
+- Fresh scoped checks after this correction: 54/54 tests, package typecheck, changed-fixture lint and Vite fixture build pass; all eight authored scenarios validate. The source atlas/fade arithmetic and ownership were independently reviewed without a blocker. Independent review also reproduced the starter Basis-transcoder failure on unchanged baseline `e1566472`; the temporary test scaffold lacks project-relative Three Basis files. That named gate remains failed; no production change or blanket waiver is made.
+
+2026-10-02 actual atlas/fade proof with an open residency failure:
+
+- [Run 37003284983](https://github.com/ThreeNativeHQ/threenative/actions/runs/37003284983), source `021e622380c61695b8c5dc0e7b9ffc604e956b8c`, captured all ten cases at 960 × 540 on Google SwiftShader WebGPU. Artifact `11225280829` SHA-256 `8fad5bcda560bedc2bb9899f747ba6e6e5aa250c50fccade4d89eca02ec586aa` was verified after download. [Raw summary](../../verification/vq11-decals-37003284983/summary.json) retains every verdict and diagnostic.
+- [Atlas selection](../../verification/vq11-decals-37003284983/atlas.png) visibly separates the impact and environment-mark cells; [partial fade](../../verification/vq11-decals-37003284983/fading.png) visibly retains translucent marks at measured material opacity 0.5. Positive intermediate pixel coverage is 6.55% left and 43.44% right. Expiry removes all 256 live marks and releases all 321 created buffers; its exact PNG equals the retained [empty receiver frame](../../verification/vq11-decals-36991562324/hidden-decals.png). These real captures were inspected.
+- Both hidden controls pass their required rejection checks. Hidden-during-fade retains opacity 0.5 and 256 slots but fails exactly the two positive intermediate-pixel assertions. Positive appearance cases have no error/device-loss diagnostics. The original static/motion/removal/restart cases also pass. Authored appearance is now qualified on this software adapter, not native or hardware performance.
+- The overall run fails the new residency case, which remains open: warmed samples alternate 258/259 geometries and 6,522,318/6,522,894 bytes, yielding +1 geometry/+576 bytes; textures stay at 4. The first sample sees only one geometry before its eventual 256-mark frame, demonstrating that 60 simulated update ticks do not guarantee world-render completion. Sampling/render timing and LOD transitions are under investigation. The zero-growth limits remain unchanged; this is not a full run pass.
+
+2026-10-02 residency sampling correction, awaiting runtime rerun:
+
+- Replace the simulated-tick sample with the proven completed-render boundary used by VQ07: observe a changed cumulative `renderer.info.render.calls` value on the next game update, count one interval regardless of internal passes, and require three consecutive stable geometry/texture/byte observations. Only accept the world at its actual projected-mark draw count with the real two-triangle LOD and post-LOD hit present.
+- The lifecycle scenario uses bounded 30-second `waitForResource` waits for each of six sampled scene generations, allowing actual presentation between polls. A unit regression rejects 90 simulated ticks with no render-call advance, resets stability when counts change, and rejects missing/nonfinite memory. All 55 focused/adjacent tests, typecheck, fixture build, lint and lifecycle schema validation pass. Production decal code, the original four scenario files and zero-growth ceilings are unchanged. The prior +1/+576 failure is preserved above and is not yet declared fixed by browser evidence.
+
+2026-10-02 completed-render residency and full browser sequence verified:
+
+- [Run 37005420971](https://github.com/ThreeNativeHQ/threenative/actions/runs/37005420971) passes at source `3bb9942c41dfd5c4a8690354373ad9c53259d0ee`: eight positive captures and both expected hidden-control rejections. Artifact `11225394658` SHA-256 `9c2811282094efd8229fc341f2a126dea451feefef7587c37882426365488493` was verified. [Raw summary](../../verification/vq11-decals-37005420971/summary.json) retains every capture, counter and diagnostic.
+- Across all six scene generations, actual completed-render samples are identical: 259 geometries, 4 textures, 6,522,894 tracked bytes. Cumulative render calls advance 56, 64, 72, 80, 88, 96; every sample has three stable observed intervals. Geometry, texture and byte growth are all zero. The previously reported +1/+576 variation was caused by sampling before stable world rendering; no ceiling changed. This is renderer-reported software-adapter residency over the six observed instances, not a hardware memory benchmark or an unlimited-duration claim.
+- All ten actual PNGs are byte-identical to already retained captures, so no duplicate image files were added. The inspected [atlas](../../verification/vq11-decals-37003284983/atlas.png), [partial fade](../../verification/vq11-decals-37003284983/fading.png), [empty/expired](../../verification/vq11-decals-36991562324/hidden-decals.png), [static/restarted lifecycle](../../verification/vq11-decals-36989297734/static.png), [motion](../../verification/vq11-decals-36989297734/motion.png) and [teardown](../../verification/vq11-decals-36989297734/teardown.png) files preserve the exact bytes of this run. Positive captures have no error/device-loss diagnostics; each negative control has only its two intended pixel failures.
+- Phases 1 and 2 and browser Phase 3 are verified. Linux-native remains unexecuted; the existing VQ07 hosted ARM desktop route provides a concrete next integration path, so this is not claimed to be blocked solely by the local missing display/GPU. Both full acceptance boxes, required aggregate gates and final promotion review remain open. PR stays draft; no auto-merge is enabled.
+
+2026-10-02 native lane local review checkpoint (execution pending):
+
+- Add an ARM native job after the unchanged browser capture job, reusing the existing ordinary QuickJS host build, dependency cache and package scripts. Four native entry bundles build locally through the maintained bundler. The desktop packager will embed the unchanged receiver GLB/manifest into each debug executable; exact runtime-prefix, runtime/bundle/package/asset hashes are verified. The actual executable's `--version` must identify QuickJS, and the execution process must be Linux ARM64. No unmerged helper from PR396 is required.
+- Native receives the same actual scene observations through state resources. Every original component predicate is mapped one-to-one; unsupported browser diagnostics/visual families are replaced by strict native startup/host-console/provenance validation and the unchanged pixel predicates evaluated against actual device PNGs. A crashed host with no `console.json` retains its original report diagnostics.
+- Three preflight regressions failed before implementation, then pass: all ten arms pass the maintained mailbox handshake/capability checker using the captured minimal QuickJS handshake and actual shared transport capability set; malformed/unready/unnamed/browser-substitute capture evidence is rejected; actual faded and hidden PNGs preserve the positive/negative visual distinction. These are protocol/unit proofs, not native rendering.
+- Fresh validation: 58/58 focused/adjacent tests across seven suites, 141 workflow tests, package typecheck, changed-file lint (four existing fixture complexity warnings), browser fixture build and all four native entry bundle builds pass. Production decals and all authored scenario JSON files are unchanged. Native execution, full acceptance and promotion remain pending.
+
+2026-10-02 first real packaged native progress, incomplete qualification:
+
+- [Run 37010879478](https://github.com/ThreeNativeHQ/threenative/actions/runs/37010879478), source `563ab23e9448a993013cb0d58c75ab6ab83917c9`, builds the ordinary ARM64 QuickJS/wgpu-native host and packages all four variants with the exact receiver assets. Actual native static, motion, teardown and one-restart cases pass supported resource/startup predicates, strict host console/provenance and every unchanged external pixel predicate at 960 × 540 on named Mesa llvmpipe (LLVM 20.1.2). Artifact `11228900169` SHA-256 `0ee5614f0ec8f859d8a23055062e58792f1c94d2af9c8bfd92b15d0e1f327a55` is verified.
+- Exact inspected native images: [static](../../verification/vq11-decals-native-37010879478/static.png), [rigid motion](../../verification/vq11-decals-native-37010879478/motion.png), [receiver teardown](../../verification/vq11-decals-native-37010879478/teardown.png). The native restart PNG is byte-identical to static. [Raw summary and artifact identities](../../verification/vq11-decals-native-37010879478/summary.json) preserve source/runtime/bundle/package/asset hashes and named native adapter/device-screenshot provenance. This is native software correctness progress, not hardware performance.
+- Native qualification remains incomplete: the [lifecycle report](../../verification/vq11-decals-native-37010879478/lifecycle-report.json) times out waiting for generation 1 residency readiness. [Actual native console](../../verification/vq11-decals-native-37010879478/static-console.json) shows 256 visible marks/321 placements but `maxDrawCalls=0` at the game-update sample; that update boundary does not retain the browser's per-frame draw counter. The actual render/reset ordering is being traced before changing the observation boundary. No residency limit or pixel predicate is relaxed. Atlas/fade/expiry/hidden native cases were not reached, and all native/full acceptance boxes remain open.
+
+### Native residency observation correction — 2026-10-02
+
+The first native run's generation-1 timeout is an observation defect, not evidence of a missing visible render: all four completed native captures passed the authored pixel gates. Three's `Info.reset()` zeros `render.drawCalls` but retains cumulative `render.calls`; its internal Animation RAF can reset the former before the fixture's update. Core also resets metrics before the world pass. This ordinary fixture's existing `Scene.render` hook runs after the world render returns. The native host then replays/submits/presents the RAF command stream in `endDawnFrame` before its next timer/mailbox/update iteration (`packages/runtime-native/src/runtime.cpp`).
+
+The fixture now snapshots the real renderer counters and resource tuple in that post-world hook, then consumes the snapshot once on the next update. Missing snapshots, repeated hooks without new render calls and simulation ticks alone cannot advance residency. The original post-LOD hit, 2-triangle receiver, at-least-256-draw requirement, three stable intervals and all zero-growth ceilings are unchanged. Scene exit removes the capture closure. This is a fixture observation correction; no engine loop, renderer counter or production decal behavior changes.
+
+The actual fixture scene regression uses the real loader/picker/LOD path and Three `Info.update/reset`, with CPU-supplied memory measurements (no GPU claim). It reproduces the reset-before-update failure before the fix, then proves 256 draws qualify while 0/255, missing snapshots and repeated snapshots do not. Focused decal/lifetime/native-proof and adjacent template runtime suites pass 57 tests; the package typecheck, four native bundles and browser fixture build pass. Biome has no errors and retains the four existing fixture complexity warnings. The root `pnpm test` attempt still fails before test execution at tsx's Unix IPC listener with `EPERM`; this does not establish a full-suite pass. Native and full acceptance remain open until the corrected exact source runs all native cases, including unchanged visible-effect controls and repeated residency.
+
+
+### Complete browser/native qualification — 2026-10-02
+
+[Run 37015640669](https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669), exact source `8f85fff19913c955a44b7b85d2cc117b972bd19c`, passed both browser and Linux native jobs. All ten native cases pass the maintained guards: static marks, rigid motion, removal, restart, six-generation residency, atlas selection, visible intermediate fade, expiry, hidden marks and hidden-during-fade rejection. Each negative control fails only its two intended authored pixel predicates while its state, startup, console and capture identity remain valid. Actual native partial opacity is approximately 0.475; expiry releases all 321 created projected buffers and retains zero active marks/geometry bytes.
+
+Six measured native generations each have 259 geometries, 4 textures and 6,522,894 renderer-tracked bytes after three stable completed-render intervals; geometry, texture and byte growth are exactly zero. At the cap the fixture retains 256 marks / 275,076 owned projected bytes and observes a maximum 516 world draw calls. This reports measured cost without a hardware-performance or single-draw claim. Native captures use packaged ARM64 QuickJS/wgpu, Mesa llvmpipe (LLVM 20.1.2, 128 bits), `device.screenshot`, 960 × 540.
+
+[Minimal provenance](../../verification/vq11-decals-native-37015640669/provenance.json) retains source/build/runtime/asset/PNG hashes and scoped results. Artifact `11231490134` ZIP SHA-256 is `07925a63d4091f2a53d06ef7b178619d775ef9185890949afb4645c83e9d519f`; full raw reports and host logs remain in that workflow artifact. The new [atlas](../../verification/vq11-decals-native-37015640669/atlas.png), [partial fade](../../verification/vq11-decals-native-37015640669/fading.png) and [expired](../../verification/vq11-decals-native-37015640669/expired.png) PNGs are copied unchanged and visually inspected. The other seven captures reuse byte-identical prior/empty frames recorded in provenance. An independent whole-PR review reran 57 tests, all ten native report/console/pixel evaluations and adversarial empty-image controls, and cleared the original implementation and acceptance scope.
+
+### Combined develop promotion preparation — 2026-10-02
+
+Merged develop `924b92f825d602485ef4e682f434b4757a825cd7` without rewriting ancestry. The changed playtest runner was rebuilt with its validator-generation check, tsup/declarations and publint; browser and all four native fixture bundles build successfully. Root `pnpm typecheck` passes serially across the workspace. Root `pnpm lint` passes with existing warnings after formatting evidence JSON. The historical raw report is preserved in its original immutable commit and workflow artifact; the current-tree file contains minimal provenance, as described below. Documentation links (2,429), all 22 generated agent mirrors and the staged evidence budget pass.
+
+The expanded affected suite has 463 passes and one failure: `create-threenative > keeps the starter's shipped assets mobile-shippable`, `TN_ASSETS_TRANSCODER_MISSING`, because its temporary scaffold cannot resolve the project-relative Three Basis transcoder. Independent review already reproduced that exact fixture failure on unchanged `e1566472`; it remains reported, not skipped or treated as green. The focused decal tests and changed playtest/tone, schema and workflow tests all pass. Root `pnpm build` and `pnpm test` were each attempted and stop at the environment's tsx Unix IPC `EPERM`; the affected package/fixture builds were executed through Node's loader instead. This is not a full-suite or hosted-CI pass. The combined source's actual browser/native rerun and mandatory CI must pass before promotion.
+
+Additional template/platformer/package/API/docs/mirror drift checks report 99 passes and two initial failures. The pristine-scaffold typecheck passed on rerun with the shared pnpm tool/cache environment (its first attempt used an unavailable default pnpm home); the remaining Xvfb-wrapper test cannot run because Xvfb is not installed and the wrapper correctly refuses blind execution. These environment limits are retained for comparison with the ordinary hosted checks.
+
+The exact Basis-transcoder test subsequently passes with `NODE_PATH` pointing to this checkout's existing `packages/core/node_modules`, which lets its isolated temporary project resolve the already installed Three transcoder. No production code, test, dependency or expected result changes. Both resolver and pristine-scaffold failures are thus reproduced environment-setup failures with passing scoped reruns; the aggregate IPC and absent-display limits remain.
+
+Publication privacy: the historical hidden-control report in the current tree is now minimal source/run/artifact/assertion provenance; its original raw report remains available in the immutable earlier commit and original workflow artifact. All original assertion outcomes and screenshots remain preserved. The current proof adds only minimal native provenance and actual PNG bytes.
diff --git a/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md b/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md
new file mode 100644
index 0000000000..0a7b69180a
--- /dev/null
+++ b/docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md
@@ -0,0 +1,329 @@
+---
+prd_contract: v1
+---
+
+# PRD-473 — Rain: the storm kit (TEMPEST, rebuilt on engine abstractions)
+
+**Status:** DONE (2026-10-01) — all three phases and AC-1..AC-5 verified; one owner taste call (the visual score) recorded under Blocked on
+**Complexity:** 7 → HIGH; risk override: none
+**Owner:** João (visual taste calls and the final PR screenshots)
+**Depends on:** None
+
+## Context
+
+`/home/joao/Downloads/tempest.html` (718 lines, single file, no assets) is a complete coastal storm
+study: volumetric clouds ray-marched through a 3D noise volume, a procedurally ray-marched shoreline
+with conifers, street luminaires, a guardrail and a lit cabin, a wet road and sea with real-time
+reflections and rain ripples, 16 000 camera-anchored rain streaks, branching lightning with
+distance-delayed synthesized thunder, four quality tiers, a full weather panel and synthesized
+rain/wind/thunder audio. Every pixel is generated at runtime; the only imagery in the PR will be
+captures of the reference and of the kit.
+
+This PRD absorbs every abstraction the source contains into the engine's public vocabulary and
+delivers the starter kit `rain`, which reproduces the complete study — not a narrowed subset — using
+engine abstractions. Templates live under `packages/create-threenative/templates/` and register
+themselves through `kit.json` (`packages/create-threenative/src/index.ts:251`).
+
+### What the source actually contains (read 2026-09-30, complete inventory)
+
+| Group | Source constructs (all of them) |
+| --- | --- |
+| A. Simulation | seeded mulberry32 `rng`; `clamp`/`mix`; exponential weather easing (τ≈0.67 s); weather record `{rain, cloud, wind, fog, exposure, wet}`; four presets `drizzle/storm/supercell/clearing`; `sanitizeWeather` clamped validation; sim clock with `dt` clamped to 0.08 s; auto-strike scheduler (`nextStrike = t + 8 + rand·16`, gated on `cloud > 0.35`) |
+| B. Camera | free-fly state `{position, yaw, pitch}` + `home`; WASD/arrows planar move with diagonal normalisation; `Q`/`E` altitude; `Shift` sprint 23 m/s vs 6 m/s; position clamps `x −100…220, y 1.65…110, z −350…90`; drag-to-look yaw/pitch with pitch clamp `−0.7…1.25`; cinematic orbit mode cancelled by manual input; FOV 55°, aspect from viewport; `basis(yaw,pitch)` forward/right/up |
+| C. Render passes | **sky/cloud** reduced-res pass (0.4–0.75×), 32/48/64/88 steps by tier, blue-noise jitter, sky gradient + sun disc/glow + flash term; cloud density from a multiscale 64³ RGBA noise volume, flattened condensation base 145–815 m; 5-tap light march with ×1.7 step growth; multiple-scattering approximation; strike-position cloud glow. **world** SDF raymarch (148 steps, adaptive epsilon): fbm+ridge heightfield, road curve, coast, island, one bounded procedural conifer per spatial cell, six repeating luminaires (pole/neck/lamp + emissive underside), guardrail with posts, cabin with lit window and antenna; ground/water with puddle mask (fbm × wetness), 3×3 hashed expanding ripple rings, micro-bump and wave motion, road stripes/edge lines with wear, water albedo and roughness, Fresnel-weighted reflection, sky-texture-offset reflection plus a 36-step secondary reflection march with emissive lamp hit; hemisphere ambient + sun + six cone point lights with specular + strike-direction flash lighting; height-dependent aerial perspective with advected fbm rain curtains; custom `gl_FragDepth` write for particle depth. **rain**: 16 000 quads in a 66×30×66 volume wrapped to a 12 m camera cell, velocity `(wind·20, −(14…22), wind·2)`, projected stretched quads, near/far opacity fades, flash-lit. **bolt**: additive ribbons with core+halo from branching geometry. **bloom**: 5×5 threshold bright pass at ¼ res. **post**: 12 animated refractive lens droplets near the screen edges, 5-tap luma-edge AA, bloom add, exposure multiply, ACES, cool-shadow/warm-highlight grade, vignette, gamma 2.2, dither. Targets: sky, world+depth, bloom; HDR when `EXT_color_buffer_float`; resolution scale and particle count per tier; identical GLSL behind a Three.js `RawShaderMaterial` (GLSL3) driver and a raw WebGL2 driver |
+| D. Audio | rain hiss loop (noise → highpass 1800 / lowpass 11 000) with gain from rain; wind loop (pink noise, 35–350 band) with gain from wind + slow LFO; thunder: 7 s generated buffer = crack `exp(−22t)` + rumble `low·(1−e^{−4t})·e^{−0.66t}` with 9 Hz tremolo, distance lowpass, distance gain, scheduled at `distance / 343`; master gain + compressor; suspend/resume on pause and hidden; mute; pending-voice tracking and clearing; gesture unlock, no autoplay |
+| E. Interface | topbar (brand, status dot, panel toggle, pause, capture, fullscreen, help); panel with 3 presets, 4 sliders (`rain` mm/h, `cloud` %, `wind` km/h, `fog` %) with readouts, auto-lightning switch, cinematic switch, strike button; "Rendering & accessibility" details (disable flashes, lens droplets, exposure, surface wetness, renderer/HDR readout); quality select (Performance/Balanced/High/Ultra); hero title block; sound pill; telemetry footer (precipitation, wind, fps) and compass heading; `aria-live` toast; help dialog with Escape/backdrop close and photosensitivity warning; hide-interface + restore button; staged loading overlay with progress text; keys `L X H P F R Space W A S D Q E Shift`, `blur` clears keys; responsive breakpoints at 900 px / 620 px / 800 px height; `prefers-reduced-motion` |
+| F. Automation API | `window.tempest`: `setWeather`, `setPreset`, `triggerLightning`, `setSafe`, `render`, `step(dt)`, `setQuality`, `stop`, `capture`, `frames`/`flash`/`sizes`; URL params `?still`, `?offline`, `?quality` |
+
+### Reference captures (root lane, 2026-09-30)
+
+The unchanged `tempest.html` was loaded in Chromium via Playwright with `?still&offline&quality=high` and
+captured at three viewports: [reference desktop](../../../verification/visuals/rain/reference-desktop.png)
+(1440×900), [reference tablet](../../../verification/visuals/rain/reference-tablet.png) (1024×768) and
+[reference mobile](../../../verification/visuals/rain/reference-mobile.png) (390×844). A 2026-10-01 re-capture
+of the same file on this machine's RTX 2080 (ANGLE/Vulkan, headed) differs from the committed references by a
+mean 0.27% per pixel, so the committed set stands as the reference. The kit's own captures and the comparison
+are in AC-5.
+
+### Capability lookups actually run (2026-09-30, `threenative-engine-mcp` over `packages/core/capabilities.json`)
+
+Launched from this lane as a stdio MCP server (`node_modules/.bin/tsx packages/engine-mcp/src/index.ts`,
+the same entry `threenative-engine-mcp` installs); 1 request-scope search plus 20 mechanic-scope
+searches, then `engine_capability_detail` on 20 hits.
+
+| Query (scope) | Result actually returned |
+| --- | --- |
+| the whole TEMPEST study (`request`) | `RenderChain`, `readRenderChainReport`, `readRenderChainObservation`, `useUiIntent`, `sendUiIntent`, `connectUiBridge`, `publishUiState`, `UiLayer`, `advanceFixedStep`, `assertCaptureNotBlank`, `InstancedBatch` |
+| emit GPU particles for falling rain, spray, impact bursts (`mechanic`) | `GPUParticles3D` |
+| wet ground, puddle mask, ripple normals, sky reflections (`mechanic`) | `Heightfield`, `WaterSurface3D`, `RippleField`, `ssr` (three addon), `loadTerrainSplat` |
+| post chain with bloom, exposure, tone mapping (`mechanic`) | `RenderChain`, `Daylight`, the generated `worldEnvironment` file entry |
+| free-fly camera, WASD, drag look, altitude keys (`mechanic`) | `InputMap`, `captureMouse`, `PointerEvents3D`, `defineGame` |
+| weather sliders and presets driving render parameters (`mechanic`) | `UiLayer`, `sendUiIntent`, `publishUiState`, `useUiState` |
+| ripple rings from impacts on water (`mechanic`) | `RippleField`, `WaterSurface3D` |
+| sky, sun and weather (`mechanic`) | `Atmosphere`, `Daylight` |
+| quality tiers scaling resolution and step counts (`mechanic`) | `readRenderChainReport`, `readRenderChainObservation`, `Atmosphere` |
+| **ray march a 3D noise volume for volumetric clouds** (`mechanic`) | **no results** |
+| **upload a 3D data texture and sample it in a shader** (`mechanic`) | **no results** |
+| **generate branching lightning ribbon geometry** (`mechanic`) | **no results** (only `UAssetError`, `getPlatform`, `softCircleDataTexture` by lexical accident) |
+| **synthesize rain/wind/thunder audio instead of loading files** (`mechanic`) | **no synthesis capability** — `AudioBus` plays buffers, `@threenative/assets` has file passes |
+| **honour a reduced-motion preference / reduce flashing** (`mechanic`) | **no results** |
+| capture a screenshot as PNG (`mechanic`) | `CanvasLayer`, `GameCanvas` — no download/capture capability |
+
+`GPUParticles3D` constraint: "geometry, color, and timing remain supplied by the game".
+`RenderChain` constraint: "stage factories own colour, strength, and all other appearance choices",
+"authored stages declare exactly one before or after anchor". `Atmosphere`: "deliberately creates no
+mesh, material, or scene light". `AudioBus`: "create buses before playing clips and dispose them
+with the game".
+
+Re-run 2026-10-01 against the regenerated `packages/create-threenative/capabilities.json` (`searchCapabilities`
+/ `capabilityDetail` from `packages/engine-mcp/src/index.ts`): the three searches that returned nothing now land
+on the kit's generated source — "ray march a 3D noise volume for volumetric clouds" → `createNoiseVolume`,
+"generate branching lightning ribbon geometry" → `createStormLightning`, "falling rain streaks around the camera"
+→ `createStormRain` — and `capabilityDetail` resolves `createWeatherWorld`, `createNoiseVolume`,
+`createStormLightning`, `createStormRain` and `createStormAudio` to files that exist under
+`templates/rain/src/` (package `template:rain`).
+
+## Solution
+
+**Framework (packages/) — what this PRD changed there, and why each belongs there:**
+- `packages/core/src/audio.ts` + the native audio graph (`packages/runtime-native/src/audio/*`): a bus
+ compressor and gain target ramps, so the study's master compressor and `setTargetAtTime` smoothing exist on
+ every target (the wip slice; 33 core audio tests, native audio graph tests, conformance `94-audio-context`).
+ Proven in the game: `storm-audio` reports `compressor: true`, `unsupported: []` and three live voices on
+ the native desktop host.
+- `packages/playtest`: a web-only scenario field `reducedMotion: "reduce"` (emulated before navigation), so a
+ game that honours the preference can prove it — the harness owns scenario plumbing.
+- Repo gates: `scripts/visual-gate.ts` asks for soft shadow-map settings only from a light that casts a
+ shadow (a ray-marched kit has no shadow map); the assets default-config test lets a kit declare per-clip
+ audio overrides; `scripts/render-chain-capabilities.ts` lists the kit's generated-source entries.
+
+**The kit owns (generated source, `templates/rain/`):** every appearance and every rule of the study. The
+coast, clouds and sea are the source's own ray-march, transpiled from `tools/tempest-{world,clouds}.frag` by
+`tools/generate-shaders.mjs` into TSL; the bloom and post grade are transcribed TSL; the 64³ noise volume
+is generated byte-for-byte from the source's seed; rain streaks and the bolt are one instanced draw each
+with their own vertex stage; the weather maths, presets, flash envelope and `distance / 343` delay are
+`src/state.ts`; sound is the source's DSP baked offline into three WAVs played through `AudioBus`; the
+interface is React in `src/ui/`. Named colours and lights are uniforms set from `src/render/{palette,sky,
+lighting,materials}.ts`; the player's four tiers are `STUDY_TIERS` in `src/render/quality.ts`.
+
+**Not admitted to the framework** (recorded so a later reader does not re-argue): the noise volume, the
+presets (a preset system is outranking rule 5), the flash envelope and bolt generation, the study tiers,
+runtime audio synthesis, the lens/grade stage and the source's second render backend.
+
+### Abstraction map (source → disposition, as built)
+
+| Source abstraction | Disposition |
+| --- | --- |
+| seeded RNG, clamp/mix easing, sim clock | `defineGame({ seed: 607 })` → `ctx.random`; easing, clamp and the 0.08 s step in `src/state.ts` |
+| free-fly camera, drag look, cinematic orbit, FOV | `InputMap` (`move` vector, `ascend`/`descend`/`boost` buttons, `look` `pointerRelative`) + rig in `src/render/camera.ts`, FOV 55° |
+| pause / hide / sound / photosensitivity keys | the game's input map (`pause`, `hideUi`, `sound`, `safe`) through `intentPatch`; F and P stay in the UI realm (browser APIs) |
+| weather state, presets, sliders, readouts, telemetry, compass | game state published through the UI bridge; UI reads `useUiState`, writes `useUiIntent` |
+| cloud volume + density + light march | `src/render/noise-volume.ts` (`Data3DTexture`) + transpiled ray-march in `clouds-shader.ts`, a `PassNode` at the tier's resolution share |
+| terrain, road, coast, island, trees, luminaires, rail, cabin, wet road, puddles, ripples, reflections, aerial perspective | the source's SDF ray-march transpiled into `world-shader.ts`, one screen quad marked `alwaysRender`, depth written for the rain and bolt |
+| rain streaks | `src/render/rain.ts`: one instanced draw, stateless closed form; `GPUParticles3D` inspected and not used (it owns position through a compute node; the source rain has no lifetime) |
+| lightning bolt, flash, scene light, cloud glow | `src/render/lightning.ts` + `flashAt` in `src/state.ts`; the strike's entry point lights the coast and the clouds in their shaders |
+| bloom, AA, exposure, ACES, grade, vignette, lens drops, dither | one authored `RenderChain` stage (`stormPost`) in `postprocessing.ts`; built-in stages off at every engine tier |
+| quality tiers | `STUDY_TIERS` (cloud scale/steps, rain budget, reflections); the drawing buffer is the engine's adaptive resolution |
+| rain hiss, wind, thunder with distance delay | `src/audio/storm.ts` over `AudioBus` with the compressor; the delay is kit code |
+| reduced motion / photosensitivity | UI reads `matchMedia` once and sends `setSafe`; `safe` gates every strike at one door |
+
+### Repo registration a new kit must make (each one is a gate, not a chore)
+
+`kit.json`, the six render-layer files (`scripts/visual-gate.ts`), `quality.ts` and its documentation
+(`scripts/check-template-quality.ts`), the conventions applicability row, the `rain` rows in the
+capability-recall corpus, the CI matrices, a README row, `playtests/survives.playtest.json` and a bounded
+`playtests/performance.playtest.json`, and `native-playtests/*.playtest.json`.
+
+## Acceptance Criteria
+
+- [x] AC-1 [local]: a project scaffolded from `--template rain` boots into the storm and one panel intent changes the rendered frame, not only the readout. proof: `node packages/playtest/dist/runner/cli.js
+ /playtests/storm.playtest.json --url http://127.0.0.1:5373
+ --server-command "pnpm --dir exec vite --host 127.0.0.1 --port 5373 --strictPort" --browser-recipe webgpu --headed`
+ — pass 2026-10-01 (final sweep at 47ccaef41): Drizzle + slider + drag on a paused storm change 92.98% of pixels; the same pause with no input changes 0.48%
+- [x] AC-2 [local]: a strike raises the flash value, brightens the scene and lands a `thunder` cue in the audio ledger at the source delay, while `safe` and `prefers-reduced-motion` suppress every flash. proof: the same runner on `playtests/lightning.playtest.json`
+ — pass: safe strike flash 0 and strikes 0; strike status `flashing`; thunder pending at 36 ticks, one `thunder` cue after; delay 0.9968 s for 341.9 m (= distance/343). `flash.playtest.json`: a held strike (flash ≥ 0.5) changes 97.5% of pixels. `reduced-motion.playtest.json` (`reducedMotion: "reduce"`): key, button and 700 ticks of auto-lightning give flash 0 and strikes 0; the same scenario without the emulation fails (strikes 2)
+- [x] AC-3 [local]: rain is registered everywhere a template must be registered — CI matrices, capability-recall brief, applicability row, docs, visual score. proof: `pnpm budgets && pnpm exec vitest run scripts/__tests__/ci-structure.spec.ts
+ scripts/__tests__/primary-docs.spec.ts scripts/__tests__/check-template-conventions.spec.ts`
+ — `pnpm budgets` exit 0; the three specs 3 files / 136 tests pass. The visual *score* is the owner's taste call and is recorded under Blocked on, not claimed
+- [x] AC-4 [local]: every row of the source inventory above (A simulation, B camera, C render passes, D audio, E interface, F automation API) is present in the rendered kit, checked one row at a time rather than by assertion. proof: the per-row table under "AC-4 — the inventory, row by row" below
+- [x] AC-5 [local]: the kit is captured at the same three viewports as the reference and the PR embeds both sets side by side, stating the comparison the owner actually sees — including any row where it differs, with nothing inferred. proof: `docs/verification/visuals/rain/reference-*.png` beside `docs/verification/visuals/rain/kit-*.jpg`, and the side-by-sides `docs/verification/PRD-473/compare-*.jpg`; comparison under "AC-5" below
+
+### AC-4 — the inventory, row by row (paste into the PR body)
+
+All browser runs: headed `--browser-recipe webgpu`, adapter NVIDIA Turing, zero console/network/runtime
+diagnostics. The repo's own template gate, `TN_TEMPLATE_ONLY=rain pnpm test:templates` (packed tarballs,
+fresh scaffold, `pnpm test` over the production preview plus the boot smoke), exits 0: "rain: scaffolded
+playtests passed". Native: the built `mystral` host on `--target desktop` (Vulkan, RTX 2080, private Xvfb).
+
+- **A. Simulation** — `src/state.ts` (presets, `sanitizeWeather`, easing τ 0.67 s, `flashAt`, `thunderDelay`), `src/scenes/Boot.ts` (clock clamped to 0.08 s, scheduler `t + 8 + rand·16` gated on cloud > 0.35), seed 607. Observed: `weather.playtest.json` Drizzle target 0.24, rendered rain eases to ≤ 0.32 (65.9% of pixels change); `auto-lightning.playtest.json` first automatic strike at t = 37.017 (28 + 9); `auto-lightning-clear.playtest.json` with cloud 0.09 no strike through t = 44.2; the API probe's `setWeather({ wind: 2 })` clamps to 1; 10 weather unit tests.
+- **B. Camera** — `src/render/camera.ts`, input map in `src/game.ts`. Observed: `fly.playtest.json` W+Shift z 18 → −4.9 (23 m/s), E y 2.85 → 5.85, drag heading 355° → 308°; `cinematic.playtest.json` orbit moves x 1.8 → 2.85 and W hands it back (`cinematic` true → false); `survives.playtest.json` ArrowUp moves the player 6.0 m.
+- **C. Render passes** — clouds (`clouds.ts`/`clouds-shader.ts`, noise volume byte-exact), world ray-march (`world.ts`/`world-shader.ts`), rain (`rain.ts`), bolt (`lightning.ts`), bloom and post (`postprocessing.ts`). Observed: `tiers.playtest.json` cloud target 864×540 / 691×432 / 576×360 / 1080×675 of 1440×900 at 64/48/32/88 steps, drops 9120/9120/4940/12160 (captures `docs/verification/PRD-473/tier-{performance,high,ultra}-1440x900.jpg`, performance drops the reflection march); `lightning-strike-1440x900.jpg` shows the branched bolt, lit coast and cloud glow; kit vs reference mean pixel difference 0.41% at 1440×900 (AC-5).
+- **D. Audio** — `src/audio/storm.ts` over `AudioBus` + compressor; `tools/make-storm-audio.mjs` bakes the three clips. Observed: browser and native `lightning` scenarios — `storm-audio` loaded 3, compressor true, one `thunder` cue only after the strike's delay; native `unsupported: []`, 3 voices; pause/visibility/mute holds covered by 12 `rain-audio.spec.ts` tests.
+- **E. Interface** — `src/ui/{GameUi,Hud,Menu,LoadingOverlay}.tsx`, `src/style.css`. Observed: kit captures at 1440×900, 1024×768, 390×844; `ui-help-*`, `ui-hidden-1440x900` (H hides panel and HUD, restore button shown), `ui-rendering-details-1024x768` (panel scrolls clear of the pill), `ui-mobile-panel-390x844`, `loading-curtain-1024x768` (staged curtain); `automation.playtest.json` (390×844: strike, X safe, H hide, P capture, Space pause); `loading.playtest.json` (curtain lifts, progress 1).
+- **F. Automation API** — `src/ui/tempest.ts`. Observed (one-off Playwright probe on the production build, real GPU, 0 errors): `render()` resolves on a new frame; `setPreset("supercell")`; `setWeather`; `setQuality("low")` → `performance`, unknown name throws `TN_RAIN_QUALITY_UNKNOWN`; `stop()` holds the clock, `step(2)` advances it exactly 2 s; `triggerLightning` strikes (flash 0.68), `setSafe(true)` turns auto-lightning off and blocks the next strike; `sizes` 1440×900; `capture()` encodes 1440×900 (1.97 MB); `?still&quality=high` freezes the clock and picks `high` (every AC-5 capture).
+
+### AC-5 — kit beside the reference
+
+Same viewports, both frozen at t = 28 with `?still&quality=high`: [desktop](../../../verification/PRD-473/compare-desktop-1440x900.jpg),
+[tablet](../../../verification/PRD-473/compare-tablet-1024x768.jpg), [mobile](../../../verification/PRD-473/compare-mobile-390x844.jpg);
+kit frames alone in `docs/verification/visuals/rain/kit-{desktop,tablet,mobile}.jpg`.
+
+| Viewport | Mean abs pixel difference | Pixels off by > 24/255 | Luminance q05/q50/q95, kit vs reference |
+| --- | --- | --- | --- |
+| 1440×900 | 0.41% | 0.30% | 0.190/0.329/0.544 vs 0.190/0.328/0.544 |
+| 1024×768 | 0.52% | 0.87% | 0.191/0.311/0.553 vs 0.190/0.312/0.553 |
+| 390×844 | 0.77% | 1.63% | 0.231/0.430/0.576 vs 0.231/0.432/0.577 |
+
+What still differs, all seen in the images rather than inferred: rain streaks fall at a different phase
+(the kit freezes one frame after t = 28); the compass reads `355°` where the reference's still page never
+ran its readout and shows `N`; the frame-rate readout shows a number instead of `—`; the sound pill sits
+lower on short windows and beside the key hints under 800 px of height (the reference draws the panel over
+it at 1024×768); on phones the icon buttons are 44 px and the subtitle is hidden under 480 px. Three real
+look differences were found by this comparison and fixed: a transposed fbm rotation (terrain ridges,
+puddles and cloud shapes drifted; desktop difference 1.16% → 0.41%), a bloom pass that never got its
+resolution and bloomed every highlight mirrored about the horizon, and the sky sampled upside down.
+
+## Sandbox demo — Storm Chaser (2026-10-01)
+
+A cold, user-like game on the kit, outside the workspace: `sandbox/rain-demo` in the shared sandbox repo
+(commit `fbc56a8`, local, not pushed), scaffolded with `--template rain` from tarballs packed off feat/rain
+at 74c910a4f (`sandbox/.packages/rain/*-rain-.tgz`). The game on top: log readings at two survey lamps,
+then reach the field station within 75 s; a lightning strike within 500 m while you are in the open costs
+one of three nerves, and a lamp post is shelter. Rules `src/chase.ts`, HUD `src/ui/ChaseHud.tsx`, light
+pillars `src/render/beacons.ts` (the engine's `Billboard3D`), wired through the kit's `strike()` and frame.
+
+| Scenario | Target | Result |
+| --- | --- | --- |
+| `playtests/chase-win.playtest.json` | browser, headed WebGPU (NVIDIA) | pass — lamp 1, lamp 2, station at their labelled steps; won with 71.3 s left; player moved 85.1 m; zero diagnostics |
+| `playtests/chase-lose.playtest.json` | browser, headed WebGPU (NVIDIA) | pass — three strikes in the open: nerve 3 → 0, lost (`nerve`); "Run it again" restores 3 and `playing` |
+| `native-playtests/chase-win.playtest.json` | `--target desktop`, feat/rain's built `mystral` host | pass — same route and outcome; flashes are suppressed there (the web view reports reduced motion), so the round is the clock and the route |
+
+Friction (`sandbox/rain-demo/FRICTION.md`), fixed at its layer in feat/rain 1b326f19f: capability search
+answered "countdown timer that ends the round" with `TracerPool3D` (the `Scheduler` now carries that
+situation; recall row `rain.round-time-limit` red → green); every scaffold's `.gitignore` now ignores the
+asset pipeline's compiled outputs (only starter had such rules, and missed the bake receipt; a new scaffold
+test was red for rain); and the kit's `AGENTS.md` now says where gameplay attaches. Not fixable in a lane:
+`pnpm build:desktop` fails at its last step on the unpublished 0.3.4 prebuilt (`prebuilt-lock.json` HTTP
+404), so the native run used the engine checkout's host. Captures:
+[desktop](../../../verification/PRD-473/sandbox-desktop-1440x900.jpg),
+[tablet](../../../verification/PRD-473/sandbox-tablet-1024x768.jpg),
+[mobile](../../../verification/PRD-473/sandbox-mobile-390x844.jpg),
+[gameplay](../../../verification/PRD-473/sandbox-gameplay-1440x900.jpg),
+[native desktop](../../../verification/PRD-473/sandbox-native-desktop-1280x720.jpg).
+
+## Blocked on
+
+- The template's human visual score in `docs/verification/visuals/scores.json` is the owner's taste call
+ (João); rain has none, like sailing, puzzle and runner. Nothing in this PRD's proofs claims one.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| Kit registration | `create-threenative --template rain` | new kit, no incumbent | AC-1 |
+| Weather control surface | panel / keys → intents → `intentPatch` → `Boot.ts` uniforms | the source's inline DOM handlers | AC-1, AC-4 A |
+| Volumetric clouds | `noise-volume.ts` → `clouds-shader.ts` pass → world sky and reflections | generated kit source | Phase 2, AC-5 |
+| Storm post chain | `postprocessing.ts` → one authored `RenderChain` stage | the source's BLOOM + POST passes | Phase 2, AC-5 |
+| Storm audio | `src/audio/storm.ts` → `AudioBus` (compressor) | the source's Web Audio graph | AC-2, Phase 3 |
+| Capture | playtest captures; `window.tempest.capture` web-gated | the source's `canvas.toDataURL` | AC-4 F |
+
+## Decisions
+
+- 2026-09-30 (agent, this PRD): the seeded 3D noise volume generator is **generated kit source**, not a
+ `packages/core` export — app maths a game could write portably, and it decides a look.
+- 2026-09-30 (agent): the earlier `## Blocked on` entry claiming `api.github.com` was unreachable was
+ withdrawn as unproven; the desktop-native run was then run (Phase 3).
+- 2026-09-30 (agent, this PRD): runtime audio synthesis is not portable (no `AudioContext` on the native
+ host), so the same DSP is baked offline into committed WAVs played through `AudioBus`.
+- 2026-09-30 (agent): the source's raw-WebGL2 second driver is withdrawn; the engine's renderer already
+ models WebGPU and WebGL2.
+- 2026-09-30 (agent): the source's `canvas.toDataURL` button stays web-gated; every target's still image
+ comes from the playtest capture path.
+- 2026-10-01 (agent, this PRD): the study's look is its own ray-march, not `Heightfield`/`WaterSurface3D`/
+ `RippleField`/`Atmosphere`/`Daylight`/`GPUParticles3D`, which the first plan named. Each was inspected:
+ none draws an SDF coast, its reflections march or a stateless camera-wrapped rain, and rebuilding the
+ study on them would change its look rather than port it. The earlier abstraction map is replaced above.
+- 2026-10-01 (agent, this PRD): the source's fixed per-tier drawing-buffer scale (0.55/0.8/1/1.25) is not
+ ported; the engine's adaptive resolution (`resolutionScale: "auto"`) measures the frame instead. The
+ per-tier cloud resolution, steps, rain budget and reflection switch are ported exactly.
+- 2026-10-01 (agent, this PRD): keyboard shortcuts live in the game's input map, not a browser keydown
+ listener — the native host's web view never has keyboard focus (probe: X and Space did nothing natively).
+ `M` (sound on/mute) is added so sound is reachable from the keyboard on every target.
+- 2026-10-01 (agent, this PRD): on the native desktop host the web view reports `prefers-reduced-motion:
+ reduce`, so the storm boots with flashes suppressed there; `native-playtests/lightning.playtest.json`
+ asserts that and turns flashes on with `X`. It is a separate file from `playtests/lightning.playtest.json`
+ because the desktop runner has no click injection and evaluates no visual assertions.
+
+## Execution Phases
+
+### Phase 1 — The kit exists and the storm answers the player
+
+**Status:** DONE (2026-10-01)
+
+Results (2026-10-01, a project scaffolded from local tarballs, headed `--browser-recipe webgpu`,
+adapter NVIDIA Turing, zero console/network/runtime diagnostics in every run):
+- `playtests/storm.playtest.json` pauses the simulation, then clicks Drizzle, drags the
+ Precipitation slider and drag-looks: 92.9% of pixels change, against 0.48% for the same pause with
+ no input (`playtests/paused.playtest.json`, elapsed unchanged). Preset `drizzle`, target and
+ rendered rain 0.24 at the Drizzle step, ≥ 0.8 after the slider, heading 355° → 308°.
+- `playtests/fly.playtest.json`: W+Shift for 60 ticks moves z 18 → −4.9 (23 m/s sprint), E for 30
+ ticks y 2.85 → 5.85, a drag turns the heading; 86.1% of pixels change. The wip camera read
+ `input.axis()` for key bindings and the raw pointer counter the tick had already spent, so
+ sprint, Q/E and drag-look had never worked; it now reads `pressed()` and a `pointerRelative`
+ binding.
+- `survives` moves the registered player 6.0 m; `weather`, `automation`, `simulation` pass.
+- The sky was sampled upside down because the generator's flip rule never matched the
+ transpiler's spacing; the rule is fixed and the shader regenerated.
+- The render-layer files `palette/sky/lighting/materials.ts` were another kit's leftovers with no
+ importer; they now decide the storm's haze, sky, sun, fill, flash light, lamp and sea colours
+ as shader uniforms (boot capture unchanged: mean delta 0.16/255).
+
+**Files:** `packages/create-threenative/templates/rain/` — `kit.json`, `src/{main.ts,game.ts,state.ts,scenes/Boot.ts}`,
+`src/render/{palette,camera,sky,lighting,materials,postprocessing,quality}.ts`,
+`src/ui/{App,GameUi,Hud,Menu,LoadingOverlay}.tsx`, `playtests/{storm,paused,fly,cinematic,survives,performance,loading}.playtest.json`; registration
+in `.github/workflows/ci.yml`, the capability-recall corpus, the conventions table and `README.md`.
+
+- [x] Phase 1: the scaffolded rain kit boots into a storm whose panel, presets and fly camera change the rendered frame. proof: AC-1 command above (pass, 92.9% changed vs 0.48% null), `fly.playtest.json` (pass), `pnpm exec tsx scripts/visual-gate.ts --structural-only` (exit 0, rain passes) and
+ `pnpm exec tsx scripts/check-template-quality.ts` (exit 0, 11 templates agree)
+
+### Phase 2 — Sky, sea and rain: the look, built from generated source
+
+**Status:** DONE (2026-10-01)
+
+Results: `tiers.playtest.json` switches the panel's quality select through all four tiers and reads back
+what was drawn — cloud target 864×540 (high, 0.6), 691×432 (balanced, 0.48), 576×360 (performance, 0.4),
+1080×675 (ultra, 0.75) of a 1440×900 frame at 64/48/32/88 march steps, drops 9120/9120/4940/12160
+(= round(budget × 0.76)); every tier capture shows coast, clouds, sea and rain, and `performance` drops
+the reflection march. The kit-vs-reference comparison (AC-5) found and fixed three look defects
+(transposed fbm `mat2`, the mirrored and unsized bloom, the inverted sky). `performance.playtest.json`
+at 1920×1080: p95 frame 4.7 ms, ≤ 11 draws, ≤ 36,865 triangles.
+**Files:** `templates/rain/src/render/{noise-volume,clouds,clouds-shader,world,world-shader,rain,
+postprocessing,bloom-shader,post-shader,quality}.ts` · `tools/generate-shaders.mjs` ·
+`playtests/tiers.playtest.json` · `scripts/render-chain-capabilities.ts` · the capability-recall corpus and
+budget · regenerated manifests · `docs/verification/visuals/rain/kit-*.jpg`
+
+- [x] Phase 2: the cloud volume, sea, wet road and rain streaks render at every tier inside the declared bounds, from generated source rather than a new engine export. proof: `pnpm exec tsx scripts/check-template-quality.ts` (exit 0); `pnpm build` regenerates the manifests (exit 0, 2026-10-01); `searchCapabilities`/`capabilityDetail` find the five Rain abstractions at existing `template:rain` paths and `pnpm caps:recall` exits 0 with every `rain.*` row recalled (66/73 overall); and
+ `node packages/playtest/dist/runner/cli.js /playtests/tiers.playtest.json --url http://127.0.0.1:5373 --server-command "pnpm --dir exec vite --host 127.0.0.1 --port 5373 --strictPort" --browser-recipe webgpu --headed`
+ — pass, captured at all four tiers (`docs/verification/PRD-473/tier-*.jpg`)
+
+### Phase 3 — Lightning, thunder, and the finished study
+
+**Status:** DONE (2026-10-01)
+
+Results: browser `lightning.playtest.json` as in AC-2. The native desktop scout's "interface but no storm"
+was the kit's stylesheet painting `body`: the native UI is a web view over the game, so an opaque body hid
+it (red-green on the desktop host — background restored, UI over a flat `#0e1921`; removed, the storm under
+the UI). The shortcuts X/H/Space never reached the native web view and now live in the game's input map.
+`native-playtests/lightning.playtest.json` on the desktop host: a strike at boot is suppressed (this web
+view reports reduced motion), `X` turns flashes on, `M` sound on, `L` strikes (status `flashing`, strikes
+1), thunder is pending at 36 ticks and one `thunder` cue sounds after it, delay 0.9968 s; the capture
+(`docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg`) shows the bolt, the lit coast and the
+toast "LIGHTNING · 0.34 km · thunder in 1.0 s". `native-playtests/boot.playtest.json` passes with the
+`stormPost` stage at tier high (`docs/verification/PRD-473/native-desktop-boot-1280x720.jpg`).
+**Files:** `templates/rain/src/render/lightning.ts` · `src/scenes/Boot.ts` · `src/game.ts` · `src/audio/storm.ts` ·
+`src/ui/` · `playtests/{lightning,flash,reduced-motion,auto-lightning,auto-lightning-clear}.playtest.json` ·
+`native-playtests/{boot,lightning}.playtest.json` · `packages/playtest` (`reducedMotion`)
+
+- [x] Phase 3: a strike flashes the sky, lights the scene and schedules its thunder, the safety switches suppress it, and the same scenario passes natively. proof: `--target browser` run of
+ `playtests/lightning.playtest.json` (pass) and `--target desktop` run of `native-playtests/lightning.playtest.json`
+ (`node packages/playtest/dist/runner/cli.js /native-playtests/lightning.playtest.json --target desktop --executable packages/runtime-native/build/tn-linux/mystral --project --host-arg run --host-arg /.threenative/build/game.js --host-arg --ui --host-arg /.threenative/build/ui`, after `pnpm native:build` exit 0) — pass
diff --git a/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md b/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md
index ed6ca2ceda..edf04b3bbb 100644
--- a/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md
+++ b/docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md
@@ -1,5 +1,37 @@
# Release readiness — 2026-09-23
+## Status 2026-10-02
+
+**The 0.3.4 cohort is published; `alpha:bar` A1 passes (6 of 7, A6 deferred).** `runtime-native-v0.3.4`
+is a finalized release (promotions #385, #386, #387 merged). On npm: `core`, `physics`, `playtest`,
+`runtime-native`, `ui` 0.3.4; `assets` 0.3.5; `raw-unreal`, `ueformat`, `threenative-blender-mcp` 0.1.4;
+`threenative-engine-mcp` 0.2.4; `@threenative/metahuman` 0.1.0 (new); `create-threenative` 0.2.8.
+`scripts/verify-registry-install.ts` against the registry passes 22/22 (npm and pnpm, including
+`doctor`, `native`, `android`, `mcp`).
+
+What it took, so the next cut does not repeat it:
+
+- **The hosted `npm-release` run cannot publish.** Its N-1 upgrade proof asserts frame time and
+ visuals, which fail on the runner's SwiftShader adapter (run 36969644927). The cohort was published
+ locally with `pnpm release --skip-gates --yes` on a GPU host, behind a load gate (frame p95 read 34 to
+ 112 ms against the 33 ms budget while other lanes ran; it passed once load stayed under 8). The gate
+ was not changed. PRD-196's last box (hosted `clean-room` run) stays open for the same reason.
+- **`doctor` demanded one version across all `@threenative` packages**, but the cohort ships `assets`
+ at 0.3.5 on purpose, so the registry clean-room failed on `npm:doctor`. It now compares the
+ major.minor series (`d43e0016e`); that needed `create-threenative` 0.2.8, published by hand with
+ `pnpm --filter create-threenative publish` because `pnpm release` refuses a partial cohort.
+- **`publish:check` refused the scaffolder tarball**: `templates/rain/tools/verify-noise-volume.mjs`
+ imported a `.js` name for a shipped `.ts` file (`6c8858d74`).
+- Native consumer proof fixes landed on `develop`: software-adapter declaration on the hosted emulator,
+ iOS no longer gates `finalize`, and a retry for dropped adb logcat transports.
+
+The local publish carries no npm provenance (not CI). The Android registry proof needs JDK 17 and
+`ANDROID_HOME`; the default JDK 27 on the operator machine fails Gradle.
+
+**Still open:** a `develop` to `main` promotion carrying the doctor and rain fixes, PRD-064's web
+60 fps arm, PRD-366 physical devices, PRD-399, the `fast-uri` highs (no fixed upstream release), and
+a way to run the upgrade proof on hosted CI (a GPU lane, or a deliberate software-adapter design).
+
## Status 2026-10-01
Inspected on `develop` at `ffe9986f5` (`origin/develop` fetched 2026-10-01). Only the rows in
@@ -239,5 +271,5 @@ These fold into [PRD-445](../BLOCKED/requires-release-credentials/PRD-445-public
- **New (2026-10-01):** PRD-064's status line still reads 2026-09-25 and does not record PR #361's
2026-09-30 desktop-pair measurement.
-**Next action (under two minutes):** run `pnpm alpha:bar --write` to re-green A7; then decide
-whether to cut the 0.3.4 cohort, whose `v*` tag push is PRD-196's last box.
+**Next action (under two minutes):** promote `develop` to `main` (merge commit) so `main` carries the
+doctor and rain fixes; the cohort itself is published (see Status 2026-10-02).
diff --git a/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md b/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md
index d435ac00f6..7426531ce6 100644
--- a/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md
+++ b/docs/PRDs/production-readiness/critical/PRD-196-published-install-is-functional.md
@@ -426,6 +426,11 @@ census test fails, and `pnpm publish:check` refuses the tree.
observed red on 2026-09-26 with the `sdkmanager` line deleted. Not yet executed: no `v*` tag
push has run this job since the provisioning landed, so the step is proven locally and the
hosted run is still the missing proof.
+ 2026-10-02: the `v0.3.4` tag push (run 36969644927, `2b6887fe`) failed in `publish` before
+ `clean-room` ran, because the N-1 upgrade proof asserts frame time and visuals and cannot
+ pass on the runner's SwiftShader adapter (`TN_PLAYTEST_SOFTWARE_ADAPTER`). The cohort was
+ published locally instead and the same verifier passed 22/22 there with JDK 17
+ (`npm:android` and `pnpm:android` included), so the box stays open until a hosted run reports it.
**Wiring:**
diff --git a/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md b/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md
new file mode 100644
index 0000000000..c7ab754fd6
--- /dev/null
+++ b/docs/PRDs/tooling/PRD-482-the-local-agent-loop-costs-only-what-it-catches.md
@@ -0,0 +1,98 @@
+---
+prd_contract: v1
+---
+
+# PRD-482 — The local agent loop costs only what it catches
+
+**Status:** PARTIAL — phases 1 and 2 verified; AC-1 audits the 7 days after this lands
+**Complexity:** 2 (LOW)
+**Owner:** CI tooling
+**Depends on:** [PRD-480](../CI/PRD-480-linux-ci-runs-on-the-owner-machine.md) for the push rule's wording
+
+## Context
+
+Audit on 2026-10-02 of what an agent pays between starting a task and pushing it:
+
+- **The pre-push hook is red on clean `develop`, so agents bypass it.** `scripts/ci-fast.sh` took 42 s
+ and failed 3 of its 4 stages on `01bc7c687`:
+ - `drift` (33 s of it): `template.spec.ts` typechecks scaffolds against the physics package's built
+ `dist`, which is stale (`TS2305 VehicleBody3D`). The script's own header promises "nothing that
+ needs a full workspace build".
+ - `lint`: `biome check .` has no `vcs.useIgnoreFile`, so it lints git-ignored files such as
+ `.leanpi/goal.json`.
+ - `docs`: real broken links from `40919491b`.
+
+ The stages run serially, and 22 of 368 pushes in the past week set `TN_SKIP_PREPUSH=1`.
+- **Pushes cancel running boards.** From 2026-09-18 to 10-02, 349 PR runs were cancelled by a newer
+ push, burning 43.1k runner-min, 37% of all CI time
+ ([PRD-481](../CI/PRD-481-ci-does-each-piece-of-work-once.md) context). 56 of those pushes changed
+ only Markdown, and 37 of them were PRD ticks.
+- **Context injected on every turn.** `.claude/hooks/ponytail-context.mjs` runs on SessionStart,
+ every UserPromptSubmit and every SubagentStart (`.claude/settings.json:8,20,31`), about 400 tokens
+ per user turn. Root `AGENTS.md` adds 1,816 words, and `packages/playtest/AGENTS.md` adds 5,071 words
+ whenever playtest work loads it. The `gate:*` commands named in root `AGENTS.md:91` were
+ effectively never run in transcripts since 2026-09-25.
+
+## Solution
+
+- Make the hook what its header says: seconds, green on a clean tree, run in parallel. A hook that
+ is red on `develop` teaches agents to skip it.
+- Put the push rule in `AGENTS.md`: while your PR's CI is running, commit locally and push only to fix
+ that run or once it finishes; a PRD tick rides with the next code push, never alone.
+- Inject ponytail once per session and on compaction, not on every prompt.
+- Move reference material out of the always-loaded files.
+
+## Acceptance Criteria
+
+- [ ] AC-1 [shared]: proof: the PRD-481 audit script over the 7 days after phase 2 lands. Runner-minutes
+ lost to PR runs cancelled by a newer push drop below 10k a week, from 21.5k. Evidence: pending.
+
+## Decisions
+
+- 2026-10-02 (João): cut any step that costs time or tokens without catching problems; speed and
+ reliability are the only criteria.
+
+## Execution Phases
+
+#### Phase 1: The pre-push hook is green and fast
+
+**Status:** COMPLETE
+**Files:** EDIT `scripts/ci-fast.sh` (stages in parallel; scaffold typecheck moves to `ci:local` and CI),
+`biome.json` (`vcs.useIgnoreFile`), `docs/PRDs/UI/PRD-native-overlay-utility-styling.md` (broken links).
+
+- [x] `pnpm ci:fast` passes on a clean `develop` checkout in under 15 s wall. proof: `time pnpm ci:fast`.
+ Evidence: 2026-10-02 on `af7e25333` plus this branch, all four stages pass, 9.2 s wall (was 42 s and
+ 3 of 4 red). The biome and link fixes landed directly on `develop` in `af7e25333` to unblock the
+ backlog push. A never-built checkout still needs one workspace build: three drift specs import
+ packages' `dist`.
+- [x] The scaffold typecheck still runs before merge. proof: the `test` job log of one CI run lists
+ `template.spec.ts`. Evidence: CI run 37049488719 (2026-10-02), job `test-unit (1/3)` 110991025981:
+ `✓ packages/create-threenative/__tests__/template.spec.ts (38 tests) 102118ms`. The unit shards run
+ plain `vitest run`, which this PR does not touch.
+
+#### Phase 2: Instructions and injections carry only what agents use
+
+**Status:** COMPLETE
+**Files:** EDIT `AGENTS.md` (push rule; `gate:*` lines move to `docs/architecture/` or the scripts' `--help`),
+`.claude/settings.json` (ponytail hook on SessionStart and compact only), `packages/playtest/AGENTS.md`
+(reference sections move to a linked doc), the `CLAUDE.md` mirrors.
+
+- [x] `AGENTS.md` states the push rule. proof: `pnpm sync:agents --check` and
+ `pnpm exec vitest run scripts/__tests__/sync-agent-docs.spec.ts scripts/__tests__/primary-docs.spec.ts`.
+ Evidence: 2026-10-02, "Push once per run" in the Pull requests list; `pnpm sync:agents` wrote the mirror;
+ sync-agent-docs, primary-docs and instruction-budget specs 24 passed.
+- [x] The ponytail hook fires only on SessionStart and compaction. proof: `jq '.hooks | keys' .claude/settings.json`
+ lists neither `UserPromptSubmit` nor `SubagentStart` for it.
+ Evidence: 2026-10-02, `jq '.hooks | keys'` prints `["SessionStart"]`; SessionStart has no matcher, so it
+ also fires on compaction.
+- [x] `packages/playtest/AGENTS.md` is under 2,000 words. proof: `wc -w packages/playtest/AGENTS.md`, and
+ `pnpm check:docs` passes with every moved section linked.
+ Evidence: 2026-10-02, 5,256 → 1,113 words. Twelve reference sections (flags and exit codes, the
+ private-Xvfb frame rate, `assert.tone[]`, the four targets, `perf`, `audio`, startup
+ observations, `deviceMetrics`, the room and the feet, `assert.causedBy`, `setup`/`aimAt`, tick
+ semantics and the startup wait) moved verbatim into `packages/playtest/docs/reference.md` and
+ `docs/determinism.md`, each linked from the section it left; a coverage diff over every line of
+ the 5,256-word file finds no paragraph missing. `pnpm check:docs` 2,504 links / 1,223 files
+ clean, `pnpm sync:agents --check` 22 mirrors in sync, sync-agent-docs + primary-docs +
+ instruction-budget + check-doc-links 36 passed, `packages/playtest/__tests__/doc-drift.spec.ts`
+ 2 passed, `pnpm lint` exit 0.
diff --git a/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md b/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md
index 531919c13d..12cda0b5ac 100644
--- a/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md
+++ b/docs/PRDs/unreal-like-features/PRD-473-open-worlds-hold-120-fps-gpu-driven.md
@@ -69,7 +69,7 @@ Risks:
- [ ] AC-2 [local]: world batches and static chunks replay from render bundles. A 200-frame walk re-records bundles only on key mint/retire, and per-frame JS in the render phase for the world is ≤ 1.5 ms at 400 world draws. proof: `world-bundles.spec.ts` (bundle version counters) plus `TN_FRAME_SPANS` on machinefall.
- [ ] AC-3 [local]: a foliage asset gets an automatic octahedral impostor as its last LOD level, drawn beyond the chain, casting into the coarse shadow levels. Past the impostor distance, triangles per tree are 2. proof: `world-impostors.spec.ts` plus a visual-baseline capture at the switch distance.
- [ ] AC-4 [local]: the cook bakes a per-cell HLOD proxy for hand-placed chunks, and WorldCells draws it beyond the HLOD distance: one draw per material group per cell. proof: `packages/assets/__tests__/hlod.spec.ts` plus the `TN_WORLD_CHUNK_MERGE` / `TN_WORLD_HLOD` markers on machinefall.
-- [ ] AC-5 [local]: machinefall `?scene=map-walk` holds CPU frame p95 ≤ 8.3 ms and GPU p95 ≤ 8.3 ms on the RTX 2080 WebGPU adapter, with 0 console errors and no game-side performance option. proof: the walk harness's `TN_FRAME_BUDGET` windows.
+- AC-5 (map-walk at 120 fps) moved to [PRD-475](PRD-475-open-world-120-fps-without-visual-loss.md) on 2026-10-01 (João): this PRD keeps the mechanisms, PRD-475 owns the frame target and the no-visual-loss check.
- [ ] AC-6 [local]: every mechanism falls back to PRD-458's CPU path on a backend without compute/indirect support, reporting why in `TN_WORLD_GPU_SCENE`. proof: a unit test with a backend lacking `drawIndexedIndirect`.
## Phases
@@ -88,7 +88,9 @@ The largest CPU and GPU win, and the prerequisite for bundles.
### Phase 3 — Impostors (AC-3) and HLOD (AC-4)
They are independent; the executable plan is below. Impostors are not implemented in the visibility pass.
-### Phase 4 — Measure and tune (AC-5)
+### Phase 4 — Measure and tune
+
+Moved to PRD-475 with AC-5 (decision 2026-10-01, João).
## GPU-driven main-pass visibility (bugfix + continuity)
diff --git a/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md b/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md
new file mode 100644
index 0000000000..19996c730c
--- /dev/null
+++ b/docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md
@@ -0,0 +1,147 @@
+# PRD-475 — Machinefall's open world at 120 fps with no visual loss
+
+**Status:** PARTIAL — merged at prd:25% on João's 2026-10-02 call; the walking p95 boxes (AC-1, AC-2, Phase 2 targets, Phase 3 guard) continue in PRD-478
+**Complexity:** 5 (MEDIUM) — 6–10 engine files (+2), engine and Machinefall release separately (+2), GPU-pass state (+1 risk); risk override: none
+**Owner:** João
+**Depends on:** PRD-473 (merged at 75% in #375 as `a602467db`)
+
+## Context
+
+Machinefall's streamed world (`?scene=map-walk`) is the open-world proof. #375 restored its look: forest shadows on road and floor, and the full forest after a camera jump. Impostors went back to opt-in because their card dropped the forest's shadow. That look, at develop `a602467db`, is the reference this PRD must not lose. The owner said "it should be FAST + look AAA": no frame-time win may cost the look.
+
+Where it stands, per PRD-473:
+
+- **GPU:** p50 9–15 ms after the GPU scene went default-on.
+- **CPU:** window p95 13.2 ms.
+- **What limits the frame:** the GPU and present wait. The main thread is mostly idle; the main pass draws 16–27 M triangles; the wide shadow level spends 11–19 ms of per-draw JS.
+- **Timing was not claimed:** under the box's load (~30), two identical walks read render p50 21.4 vs 9.0 ms.
+- **Bundles:** measured no gain and are opt-in, because a bundle bakes `visible` and lost gate-hidden trees.
+
+Develop's recent cuts the game has not picked up yet:
+
+- PRD-458 world streaming (`4f9638c2e`).
+- PRD-473's terminal coarse LOD. `LOD_GENERATOR_VERSION` 2 needs a re-cook.
+- PRD-462's spread material checks: frame p50 10.4–13.6 → 3.5–5.9 ms on the colour lane.
+- The one-palette skinned draw (`0107bc7d0`).
+
+Machinefall still pins pre-develop tarballs:
+
+- main: `pr375-binding-054d0f866554`.
+- `feat/prd-458-engine-pin`: `pr375-stabilize-2a776d82d056`.
+
+No develop-tip tarball exists.
+
+Instruments, all shipped:
+
+- `TN_FRAME_BUDGET` (`packages/core/src/frame-budget.ts`) splits the frame into phases (`update|render|overlay|ui|residual|hostGap`).
+- The same marker gives per-pass GPU times (`gpuMain|gpuShadow|gpuOther|gpuCompute`) as timestamp-query p50/p95, plus draws and triangles per pass.
+- `playtest perf` (`packages/playtest/src/runner/perf.ts`) prints them.
+- Under the private Xvfb, fps is suppressed: the present wait lands in update, with a ~50 ms swap floor. CPU is therefore read from the phase partition and GPU from timestamps, never from fps.
+
+## Solution
+
+1. **Sync, then measure on develop.** Pack core and assets from develop with `pnpm --filter pack`, and pin Machinefall's `apps/client` to them, including `pnpm.overrides`. Re-cook the world with `pnpm world:post`. Then record the baseline: the frame phases and the per-pass GPU split on the Turing WebGPU adapter. Capture the same-pose reference images at the same time.
+2. **Cut what the baseline ranks first, in the engine.** Every fix lives in `packages/core/src/` behind the convention that the engine decides; Machinefall gets no performance option. Candidates, each kept only if it moves its own meter and passes the visual A/B:
+ - Shadows: redraw a clip level only when its casters or the light moved, instead of every frame. Draw the wide levels from the GPU scene's cull.
+ - Main pass: triangle cost per screen pixel: LOD gates, and an impostor that casts the forest's shadow. That is the condition under which impostors may come back.
+ - CPU: per-draw JS on the shadow and main passes. Bundles return only if they keep gate-hidden trees hidden.
+3. **Hold it.** The map-walk scenario asserts the budget, so a regression reds the game's own test.
+
+Quiet-machine rule: compare timings only with load average below 4. Interleave arms (A, B, A, B) over at least 3 runs each. When load stays high, record the run as unverified, never as a result.
+
+Risks:
+
+- A cut that reads as "faster" can be a cut that draws less. Every Phase 2 box therefore carries the same-pose A/B, and draws and triangles are compared alongside milliseconds.
+- Caching shadow levels can leave stale shadows on moving casters. The A/B includes a pose taken while walking.
+
+## Acceptance Criteria
+
+- [ ] AC-1 [local]: Machinefall `?scene=map-walk` CPU frame p95 ≤ 8.3 ms. CPU means the summed phases excluding the present wait, over 3 interleaved runs on the RTX 2080 WebGPU adapter, with 0 console errors and no game-side performance option. proof: `TN_FRAME_BUDGET` windows via `node packages/playtest/dist/runner/cli.js perf` on the map-walk run — Evidence: pending.
+- [ ] AC-2 [local]: the same walk's GPU p95 ≤ 8.3 ms, from timestamp-query `gpu` p95 (`gpuMain + gpuShadow + gpuOther + gpuCompute`). proof: the same `TN_FRAME_BUDGET` windows — Evidence: pending.
+- [x] AC-3 [local]: no visual loss against develop `a602467db`. Fresh blind raters score the after-captures no lower than the reference on all 4 map-walk and all 4 map-views poses, including the road and forest-floor shadows. proof: `pnpm visuals:ab --before --after --raters 3` — Evidence: engine `d1e6e1796` (core cut12f, machinefall `910d49f`), 3 blind raters through `pnpm visuals:ab`, before = develop walk captures plus settle-gated views (`artifacts/prd-475/ab4-before`), after = `artifacts/prd-475/cut12g/after`: 7 poses equal (v1-v4 4/4, walk-01 4/4, walk-02 3/3, walk-03 4/4), walk-04 a win 3 → 4, no loss; duplicate-pair spread 0 (`artifacts/prd-475/ab5/score.json`). Model raters, not the human session VISUAL-BASELINE.md describes.
+- [x] AC-4 [local]: no tree or shadow pops in on approach. A fine capture series along the map-walk highway leg shows no element appearing or swapping LOD within 40 m of the camera. proof: map-walk capture series plus a fresh judge subagent — Evidence: 42 captures 2.3 m apart along the highway (`artifacts/prd-475/cut12f/pop`) against the same series on develop (`pop/base`); a fresh judge found 0 candidate-only pops within 40 m, popping the SAME as develop, and the road forest shadows match frame for frame. Road-band luminance over frames 10-20 is 25.933 % vs develop 25.940 %.
+
+## Blocked on
+
+- Presented 120 fps on a real 120 Hz+ display. Xvfb's swap floor cannot show it. Unblocked by João running `?scene=map-walk` on the desktop monitor and reading the fps overlay.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+|---|---|---|---|
+| Develop engine in the game | Machinefall `apps/client/package.json` `file:` tarball pins and `pnpm.overrides` | Replaces the `pr375-*` pins | Phase 1 box 1 |
+| World frame cost | `?scene=map-walk` → `WorldCells.update` (`packages/core/src/world-cells.ts`) → GPU scene and `VirtualShadowNode` (`packages/core/src/render/virtual-shadow.ts`) | Changed in place; the CPU path stays as the fallback for backends without indirect draws | AC-1, AC-2 |
+| Budget regression guard | Machinefall `apps/client/playtests/scenes/map-walk.playtest.json` `assert.performance` | Tightens the current `maxFrameMsP95: 250` | Phase 3 box 1 |
+
+## Decisions
+
+- 2026-10-01 (João): a follow-up PRD targets 120 fps with no visual compromise ("FAST + look AAA"). Impostors stay opt-in until they cast the forest's shadow.
+- 2026-10-01 (João, via this PRD): PRD-473's AC-5 (map-walk at 120 fps) moves here. PRD-473 keeps its mechanisms: GPU cull, bundles, impostors, HLOD and fallback.
+- 2026-10-01: the visual reference is develop `a602467db`, the look restored in #375, and not the older `pr375-binding` build.
+- 2026-10-01: a merged super-tile was drawn a whole `blockOrigin` from the tiles it replaced — its geometry is written relative to the block origin and the mesh was left at (0, 0, 0), so every block away from the origin showed as floating slabs over a hole where the ground is; unit tests only ever merged an island at the origin, where the two agree.
+- 2026-10-02 (João): stop tuning and "stabilize and merge what you can". This PRD merges with its shipped cuts: idle frame p50 2.6–4.8 ms vs develop 5–7, walking render p50 7.0–8.4 vs 8.3–10.4, and AC-3 holding. Walking p95 stays at 19–26 ms (develop 18–25) and moves to PRD-478 (frame architecture: per-draw refresh only on changed inputs, streaming in a worker, GPU-driven shadow casters). The boxes stay open here rather than being ticked on unmet targets.
+- 2026-10-02: the bridge-pose van shading is a capture race, not a shipped regression. The van band reads 30.89 / 31.05 / 31.47 across repeated captures of the same build, and develop `a602467db` itself read 31.0472 in `artifacts/prd-475/bisect-van2`. Our builds land on 31.47 more often (cut12i 3/3, cut12k 2/3, cut12m 1/3); the cause is open in PRD-478 alongside the shadow-level cadence.
+
+## Execution Phases
+
+#### Phase 1: Sync Machinefall with develop and record the baseline
+**Files:** Machinefall `apps/client/package.json`, `pnpm-lock.yaml`, and the re-cooked `public/world/*`; `docs/verification/runtime-perf-state.md` (perf findings update in place).
+
+- [x] Machinefall runs develop's engine: the core and assets tarballs packed from develop `a602467db` or later are pinned, the world is re-cooked, and map-walk passes. proof: `pnpm test:scenes` (map-walk) in Machinefall, 0 console errors. — machinefall `feat/prd-475-develop-engine` `2ba8f21`. Core 0.3.4 and assets 0.3.5 tarballs from `a602467db`; installed dist tree hash equals the engine build. map-walk ran 5 times on nvidia/turing with 0 console errors; 4 green, 1 red on `state.failures` 1, the unlogged cell failure in Phase 2. map-views is green with 4 distinct poses.
+- [x] The baseline is recorded in `docs/verification/runtime-perf-state.md`. It covers the CPU phases p50/p95 and the `gpuMain/gpuShadow/gpuOther/gpuCompute` p50/p95, with draws and triangles per pass, over 3 quiet-machine map-walk runs. The same-pose reference captures (4 walk, 4 views) are saved as the AC-3 `--before` directory. proof: `node packages/playtest/dist/runner/cli.js perf` on the runs. — 3 runs at load 3.4–3.8: frame p50 12.3 / p95 26.3 ms, CPU render 12.1 / 26.1 ms, GPU 7.4 / 11.3 ms (main 5.6, shadow 1.1), draws main 318 and shadow 200. References are in `artifacts/prd-475/reference/` (gitignored): 4 walk and 4 views.
+
+**Status:** DONE. The CPU render phase leads: render p50 is 1.6× GPU p50, and update/overlay/ui read 0. Phase 2 therefore starts with CPU attribution.
+
+**Verification:** the scenario run plus the perf report. The ranking of costs decides the order of Phase 2.
+
+#### Phase 2: Cut the frame where the baseline says, in the engine
+**Status:** IN PROGRESS
+**Files:** `packages/core/src/render/virtual-shadow.ts`, `packages/core/src/world-cells.ts`, `packages/core/src/world-gpu-scene.ts`, plus their specs. These are expected, not fixed: the baseline names the real ones.
+
+Budget split, to be confirmed against the baseline: shadows 2.5 ms GPU, main 5 ms GPU, CPU render phase 4 ms.
+
+- [ ] Shadow passes: map-walk `gpuShadow` p95 ≤ 2.5 ms. The shadow pass's share of CPU render time halves against the baseline, with a red-green spec on the mechanism and AC-3's A/B at the four walk poses. proof: `pnpm exec vitest run packages/core/__tests__/` plus `playtest perf`.
+- [ ] Main pass: map-walk `gpuMain` p95 ≤ 5 ms, with a red-green spec on the mechanism and the same A/B. proof: the mechanism's spec plus `playtest perf`.
+- [x] A failed world load names itself: `WorldCells` prints the asset or cell and the error behind a `TN_WORLD_CELL_FAILURE` marker instead of only counting it (`world-cells.ts` `#startAssetLoad`, `#startChunkLoad`, `#attachChunks`). proof: a red-green spec in `packages/core/__tests__/` with a rejecting loader. — `packages/core/__tests__/world-cells.spec.ts::WorldCells > names a refused world load in a TN_WORLD_CELL_FAILURE marker`, red without the marker, green with it; core 2184/2184.
+- [ ] CPU render phase p95 ≤ 4 ms on map-walk, with a red-green spec on the mechanism and the same A/B. proof: the mechanism's spec plus `playtest perf`.
+
+**Verification:** each cut is measured A/B against the build before it, interleaved and on a quiet machine. A cut that fails its meter, or the visual A/B, is reverted and recorded under Decisions.
+
+Cut 1 (terrain super-tiles, PRD-473 plan): landed in ec9a739ad, b9442ec5a, bc998f510, 8349962ce, 0421ed329; Machinefall A/B pending.
+Cut 1 fix: Machinefall correctness pair (same build, merge off/on) found the ring shrinking 289 → 248 because block bytes were charged to tile admission; blocks are now reported (`blockBytes`), not charged — 68a3691d2. Terrain draws 289 → 33–42, main draws p50 289–343 → 158–258.
+Cut 1 fix 2: a block kept drawing the ground of a tile that had left it — a second copy at the level the tile left, for as many frames as the one-per-frame rebuild queue took to reach it — and counted that tile twice, so the `tiles` stat drifted off 289; a departure now dissolves its block for the frames before the rebuild brings it back — f8a990abe; per-frame walk invariant `world-terrain-merge-walk.spec.ts` (red at frame 18, green after; draws stay under the unmerged ring's 81).
+Cut 2 (memoised shadow-caster table): landed in d47a24dfc; Machinefall A/B pending.
+Cut 1 fix 3: a merged block's mesh sat at the world origin while its vertices were block-local, so every block but (0,0) drew displaced ("floating slabs", white ground) — `890578e81`, two specs comparing every block vertex through `matrixWorld` with its tiles'.
+Cuts 1+2 in Machinefall (`machinefall@8107d51`, core `prd475-cut12c`), clean machine (load < 3, GPU < 10%), 4+4 interleaved against develop: frame p50 9.55 → 7.20 ms, frame p95 20.55 → 17.22 ms, render p95 20.45 → 17.10 ms, GPU p50 6.95 → 7.12 ms, GPU p95 10.93 → 10.20 ms, main draws 335.5 → 246.5; 8/8 map-walk pass, 0 console errors. Fresh blind same-pose judge, 8 poses against `artifacts/prd-475/reference/`: SAME overall (6 same, 1 better, views-v2 noted for vehicle variants and slightly thinner left forest).
+Cut 3 (per-frame resource version bumps): measured, not fixed — there are none to stop. A throwaway probe in the game's bundled `three` counted `Textures.updateTexture` past its version guard at **0 calls** and `_copyCompressedBufferToTexture` at **0** over a 8400-frame map-walk (`machinefall@8107d51`), so nothing is re-uploaded per draw; `Source.needsUpdate` and `BufferAttribute.needsUpdate` (which `StorageBufferAttribute` inherits, so the GPU scene's `source`/`keys`/`locals`/`args` are counted) peak at 0.5 and 0.6 per 1000 frames, all from real writes: `mergeParts` cloning a geometry during a streamed chunk merge, and GLTFLoader/KTX2Loader loading a newly resident asset. `NodeMaterialObserver.needsRefresh` ran 0.33 times per 1000 frames, so the per-draw node-state refresh already takes its settled fast path. The `attrib2` profile agrees: `_copyCompressedBufferToTexture` is 156.6 ms of 27.6 s total, **0.57%** inclusive, not the 26% / 2.95 ms per frame the stack summary suggested — its samples are ancestors of a per-draw path that is already short-circuiting, and the 34.1% `getNodeBuilderState` figure is the per-draw `needsRefresh` check itself. No engine change, no commit, no Machinefall A/B.
+Cut 4 (per-level shadow caster culling): measured, not built — three already culls per level, so a batch-bounds cut would be a second cull of the same volumes. R1: a caster batch is **per `(asset,level,part) × world-grid square`** (`#casterFor`, `world-cells.ts:5537`, key `${key}@${cluster}`), not per cell and not key-wide; the wide half (`#wideFor`, `:5553`, key `@*`) is deliberately ring-wide and is only drawn by a level whose window covers the ring. Each carries **its own** world bounds — `#rebound` unions the per-block AABBs read from the written instance bytes (`:1148`, `#boxOf` `:1167`), published as `mesh.boundingSphere` — and `frustumCulled = true` (`:1079`, restored `:1737`). R2: three decides, in `_projectObject` (`Renderer.js:3132`), `frustum.intersectsObject(object)` against the level's own `shadow.camera` — the very ortho window the cut proposes to test by hand; a level's camera is `±extent` X/Y with the depth `#deriveDepth` brackets conservatively toward the light (`virtual-shadow.ts:1509`). R3: measured with a throwaway probe through that exact path — 17 cluster batches on a line at 24 m spacing, level 0 (48 m window) kept **1**, level 1 (192 m) kept **5**, and a tall caster beyond the box *toward the light* was still kept (`near=1`, `far=41.4`, intersects `true`), so the existing depth derivation already refuses the false negative the cut's own rule warns about. Two brief premises were wrong and are corrected here: `frustumCulled = false` at `world-cells.ts:5952`/`:5956` is `#dressGpu`, reached only for `role === "main"` (`#adoptGpu` `:5843`), and main meshes keep `castShadow = false` on layer 0 (`#dressMesh` `:5744`) — so **no caster batch is GPU-dressed** and the main camera's dispatch (`world-gpu-scene.ts:4132`, ortho early-return above it) never sets a caster's count. The 257 draws a level submits are batches three's own per-level test already kept. No engine change, no commit, no Machinefall A/B.
+Visual fixes found on the way (all red-green): GPU-scene activation dropped placements sliced before the renderer arrived (`d91709b44`, Sol); cached shadow levels never re-rendered when casters left, joined or stopped casting, which was pre-existing on develop (`0c9b264ff`, `07f617495`); cut 2's probe loop kept two callback `return`s, so the first skipped coarse caster ended the probe and the 96 m/320 m levels lost the forest's road shadows (`d1e6e1796`, Sol). Machinefall's map-views drains on streaming settle, not on the in-flight count (`machinefall@13976d8`): develop also lost the v2 forest when the screenshot landed mid-load.
+Cut 5 (chunk shadow consolidation, 7376879e1, f015f6cbe, b3d746a24): retained opaque instanced casters go from 5 layer-0 draws to 0 plus 2 side proxies with identical per-level triangle inputs, unchanged main draws and eviction cleanup; AutoLOD remains excluded. In Machinefall (core cut12h) putting merged proxies on the wide half too (7376879e1) drew merged slabs into coarse levels, and the bridge deck self-shadowed as wide acne stripes (map-views v3, walk-04); an ablation build of 7376879e1 alone reproduces it, develop does not. b3d746a24 keeps merged proxies cluster-only as on develop; retained proxies keep both halves (they stand in for layer-0 casters every level drew). cut12i (machinefall `c5baf56`): blind 3-rater judge 8/8 poses equal to develop (`artifacts/prd-475/ab7`), pop series SAME (0 pops each, road band 25.929 % vs 25.940 %), coarse-level layer-0 casters 148 → 65 at 320 m in cut12h. Timing A/B pending.
+Cut 6 (repeated chunk parts instanced, 78280560c, reverted bf1e22e5b): in its unit fixture, 18 → 4 main and layer-0 candidates with identical inputs; in Machinefall (core cut12j) main draws went 225 → 171, but the bridge-deck acne returned. Cut 6 takes meshes that carry extra vertex channels (tangents, colours) out of the material merge so it can instance them, and the deck then cast through a retained proxy into coarse wide levels. Without that skip, 9 of its 10 specs lose their candidates. Kept out until merge-skipped parts cast like merged ones.
+Timing (clean-ab3, develop vs cut12i, 4+4 interleaved, quiet gate): idle at the final pose frame p50 2.3 vs 5.5-6 ms, but walking CPU render p95 per window 40-70 vs 17-34 ms in all 4 pairs; cut12f vs cut12i (2 pairs) puts it on cut 5: 23-24 vs 34-37 ms.
+Cut 5 index fix (e6ce09623): retained proxies keep one index geometry per level camera and rewrite it only when that level's selection changes (red: 12 index-version bumps per 3-level sequence, green 0). cut12k (machinefall `eabf0a7`) vs develop, 4 pairs: walking render p95 19-26 vs 18-24 ms, walking p50 7.0-8.4 vs 8.3-10.4, idle frame p50 2.6-4.8 vs 5-7, main draws 208-297 vs 297-327; blind 3-rater judge 8/8 equal (`artifacts/prd-475/ab8`); pop road band 25.940 % = develop. Gates: core 2219 passed, typecheck 0, lint 0 errors.
+Walking tail attribution (unminified trace, cut12k): 390 of 1491 rAFs over 12 ms (p95 21.6 ms). Inside them: three's per-draw loop ~50 %, WorldCells update ~28 % (terrain follow 11 %, admit 9.7 %), VirtualShadow level renders ~15 %, node builds ~9 %. Level 0 (24 m) re-renders while moving and submits 270-690 draws (cluster 206-283 + layer0 93-309): the walking plateau. Next lever: GPU-driven shadow casters / level-0 cost.
+Shadow invalidation on caster change: both sides were broken — a level held its map across a caster leaving or joining the world and across `castShadow`/`visible` flipping, which is the ghost shadow the map-views camera jump leaves behind, because cut 2's `childadded`/`childremoved` listener only marked the caster table stale and nothing asked the affected level to redraw; `#onTreeChanged` now also measures the changed child and `invalidateRegion`s the levels covering it, and a per-frame `#pollCasters` reads `visible`/`castShadow` off the memoised table (one flag pair per caster, no walk) for the changes that fire no event.
+
+GPU source continuity fix (2026-10-02, source/unit lane; complexity 3, LOW): a queued build could consume CPU placement slices before GPU activation, then publish without those slices' source records. `WorldCells.update` now restarts pending builds through `#seedGpuSources` before dressing the keys, returning unpublished CPU claims and retaining outgoing batches. Red: `world-gpu-scene.spec.ts` "seeds source records for a build sliced before the renderer arrived" received 164 placements against the CPU's 262. Green: four activation timings (1, 3, 5, 8 CPU units), with shadows and one fresh mesh per update, match the CPU's source matrices before and after eviction/readmission and its main-pass instance total; a multi-part partial-eviction test also keeps every placement drawable. Self-review: one activation reset, unchanged CPU fallback, existing admission queue and synchronous buffer growth reused. Gates: `pnpm exec vitest run packages/core` 2208 passed, 2 skipped; `pnpm typecheck` exit 0 after building missing local package artifacts; `pnpm lint` 0 errors, 992 warnings, exit 0; `pnpm quality` 181 findings, exit 0. The separate browser matrix owns visual proof; Phase 2's performance and visual boxes stay open.
+
+Cut 2 coarse-probe fix (2026-10-02, source/unit lane): converting the caster traversal callback to a loop left two `return`s behind, so an alpha caster or a mesh without bounds ended the whole probe before later casters, layer selection and depth collection. Both now `continue`. `virtual-shadow-caster-draws.spec.ts` adds nested streamed casters, cluster/wide choices, moving cameras and caster transforms, and a missing-bounds case: all 3 red with `expected 0 to be 1`, all green after the two-line fix. A temporary comparison against `d47a24dfc^` matched all 12 coarse-level probe/depth/layer/candidate-draw observations after the fix; the specs also keep the selected trunk inside the 96/320 m shadow frustums. Gates: core 2211 passed, 2 skipped; typecheck exit 0; lint 0 errors, 992 warnings, exit 0; quality 181 findings, exit 0. Self-review: callback skips now skip one mesh; no API or scheduling change. Machinefall shadow recovery and performance remain unverified in this lane; the browser owner must run the same-pose A/B, so Phase 2 and AC-3 stay open.
+
+Active-blend scan fix: `world-tiles-cost.spec.ts` red: expected `Map.values` not called, got 1. The active-tile Set removes a 25-tile walk from every blend-count read and makes transition advancement visit active tiles only; settled rendered positions stay identical. `pnpm exec vitest run packages/core/__tests__/world-tiles-cost.spec.ts packages/core/__tests__/world-terrain-merge-walk.spec.ts --maxWorkers=1`: 10 passed, 2 benchmark skips, exit 0.
+
+Neighbor-LOD scan fix: `world-tiles-cost.spec.ts` red: 75 resident-map lookups, expected 25. Target changes already coordinate the resident ring; the extra neighbor pass is now required only for newly admitted tiles. Green: 25 lookups (50 saved/follow), identical settled positions; terrain cost and merge-walk specs 11 passed, 2 benchmark skips, exit 0.
+
+Frame-work follow-up (2026-10-02): the shared measured frame budget (c13e35a5e: streaming after the draw, deferred in shadow frames, forced within 3) was reverted in c823efa91. Machinefall cut12l (core `7deb743a39daa13e`) clean-ab5 pairs 1-3: walking render p95 26-35 ms vs develop 21.5-24.7, p50 no better; blind ab9 7/8 equal, 1 LOSS (walk-04 bridge: cliff shadow missing on the vans) that a recapture did not repeat (van-band luminance 30.8937 = develop); pop road band 25.940 = develop, pop judge: no late objects. Deferring stacked the backlog rather than spreading it. The two exact scan cuts above stay.
+
+Render-loop node-key fix (2026-10-02, source/unit lane): reuse `three/tsl`'s stock constant shadow-colour node across VirtualShadowNode's per-level materials. Red: 9 prewarmed casters held 2 node states; green: 1, gate cache misses 34 → 21 (compiler stubbed), with unchanged source index ranges, materials and instance counts per pass. All 39 settled submissions still refresh inside three; 0 render-object creations/geometry swaps and 0 late builds for prewarmed keys. Gates: core 2246 passed, 2 skipped; typecheck exit 0; lint 0 errors, 1012 warnings, exit 0; quality 183 findings, exit 0. Browser timing and same-pose GPU output remain unverified; Phase 2 stays open.
+
+Shared shadow colour (2026-10-02): `1299c6a91` shares three's constant shadow colorNode across the virtual levels, so identical level shaders reuse one node-builder state (`world-cells-shadow-prewarm.spec.ts` red: 2 states for 9 casters, expected 1; loading-gate cache misses 34 → 21). cut12n blind ab11: 7 equal, 1 win (develop's bridge-deck capture missed trees), 0 losses; pop band 25.940 = develop.
+
+#### Phase 3: Hold 120 fps with the look intact
+**Status:** NOT STARTED
+**Files:** Machinefall `apps/client/playtests/scenes/map-walk.playtest.json`; this PRD.
+
+- [ ] map-walk's own scenario reds when CPU or GPU p95 exceeds 8.3 ms. It uses the performance assertion on the render and GPU series, not fps, which Xvfb suppresses. proof: `pnpm test:scenes` green on the candidate and red on the Phase 1 baseline build.
+- AC-1 to AC-4 above close this phase.
+
+**Verification:** AC-1 to AC-4 evidence, then `pnpm typecheck && pnpm lint && pnpm test`, and the Machinefall pin moved to the final tarball.
diff --git a/docs/architecture/CHARTER.md b/docs/architecture/CHARTER.md
index 0687a1de76..08c87a1942 100644
--- a/docs/architecture/CHARTER.md
+++ b/docs/architecture/CHARTER.md
@@ -270,7 +270,11 @@ geometry, placement, colour and material all come from the game); **`clipPoseErr
`clipTrackBindings`, `clipBoneCoverage`, `boneContact`** (instruments that drive a clip, read the
pose and report degrees, names and metres — they select no clip and move nothing); and
**`Heightfield`** (one row-major buffer, interpolation, normals, and the ordering physics consumes;
-every height comes from the game's own sampler); **`FlightModel`** (lift, drag, thrust, stall,
+every height comes from the game's own sampler); **`SnowField`** (indentation, displaced bank,
+compaction and disturbance channels over one canonical `Heightfield`, load-dependent penetration
+and bounded sparse recovery; the game supplies the terrain, the snow depth, the contact footprint,
+the load and every response coefficient, and no renderer or GPU encoding is involved);
+**`FlightModel`** (lift, drag, thrust, stall,
control moments and the carrier-deck run for a fixed-wing aircraft; every mass, wing area, power,
inertia, wind and damage multiplier comes from the game, which the model moves but never dresses);
and **automatic discrete LOD** (error-driven level selection for a model the asset cook baked a
diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md
index b4db25cf5c..809cd31c92 100644
--- a/docs/guides/getting-started.md
+++ b/docs/guides/getting-started.md
@@ -24,7 +24,7 @@ pnpm dev
Open the local URL that Vite prints. The default template is `starter`, a game with a React HUD.
This guide uses `minimal`, which draws its HUD in the scene. Other templates include
-`platformer`, `action-rpg`, `defense`, `racing`, `sailing` and `shooter`.
+`platformer`, `action-rpg`, `defense`, `racing`, `sailing`, `rain`, `shooter` and `snow`.
If you use npm, pass template flags after `--`:
diff --git a/docs/verification/PRD-289-conventions-2026-08-31.md b/docs/verification/PRD-289-conventions-2026-08-31.md
index 9da7fba1af..d41c57ad69 100644
--- a/docs/verification/PRD-289-conventions-2026-08-31.md
+++ b/docs/verification/PRD-289-conventions-2026-08-31.md
@@ -19,10 +19,12 @@ table, reads each template's `AGENTS.md`, and checks the TypeScript AST for the
| platformer | N/A — the fox run places every body through its own physics character and keeps no generated conventions module | N/A — every model is authored in metres already and no generated conventions module rescales it | N/A — the template has no held object or character hand | N/A — procedural rig motion has no AnimationClip asset |
| puzzle | N/A — the vault keeps its own sandbox look and places crates and the warden through physics, with no generated conventions module | N/A — every model is authored in metres already and no generated conventions module rescales it | N/A — the claw carries crates through physics velocity, not a bone attachment | N/A — no skinned or animated asset is loaded |
| racing | N/A — vehicle suspension and snap-to-ground own floor contact | `src/conventions.ts:5` | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded |
+| rain | N/A — the camera flies; no walking actor needs floor contact | N/A — procedural geometry is authored in metres; no imported model needs scaling | N/A — there is no held object or character hand | N/A — no skinned or animated asset is loaded |
| rts | N/A — every unit is placed by the simulation at its own ground height, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded |
| runner | `src/conventions.ts:19` | `src/conventions.ts:18` | N/A — the runner carries nothing and has no character hand | N/A — the jump is an authored arc, not an AnimationClip |
| sailing | N/A — the ship is waterborne and uses buoyancy, not floor grounding | `src/conventions.ts:9` | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded |
| shooter | N/A — the soldier's floor contact is measured against the town's own collider boxes, and a corpse is settled onto the deck it fell on | `src/entities/Enemy.ts:863` | `src/entities/Enemy.ts:869` | `src/entities/Rifle.ts:121` |
+| snow | N/A — the procedural explorer plants each boot on the canonical snow surface (`src/entities/Explorer.ts`), and no authored model is grounded | N/A — every body is built from primitives in metres; no authored model is loaded | N/A — the template has no held object | N/A — the gait is procedural IK, not an AnimationClip |
| starter | `src/conventions.ts:15` | `src/conventions.ts:14` | N/A — the template has no held object or character hand | N/A — the native proof asset is static and has no AnimationClip |
| tower-defense | N/A — every tower, walker and pad is placed by the simulation at ground level, so there is no rendered body to keep on a floor | N/A — every model is procedural in metres already, so no authored asset needs normalising | N/A — the template has no held object or character hand | N/A — no skinned or animated asset is loaded |
diff --git a/docs/verification/PRD-469/ac1-ac2-contact-shapes.png b/docs/verification/PRD-469/ac1-ac2-contact-shapes.png
new file mode 100644
index 0000000000..d443fab274
Binary files /dev/null and b/docs/verification/PRD-469/ac1-ac2-contact-shapes.png differ
diff --git a/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg b/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg
new file mode 100644
index 0000000000..ead251af1f
Binary files /dev/null and b/docs/verification/PRD-469/physics-compaction-view-webgpu.jpg differ
diff --git a/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg b/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg
new file mode 100644
index 0000000000..86af41df08
Binary files /dev/null and b/docs/verification/PRD-469/physics-sphere-track-box-capsule-dents-webgpu.jpg differ
diff --git a/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg b/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg
new file mode 100644
index 0000000000..6745bb02ee
Binary files /dev/null and b/docs/verification/PRD-469/physics-sphere-track-native-desktop-1280x720.jpg differ
diff --git a/docs/verification/PRD-469/reference-icefield-1024x768.jpg b/docs/verification/PRD-469/reference-icefield-1024x768.jpg
new file mode 100644
index 0000000000..c27e0d6a8c
Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-1024x768.jpg differ
diff --git a/docs/verification/PRD-469/reference-icefield-1440x900.jpg b/docs/verification/PRD-469/reference-icefield-1440x900.jpg
new file mode 100644
index 0000000000..4988f856aa
Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-1440x900.jpg differ
diff --git a/docs/verification/PRD-469/reference-icefield-390x844.jpg b/docs/verification/PRD-469/reference-icefield-390x844.jpg
new file mode 100644
index 0000000000..642f0a353a
Binary files /dev/null and b/docs/verification/PRD-469/reference-icefield-390x844.jpg differ
diff --git a/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg b/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg
new file mode 100644
index 0000000000..7cb0bc0cd1
Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-mobile-webgpu-390x844.jpg differ
diff --git a/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg b/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg
new file mode 100644
index 0000000000..21fb49d220
Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-won-native-desktop-1280x720.jpg differ
diff --git a/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg b/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg
new file mode 100644
index 0000000000..5cf323d2d8
Binary files /dev/null and b/docs/verification/PRD-469/sandbox-snow-putt-won-webgpu-1280x720.jpg differ
diff --git a/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg b/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg
new file mode 100644
index 0000000000..1c724b2a5b
Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-1024x768.jpg differ
diff --git a/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg
new file mode 100644
index 0000000000..4ac83dc56a
Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-1440x900.jpg differ
diff --git a/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg b/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg
new file mode 100644
index 0000000000..78c6793c54
Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-390x844-touch.jpg differ
diff --git a/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg
new file mode 100644
index 0000000000..718a3b2b4c
Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-blizzard-1440x900.jpg differ
diff --git a/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg b/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg
new file mode 100644
index 0000000000..6aa17cfb77
Binary files /dev/null and b/docs/verification/PRD-469/snow-kit-webgpu-surface-view-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/compare-desktop-1440x900.jpg b/docs/verification/PRD-473/compare-desktop-1440x900.jpg
new file mode 100644
index 0000000000..3a93499f91
Binary files /dev/null and b/docs/verification/PRD-473/compare-desktop-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/compare-mobile-390x844.jpg b/docs/verification/PRD-473/compare-mobile-390x844.jpg
new file mode 100644
index 0000000000..ad6ef24626
Binary files /dev/null and b/docs/verification/PRD-473/compare-mobile-390x844.jpg differ
diff --git a/docs/verification/PRD-473/compare-tablet-1024x768.jpg b/docs/verification/PRD-473/compare-tablet-1024x768.jpg
new file mode 100644
index 0000000000..e636b54d42
Binary files /dev/null and b/docs/verification/PRD-473/compare-tablet-1024x768.jpg differ
diff --git a/docs/verification/PRD-473/lightning-strike-1440x900.jpg b/docs/verification/PRD-473/lightning-strike-1440x900.jpg
new file mode 100644
index 0000000000..e5e31b7082
Binary files /dev/null and b/docs/verification/PRD-473/lightning-strike-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/loading-curtain-1024x768.jpg b/docs/verification/PRD-473/loading-curtain-1024x768.jpg
new file mode 100644
index 0000000000..75688ae512
Binary files /dev/null and b/docs/verification/PRD-473/loading-curtain-1024x768.jpg differ
diff --git a/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg b/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg
new file mode 100644
index 0000000000..f394fce9d9
Binary files /dev/null and b/docs/verification/PRD-473/native-desktop-boot-1280x720.jpg differ
diff --git a/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg b/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg
new file mode 100644
index 0000000000..3f5e35aca2
Binary files /dev/null and b/docs/verification/PRD-473/native-desktop-lightning-1280x720.jpg differ
diff --git a/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg b/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg
new file mode 100644
index 0000000000..4646cd954e
Binary files /dev/null and b/docs/verification/PRD-473/sandbox-desktop-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg b/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg
new file mode 100644
index 0000000000..95b9cad34f
Binary files /dev/null and b/docs/verification/PRD-473/sandbox-gameplay-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/sandbox-mobile-390x844.jpg b/docs/verification/PRD-473/sandbox-mobile-390x844.jpg
new file mode 100644
index 0000000000..fc8b14db97
Binary files /dev/null and b/docs/verification/PRD-473/sandbox-mobile-390x844.jpg differ
diff --git a/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg b/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg
new file mode 100644
index 0000000000..b22a11953b
Binary files /dev/null and b/docs/verification/PRD-473/sandbox-native-desktop-1280x720.jpg differ
diff --git a/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg b/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg
new file mode 100644
index 0000000000..4b172e20e0
Binary files /dev/null and b/docs/verification/PRD-473/sandbox-tablet-1024x768.jpg differ
diff --git a/docs/verification/PRD-473/tier-high-1440x900.jpg b/docs/verification/PRD-473/tier-high-1440x900.jpg
new file mode 100644
index 0000000000..fb55bd0fbd
Binary files /dev/null and b/docs/verification/PRD-473/tier-high-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/tier-performance-1440x900.jpg b/docs/verification/PRD-473/tier-performance-1440x900.jpg
new file mode 100644
index 0000000000..66526bc30a
Binary files /dev/null and b/docs/verification/PRD-473/tier-performance-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/tier-ultra-1440x900.jpg b/docs/verification/PRD-473/tier-ultra-1440x900.jpg
new file mode 100644
index 0000000000..d0c97d8a00
Binary files /dev/null and b/docs/verification/PRD-473/tier-ultra-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/ui-help-1440x900.jpg b/docs/verification/PRD-473/ui-help-1440x900.jpg
new file mode 100644
index 0000000000..6e14d0ef37
Binary files /dev/null and b/docs/verification/PRD-473/ui-help-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/ui-hidden-1440x900.jpg b/docs/verification/PRD-473/ui-hidden-1440x900.jpg
new file mode 100644
index 0000000000..0a2563881b
Binary files /dev/null and b/docs/verification/PRD-473/ui-hidden-1440x900.jpg differ
diff --git a/docs/verification/PRD-473/ui-mobile-help-390x844.jpg b/docs/verification/PRD-473/ui-mobile-help-390x844.jpg
new file mode 100644
index 0000000000..c361c5e7b0
Binary files /dev/null and b/docs/verification/PRD-473/ui-mobile-help-390x844.jpg differ
diff --git a/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg b/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg
new file mode 100644
index 0000000000..f8018b3001
Binary files /dev/null and b/docs/verification/PRD-473/ui-mobile-panel-390x844.jpg differ
diff --git a/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg b/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg
new file mode 100644
index 0000000000..54dbfd6cec
Binary files /dev/null and b/docs/verification/PRD-473/ui-rendering-details-1024x768.jpg differ
diff --git a/docs/verification/alpha-bar.md b/docs/verification/alpha-bar.md
index 885fc6b6e1..8594813061 100644
--- a/docs/verification/alpha-bar.md
+++ b/docs/verification/alpha-bar.md
@@ -4,7 +4,7 @@
| # | Alpha requirement | State | What the bar read, and what it said |
|---|---|---|---|
-| A1 | A stranger can install it from the public registry | **red** | `npm view versions --json` — 1 of 12 publishable package(s) are absent from the registry: @threenative/metahuman. Unpublished workspace version(s): @threenative/assets 0.3.5, @threenative/core 0.3.4, @threenative/physics 0.3.4, @threenative/playtest 0.3.4, @threenative/raw-unreal 0.1.4, @threenative/runtime-native 0.3.4, @threenative/ueformat 0.1.4, @threenative/ui 0.3.4, create-threenative 0.2.7, threenative-blender-mcp 0.1.4, threenative-engine-mcp 0.2.4. |
+| A1 | A stranger can install it from the public registry | **green** | `npm view versions --json` — All 12 publishable package(s) are on the registry at their workspace versions. |
| A2 | The golden path completes from published artifacts | **green** | `an alpha-bar evidence block for A2 in docs/verification/` — npx create-threenative@0.2.2 scaffolds, npm install resolves every package from registry.npmjs.org with zero file:/link: specifiers, and npm run build succeeds. (docs/verification/registry-install-2026-08-16.md, produced by: npx --yes create-threenative@0.2.2 my-game --template starter && npm install && npm run build, in a directory with no workspace above it) |
| A3 | Verification cannot report green while asserting nothing | **green** | `an alpha-bar evidence block for A3 in docs/verification/` — The shipped runner exited 1 for an empty assertion set and reported TN_PLAYTEST_SCENARIO_NO_ASSERTIONS with the message "Scenario 'alpha-a3-empty' completed without evaluating any assertions."; the true-positive component.cube.quaternion.0 control passed with exit 0 on the same runner, project and browser recipe. (docs/verification/alpha-a3-2026-08-29.md, produced by: node packages/playtest/dist/runner/cli.js packages/playtest/__tests__/fixtures/alpha-a3-empty.playtest.json --project examples/native-smoke --url http://127.0.0.1:5187 --server-command "pnpm exec vite --host 127.0.0.1 --port 5187 --strictPort" --browser-recipe webgpu --allow-software) |
| A4 | The value claim rests on one measured paired round | **green** | `docs/verification/round-*.md` — Round 9 (2026-08-15) records a measured paired round: platformer/framework 1/2 scenarios, 9/10 rows, platformer/vanilla 0/2 scenarios, 7/10 rows. |
diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md
index 91055cb8a5..369ddacfd0 100644
--- a/docs/verification/native-coverage-2026-08-28.md
+++ b/docs/verification/native-coverage-2026-08-28.md
@@ -8,7 +8,7 @@ targets could not be built and are named below.
| Subsystem | Instrumented lines | Covered | Line coverage |
| --- | ---: | ---: | ---: |
| `src/async/` | 73 | 60 | 82.19% |
-| `src/audio/` | 1223 | 1047 | 85.61% |
+| `src/audio/` | 1471 | 1301 | 88.44% |
| `src/canvas/` | 1334 | 1110 | 83.21% |
| `src/cli/` | 1682 | 1238 | 73.60% |
| `src/fs/` | 235 | 189 | 80.43% |
@@ -23,10 +23,10 @@ targets could not be built and are named below.
| `src/vfs/` | 239 | 195 | 81.59% |
| `src/webgpu/` | 9661 | 7380 | 76.39% |
| `src/webtransport/` | 1391 | 1078 | 77.50% |
-| `src/workers/` | 615 | 524 | 85.20% |
-| **TOTAL** | **24108** | **18917** | **78.47%** |
+| `src/workers/` | 615 | 527 | 85.69% |
+| **TOTAL** | **24356** | **19174** | **78.72%** |
-Source digest: `sha256:26482043df2162d041ffeec0c7448661fde58a299a62285a6728d703b75e6aed`
+Source digest: `sha256:b309d234707d2ecd8ad21f4c86933adb2b1b9d43ed966e2e276f89c33fa4dbad`
The default `pnpm budgets` gate reads this committed measurement without configuring or compiling
the native host. Any native source, native C++ test, CTest registration, or coverage aggregation
diff --git a/docs/verification/native-runtime-census-2026-08-16.md b/docs/verification/native-runtime-census-2026-08-16.md
index 63f5922ca3..4e3c66431d 100644
--- a/docs/verification/native-runtime-census-2026-08-16.md
+++ b/docs/verification/native-runtime-census-2026-08-16.md
@@ -15,16 +15,16 @@ considered, and a KEEP/DELETE verdict.
| Counted area | Lines | Owner | Live proof or caller | Alternative considered | Verdict |
| --- | ---: | --- | --- | --- | --- |
-| `src/` | 57,242 | PRD-045, PRD-047, PRD-048, PRD-050, PRD-053, PRD-116, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `src/physics/native_bindings.cpp:586`; desktop V8 and native runtime commands | Move host shims into each game or delete the native host | **KEEP** — this is the owned native host and its physics boundary. |
-| `conformance/` | 9,409 | PRD-053, PRD-054, PRD-055, PRD-076 | `conformance/run-conformance.mjs`; root `pnpm parity` | Replace cross-target registry/proofs with untested per-game scripts | **KEEP** — shared executable conformance evidence. |
-| `tests/` | 57,463 | PRD-045, PRD-046, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-055, PRD-076, PRD-116, PRD-127, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `tests/physics_actuation_bindings_test.cpp:141`; runtime-native Vitest and native tests | Delete fail-closed tests to reduce the trigger | **KEEP** — removal would conceal regressions. |
+| `src/` | 57,757 | PRD-045, PRD-047, PRD-048, PRD-050, PRD-053, PRD-116, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `src/physics/native_bindings.cpp:586`; desktop V8 and native runtime commands | Move host shims into each game or delete the native host | **KEEP** — this is the owned native host and its physics boundary. |
+| `conformance/` | 9,477 | PRD-053, PRD-054, PRD-055, PRD-076 | `conformance/run-conformance.mjs`; root `pnpm parity` | Replace cross-target registry/proofs with untested per-game scripts | **KEEP** — shared executable conformance evidence. |
+| `tests/` | 57,859 | PRD-045, PRD-046, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-055, PRD-076, PRD-116, PRD-127, PRD-143, PRD-153, PRD-154, PRD-155, PRD-292 | `tests/physics_actuation_bindings_test.cpp:141`; runtime-native Vitest and native tests | Delete fail-closed tests to reduce the trigger | **KEEP** — removal would conceal regressions. |
| `__tests__/` | 936 | PRD-228 | `__tests__/host-gap-gpu-drain.spec.ts`; the runtime-native Vitest suite via `vitest.config.ts` | Fold it into `tests/`, where every other spec in this package lives | **KEEP** — it is one source-reading spec proving the GPU-drain profile stays diagnostic-only and never ships, and it is picked up by the same suite either way. Its one-file directory is an accident of where it landed, not a design; moving it into `tests/` is a tidy-up and not this row's argument. |
-| `scripts/` | 30,903 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-116, PRD-127, PRD-153, PRD-155, PRD-292 | `scripts/verify-desktop-physics.mjs:206-228`; build, platform, and device-condition verifiers | Make every game own packaging, device, and verifier orchestration | **KEEP** — no smaller shared alternative preserves the proof. |
+| `scripts/` | 30,902 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-053, PRD-054, PRD-116, PRD-127, PRD-153, PRD-155, PRD-292 | `scripts/verify-desktop-physics.mjs:206-228`; build, platform, and device-condition verifiers | Make every game own packaging, device, and verifier orchestration | **KEEP** — no smaller shared alternative preserves the proof. |
| `scenarios/` | 109 | PRD-217 | `scenarios/starter-ui-overlay-desktop.playtest.json`; run by the hosted `native-platforms` desktop-core job through `scripts/verify-starter-ui-overlay.mjs` | Keep the starter-HUD input assertion inside the desktop-core job script instead of a standalone scenario file | **KEEP** — the scenario is the portable playtest that proves the starter HUD receives real input on the packed desktop runtime; the hosted desktop-core job is its caller, so the area is one owned proof rather than reusable game content. |
-| `include/` | 5,935 | PRD-046, PRD-047, PRD-053, PRD-116, PRD-143, PRD-153, PRD-292 | `include/threenative/physics_native.h:131`; C ABI consumed at `src/physics/native_bindings.cpp:586` | Add per-game native headers or remove the native C boundary | **KEEP** — the coarse host ABI is the shared boundary. |
+| `include/` | 6,091 | PRD-046, PRD-047, PRD-053, PRD-116, PRD-143, PRD-153, PRD-292 | `include/threenative/physics_native.h:131`; C ABI consumed at `src/physics/native_bindings.cpp:586` | Add per-game native headers or remove the native C boundary | **KEEP** — the coarse host ABI is the shared boundary. |
| `android/` | 3,187 | PRD-045, PRD-048, PRD-050, PRD-053, PRD-054, PRD-153 | Android host sources and `scripts/verify-android-physics-parity.mjs` | Require each game to rebuild Android lifecycle and transport | **KEEP** — required Android packaging and execution plumbing. |
-| `native/` | 8,161 | PRD-046, PRD-049, PRD-116, PRD-143, PRD-292 | `native/physics/src/lib.rs:463`; `native/physics/tests/actuation.rs:324` | Use the web WASM/Rapier backend on native or move physics into games | **KEEP** — this is the native Rust implementation behind the shared API. |
-| Root `CMakeLists.txt` | 2,758 | PRD-047, PRD-048, PRD-050 | `pnpm native:build`; explicit binding target build | Make every game own native dependency discovery and linking | **KEEP** — opt-in host build configuration. |
+| `native/` | 8,580 | PRD-046, PRD-049, PRD-116, PRD-143, PRD-292 | `native/physics/src/lib.rs:463`; `native/physics/tests/actuation.rs:324` | Use the web WASM/Rapier backend on native or move physics into games | **KEEP** — this is the native Rust implementation behind the shared API. |
+| Root `CMakeLists.txt` | 2,759 | PRD-047, PRD-048, PRD-050 | `pnpm native:build`; explicit binding target build | Make every game own native dependency discovery and linking | **KEEP** — opt-in host build configuration. |
| `cmake/` | 328 | PRD-047, PRD-048, PRD-050 | Root CMake configuration includes these platform modules | Duplicate compiler/platform rules in each game | **KEEP** — shared build modules. |
| `CMakePresets.json` | 141 | PRD-047, PRD-048, PRD-050 | `tn-linux` preset used by the native build and binding target | Remove declared presets and use undocumented local flags | **KEEP** — reproducible host presets. |
| `ios/` | 598 | PRD-045, PRD-048, PRD-049, PRD-050, PRD-153 | `scripts/verify-ios-simulator.mjs` and iOS host sources | Make each game own iOS packaging and simulator lifecycle | **KEEP** — shared iOS packaging boundary; this lane made no iOS execution claim. |
@@ -35,7 +35,7 @@ considered, and a KEEP/DELETE verdict.
| `js-engine-versions.json` | 38 | PRD-222 | `tests/js-engine-version-skew.test.mjs`; `scripts/download-deps.mjs` consumes the pinned engine versions | Leave engine/version compatibility as prose or duplicate pins across download scripts | **KEEP** — one fail-closed manifest prevents desktop, Android, and iOS engine pins from drifting silently. |
| `build-matrix.json` | 231 | PRD-235 | `scripts/check-build-matrix.ts` from `scripts/check-budgets.ts:467` via `pnpm budgets`; proven by `scripts/__tests__/check-build-matrix.spec.ts` | Leave "which build directory does this test executable live in" as prose in `AGENTS.md` and tribal knowledge | **KEEP** — the prose answer was already wrong once: a contract test that only links in `build/tn-linux` is invisible to anyone reading `CMakeLists.txt`. This is the machine-readable contract, and the gate cross-checks every preset, cache variable, and test executable it names against CMake, so either side drifting is a named failure. |
| `native-deps.lock.json` | 1,050 | PRD-059 | `scripts/download-deps.mjs` reads it before any acquisition; `tests/dependency-provenance.test.mjs` proves lock coverage | Leave dependency URLs/versions/hashes as inline mutable code in `download-deps.mjs` | **KEEP** — the reviewed immutable payload matrix is what makes reconstruction mean the same bytes; inline authority already served one 404. |
-| **Total** | **178,811** | | `pnpm budgets` current measurement | | **No area rejected.** |
+| **Total** | **180,365** | | `pnpm budgets` current measurement | | **No area rejected.** |
**Regenerated 2026-09-02 for PRD-292 recovery:** the native physics ABI, its contract test, and
the continuous-collision measurement harness changed the counted `src/`, `include/`, `tests/`,
diff --git a/docs/verification/prd-364-draft-audit-2026-09-06.md b/docs/verification/prd-364-draft-audit-2026-09-06.md
index 1377ae840f..93f646bd2e 100644
--- a/docs/verification/prd-364-draft-audit-2026-09-06.md
+++ b/docs/verification/prd-364-draft-audit-2026-09-06.md
@@ -1,6 +1,6 @@
# PRD-364 drafting audit — 2026-09-06
-Scope: planning only at checkout `713ca111`. The [PRD](../PRDs/CI/PRD-364-remove-development-gate-bloat.md) records inspected callers and read-only GitHub run observations. No runtime or workflow implementation changed.
+Scope: planning only at checkout `713ca111`. The [PRD](../PRDs/done/PRD-364-remove-development-gate-bloat.md) records inspected callers and read-only GitHub run observations. No runtime or workflow implementation changed.
## Executed commands
diff --git a/docs/verification/prd341/provenance.json b/docs/verification/prd341/provenance.json
new file mode 100644
index 0000000000..a971ade44d
--- /dev/null
+++ b/docs/verification/prd341/provenance.json
@@ -0,0 +1,144 @@
+{
+ "sourceSha": "0ea7e29a0ff18717d33b8bce3300ec0568fea842",
+ "workflowRun": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36988379892",
+ "artifactId": 11218670853,
+ "artifactDigest": "sha256:8c8b12e370e8f8013804b7701570d284f2ed618b23b692b873e5ec6240110a89",
+ "qualification": "rendered-pixel correctness only; software adapters allowed, no native or hardware-performance claim",
+ "dedicatedGatePassed": true,
+ "fullRequiredCi": "pending",
+ "frames": [
+ {
+ "variant": "underexposed",
+ "file": "underexposed.png",
+ "sha256": "173b18dadda32b33922afacf2bb9596dab752e8273914755204f86617c4b6657",
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 720,
+ "width": 1280
+ }
+ },
+ "pass": false,
+ "tone": [
+ {
+ "code": "TN_TONE",
+ "label": "tone-0.png",
+ "atStep": "rendered",
+ "mean": 89.55505316840278,
+ "p1": 6,
+ "p50": 95,
+ "p99": 136,
+ "clipFraction": 0,
+ "blackFraction": 0.00390625
+ },
+ {
+ "code": "TN_TONE",
+ "label": "after.png",
+ "mean": 89.55505316840278,
+ "p1": 6,
+ "p50": 95,
+ "p99": 136,
+ "clipFraction": 0,
+ "blackFraction": 0.00390625
+ }
+ ],
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED",
+ "message": "Tone 'rendered' mean measured 89.55505316840278; required {\"min\":140,\"max\":210}.",
+ "severity": "error",
+ "observedRuntimePath": "observations.json/tone"
+ },
+ {
+ "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED",
+ "message": "Tone 'rendered' p99 measured 136; required {\"min\":230}.",
+ "severity": "error",
+ "observedRuntimePath": "observations.json/tone"
+ },
+ {
+ "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED",
+ "message": "Tone 'after.png' mean measured 89.55505316840278; required {\"min\":140,\"max\":210}.",
+ "severity": "error",
+ "observedRuntimePath": "observations.json/tone"
+ },
+ {
+ "code": "TN_PLAYTEST_TONE_ASSERTION_FAILED",
+ "message": "Tone 'after.png' p99 measured 136; required {\"min\":230}.",
+ "severity": "error",
+ "observedRuntimePath": "observations.json/tone"
+ }
+ ]
+ },
+ {
+ "variant": "restored",
+ "file": "restored.png",
+ "sha256": "ad77af341b9ab04b61060e3eb8e1904f93b7d9273b6b2bc763e0e6269f0e4dcf",
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 720,
+ "width": 1280
+ }
+ },
+ "pass": true,
+ "tone": [
+ {
+ "code": "TN_TONE",
+ "label": "tone-0.png",
+ "atStep": "rendered",
+ "mean": 170.49870985243055,
+ "p1": 22,
+ "p50": 180,
+ "p99": 254,
+ "clipFraction": 0.0078125,
+ "blackFraction": 0.00390625
+ },
+ {
+ "code": "TN_TONE",
+ "label": "after.png",
+ "mean": 170.49870985243055,
+ "p1": 22,
+ "p50": 180,
+ "p99": 254,
+ "clipFraction": 0.0078125,
+ "blackFraction": 0.00390625
+ }
+ ],
+ "diagnostics": []
+ }
+ ]
+}
diff --git a/docs/verification/prd341/restored.png b/docs/verification/prd341/restored.png
new file mode 100644
index 0000000000..6d62534715
Binary files /dev/null and b/docs/verification/prd341/restored.png differ
diff --git a/docs/verification/prd341/underexposed.png b/docs/verification/prd341/underexposed.png
new file mode 100644
index 0000000000..d2bf903f30
Binary files /dev/null and b/docs/verification/prd341/underexposed.png differ
diff --git a/docs/verification/runtime-perf-state.md b/docs/verification/runtime-perf-state.md
index adf8804546..51c2fc7a4e 100644
--- a/docs/verification/runtime-perf-state.md
+++ b/docs/verification/runtime-perf-state.md
@@ -10,6 +10,26 @@ git history (`git log --diff-filter=D --name-only -- docs/verification/` names t
`git show ^:docs/verification/`). §8 indexes what each one concluded. A claim whose
detail is not in this file exists only in git — quote it with the commit.
+## Machinefall open world on develop: the CPU render phase is the frame — 2026-10-01
+
+PRD-475 Phase 1 baseline (`docs/PRDs/unreal-like-features/PRD-475-open-world-120-fps-without-visual-loss.md`). Machinefall `?scene=map-walk` on develop `a602467db` (core 0.3.4, assets 0.3.5, world re-cooked), browser WebGPU on the RTX 2080 (nvidia/turing), private Xvfb, 3 runs started at load 3.4–3.8. Steady `TN_FRAME_BUDGET` windows; medians across runs.
+
+| Meter | p50 | p95 |
+| --- | --- | --- |
+| frame | 12.3 ms | 26.3 ms |
+| render phase (CPU) | 12.1 ms | 26.1 ms |
+| hostGap | 0.6 ms | 61.9 ms |
+| GPU (timestamp-query) | 7.4 ms | 11.3 ms |
+| gpuMain / gpuShadow | 5.6 / 1.1 ms | — |
+| draws main / shadow | 318 / 200 | 414 / 270 |
+
+- The CPU render phase is the frame: render p50 is 1.6× the GPU p50, and update, overlay and ui read 0. The 120 fps budget (8.3 ms p95) is missed by 3.1× on CPU and 1.4× on GPU.
+- Window fps median 45 (51, 42, 45). One window per run is the post-walk idle at 83–119 fps, which is why a whole-run average reads near 120.
+- The triangle census reads 264 M main and 23 M shadow triangles for about 318 draws, against 9.8 M `mainGpuTriangles`. It is not trusted until it is checked. `gpuShadow` read 0 in one run.
+- One earlier run of five counted 1 failed world load (`WorldCells` `failures`), not reproduced in four reruns. The count records no cell and no error.
+- **After PRD-475 cuts 1+2** (terrain super-tiles, shadow caster table; `890578e81`), clean machine (load < 3, GPU < 10%), 4+4 interleaved runs against develop: frame p50 9.55 → 7.20 ms, frame p95 20.55 → 17.22 ms, GPU p50 6.95 → 7.12, GPU p95 10.93 → 10.20, main draws 335.5 → 246.5. The develop arm's own spread (frame p50 6.4–12.0 ms) overlaps, so the ratio is directional; the branch's distribution is tighter. Still 2.1× over the 8.3 ms p95 on CPU and 1.2× on GPU.
+- **PRD-475 as merged** (2026-10-02, cut12k–n, walk/idle split of `TN_FRAME_BUDGET` windows, quiet gate, 3–4 interleaved pairs against develop `a602467db`): idle frame p50 2.6–4.8 vs 5–7 ms; walking render p50 7.0–8.4 vs 8.3–10.4; walking render p95 19–26 vs 18–25 (unchanged, the 8.3 ms target is not met); main draws 208–297 vs 297–327. A long walking frame (unminified trace, 390 of 1491 rAFs over 12 ms, mean 20 ms) spends ~7.5 ms in three's per-object refresh, ~5.5 ms in WorldCells streaming and ~3 ms in a shadow-level re-render; that tail is PRD-478's. A shared measured frame budget (streaming deferred in shadow frames) made walking p95 worse (26–35 ms) and was reverted.
+
## ThreeNative vs Godot 4.7.1 scoreboard, and three projection fixes — 2026-09-28
PRD-449 (`docs/PRDs/done/PRD-449-cross-engine-benchmarks-and-html-report.md`). One cube scene, native desktop, RTX 2080, 1280×720 uncapped, 40 warm-up + 120 measured frames, and 3 alternating runs per engine (`pnpm bench:scoreboard`). A row is a win only when the median gap beats the run-to-run spread. The dashboard is `pnpm bench:engines:monitor` (`artifacts/engine-load-test/prd-449/progress.html`).
diff --git a/docs/verification/visuals/rain/kit-desktop.jpg b/docs/verification/visuals/rain/kit-desktop.jpg
new file mode 100644
index 0000000000..55be161ba5
Binary files /dev/null and b/docs/verification/visuals/rain/kit-desktop.jpg differ
diff --git a/docs/verification/visuals/rain/kit-mobile.jpg b/docs/verification/visuals/rain/kit-mobile.jpg
new file mode 100644
index 0000000000..e3c84d9f72
Binary files /dev/null and b/docs/verification/visuals/rain/kit-mobile.jpg differ
diff --git a/docs/verification/visuals/rain/kit-tablet.jpg b/docs/verification/visuals/rain/kit-tablet.jpg
new file mode 100644
index 0000000000..3f6e84e90b
Binary files /dev/null and b/docs/verification/visuals/rain/kit-tablet.jpg differ
diff --git a/docs/verification/visuals/rain/reference-desktop.png b/docs/verification/visuals/rain/reference-desktop.png
new file mode 100644
index 0000000000..ed3bb7cf38
Binary files /dev/null and b/docs/verification/visuals/rain/reference-desktop.png differ
diff --git a/docs/verification/visuals/rain/reference-mobile.png b/docs/verification/visuals/rain/reference-mobile.png
new file mode 100644
index 0000000000..77ceea8736
Binary files /dev/null and b/docs/verification/visuals/rain/reference-mobile.png differ
diff --git a/docs/verification/visuals/rain/reference-tablet.png b/docs/verification/visuals/rain/reference-tablet.png
new file mode 100644
index 0000000000..b6e76a107b
Binary files /dev/null and b/docs/verification/visuals/rain/reference-tablet.png differ
diff --git a/docs/verification/vq11-decals-36989297734/motion.png b/docs/verification/vq11-decals-36989297734/motion.png
new file mode 100644
index 0000000000..d2af756406
Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/motion.png differ
diff --git a/docs/verification/vq11-decals-36989297734/static.png b/docs/verification/vq11-decals-36989297734/static.png
new file mode 100644
index 0000000000..c15d6d481e
Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/static.png differ
diff --git a/docs/verification/vq11-decals-36989297734/summary.json b/docs/verification/vq11-decals-36989297734/summary.json
new file mode 100644
index 0000000000..82172ab59a
--- /dev/null
+++ b/docs/verification/vq11-decals-36989297734/summary.json
@@ -0,0 +1,186 @@
+{
+ "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324",
+ "correctnessOnly": true,
+ "results": [
+ {
+ "name": "static",
+ "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png",
+ "diagnostics": []
+ },
+ {
+ "name": "motion",
+ "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0.8042230500706451,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png",
+ "diagnostics": []
+ },
+ {
+ "name": "teardown",
+ "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 128,
+ "capacity": 256,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136140,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": true
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png",
+ "diagnostics": []
+ },
+ {
+ "name": "restart",
+ "sourceSha": "7396738b33a2b3018a2e6235a0e59ca07620a324",
+ "qualified": true,
+ "measurement": {
+ "generation": 2,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png",
+ "diagnostics": []
+ }
+ ]
+}
diff --git a/docs/verification/vq11-decals-36989297734/teardown.png b/docs/verification/vq11-decals-36989297734/teardown.png
new file mode 100644
index 0000000000..f10178395e
Binary files /dev/null and b/docs/verification/vq11-decals-36989297734/teardown.png differ
diff --git a/docs/verification/vq11-decals-36991562324/hidden-decals.png b/docs/verification/vq11-decals-36991562324/hidden-decals.png
new file mode 100644
index 0000000000..6c43259ac7
Binary files /dev/null and b/docs/verification/vq11-decals-36991562324/hidden-decals.png differ
diff --git a/docs/verification/vq11-decals-36991562324/hidden-report.json b/docs/verification/vq11-decals-36991562324/hidden-report.json
new file mode 100644
index 0000000000..5bebf69176
--- /dev/null
+++ b/docs/verification/vq11-decals-36991562324/hidden-report.json
@@ -0,0 +1,65 @@
+{
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36991562324",
+ "artifactId": 11218994493,
+ "artifactSha256": "a84190c40b3cdd6679ff9fea858dd6cfe2373646f090192ee531411fbaba1070",
+ "kind": "historical hidden-material control provenance",
+ "pass": false,
+ "expectedFailureIds": ["visual.1.region.darkPixels", "visual.2.region.darkPixels"],
+ "assertions": [
+ {
+ "id": "visual.0.region",
+ "pass": true
+ },
+ {
+ "id": "visual.1.region",
+ "pass": true
+ },
+ {
+ "id": "visual.1.region.darkPixels",
+ "pass": false
+ },
+ {
+ "id": "visual.1.region.maxDarkPixels",
+ "pass": true
+ },
+ {
+ "id": "visual.2.region",
+ "pass": true
+ },
+ {
+ "id": "visual.2.region.darkPixels",
+ "pass": false
+ },
+ {
+ "id": "visual.2.region.maxDarkPixels",
+ "pass": true
+ },
+ {
+ "id": "component.decals.active.value",
+ "pass": true
+ },
+ {
+ "id": "component.decals.created.value",
+ "pass": true
+ },
+ {
+ "id": "component.decals.geometryBytes.value",
+ "pass": true
+ },
+ {
+ "id": "component.decals.renderTriangles.value",
+ "pass": true
+ },
+ {
+ "id": "component.decals.generation.value",
+ "pass": true
+ },
+ {
+ "id": "diagnostics",
+ "pass": true
+ }
+ ],
+ "originalReportUrl": "https://github.com/ThreeNativeHQ/threenative/blob/e1566472ee439650c74109a92e7fabae1770f71a/docs/verification/vq11-decals-36991562324/hidden-report.json",
+ "note": "Minimal public provenance. Full original report remains in the workflow artifact and the immutable historical commit; no environment or diagnostic payload is republished."
+}
diff --git a/docs/verification/vq11-decals-36991562324/summary.json b/docs/verification/vq11-decals-36991562324/summary.json
new file mode 100644
index 0000000000..036da3a007
--- /dev/null
+++ b/docs/verification/vq11-decals-36991562324/summary.json
@@ -0,0 +1,428 @@
+{
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "correctnessOnly": true,
+ "results": [
+ {
+ "name": "static",
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "motion",
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0.8042230500706451,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.04210648148148148,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5506944444444445,
+ "nonblankPixelRatio": 0.9999729938271605
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.051812865497076026,
+ "nonblankPixelRatio": 0.9997660818713451
+ }
+ ]
+ }
+ },
+ {
+ "name": "teardown",
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 128,
+ "capacity": 256,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136140,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": true
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.07619598765432099,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.6017264660493827,
+ "nonblankPixelRatio": 0.9999787808641976
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ }
+ ]
+ }
+ },
+ {
+ "name": "restart",
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "qualified": true,
+ "measurement": {
+ "generation": 2,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "hidden-decals",
+ "sourceSha": "4cfc3c2024da1cb37533dc67fd07679e364c27f6",
+ "qualified": false,
+ "negativeControlPassed": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 259794,
+ "maxDrawCalls": 4,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ },
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/docs/verification/vq11-decals-37003284983/atlas.png b/docs/verification/vq11-decals-37003284983/atlas.png
new file mode 100644
index 0000000000..e8bb3a7169
Binary files /dev/null and b/docs/verification/vq11-decals-37003284983/atlas.png differ
diff --git a/docs/verification/vq11-decals-37003284983/fading.png b/docs/verification/vq11-decals-37003284983/fading.png
new file mode 100644
index 0000000000..58991affee
Binary files /dev/null and b/docs/verification/vq11-decals-37003284983/fading.png differ
diff --git a/docs/verification/vq11-decals-37003284983/summary.json b/docs/verification/vq11-decals-37003284983/summary.json
new file mode 100644
index 0000000000..0b91e2cedd
--- /dev/null
+++ b/docs/verification/vq11-decals-37003284983/summary.json
@@ -0,0 +1,1071 @@
+{
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "correctnessOnly": true,
+ "results": [
+ {
+ "name": "static",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "motion",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0.8042230500706451,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.04210648148148148,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5506944444444445,
+ "nonblankPixelRatio": 0.9999729938271605
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.051812865497076026,
+ "nonblankPixelRatio": 0.9997660818713451
+ }
+ ]
+ }
+ },
+ {
+ "name": "teardown",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 128,
+ "capacity": 256,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136140,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": true,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.07619598765432099,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.6017264660493827,
+ "nonblankPixelRatio": 0.9999787808641976
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ }
+ ]
+ }
+ },
+ {
+ "name": "restart",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 2,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ },
+ {
+ "generation": 2,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ }
+ ],
+ "residencySamples": 2,
+ "geometryGrowth": 0,
+ "textureGrowth": 0,
+ "memoryGrowth": 0
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "atlas",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/atlas/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5419116512345679,
+ "nonblankPixelRatio": 0.9999787808641976
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.1,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.12,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.4545029239766082,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "fading",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0.49999999999999994,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/fading/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.0277758487654321,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 100,
+ "width": 150,
+ "x": 280,
+ "y": 195,
+ "maxDarkPixelRatio": 0.3,
+ "maxLuminance": 0.65,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.06553333333333333,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 80,
+ "width": 85,
+ "x": 555,
+ "y": 195,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.6,
+ "minDarkPixelRatio": 0.1,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.43441176470588233,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "expired",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 0,
+ "capacity": 256,
+ "created": 321,
+ "released": 321,
+ "geometryBytes": 0,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/expired/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.029544753086419753,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "lifecycle",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": false,
+ "measurement": {
+ "generation": 6,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ },
+ {
+ "generation": 2,
+ "geometries": 258,
+ "textures": 4,
+ "bytes": 6522318
+ },
+ {
+ "generation": 3,
+ "geometries": 258,
+ "textures": 4,
+ "bytes": 6522318
+ },
+ {
+ "generation": 4,
+ "geometries": 258,
+ "textures": 4,
+ "bytes": 6522318
+ },
+ {
+ "generation": 5,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 6,
+ "geometries": 258,
+ "textures": 4,
+ "bytes": 6522318
+ }
+ ],
+ "residencySamples": 6,
+ "geometryGrowth": 1,
+ "textureGrowth": 0,
+ "memoryGrowth": 576
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/lifecycle/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_COMPONENT_ASSERTION_FAILED",
+ "message": "Component 'geometryGrowth' on entity 'decals' did not satisfy the assertion.",
+ "observedRuntimePath": "observations.json/components/decals/geometryGrowth",
+ "severity": "error",
+ "suggestion": "Expected {\"component\":\"geometryGrowth\",\"entity\":\"decals\",\"allowTrivial\":\"GPU resource counts and bytes must not grow across the five warmed scene instances.\",\"lte\":0}, observed before=undefined after=1. Check the owning script and runtime component synchronization."
+ },
+ {
+ "code": "TN_PLAYTEST_COMPONENT_ASSERTION_FAILED",
+ "message": "Component 'memoryGrowth' on entity 'decals' did not satisfy the assertion.",
+ "observedRuntimePath": "observations.json/components/decals/memoryGrowth",
+ "severity": "error",
+ "suggestion": "Expected {\"component\":\"memoryGrowth\",\"entity\":\"decals\",\"allowTrivial\":\"GPU resource counts and bytes must not grow across the five warmed scene instances.\",\"lte\":0}, observed before=undefined after=576. Check the owning script and runtime component synchronization."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "hidden-decals",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": false,
+ "negativeControlPassed": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 259794,
+ "maxDrawCalls": 4,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ },
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "hidden-fading",
+ "sourceSha": "021e622380c61695b8c5dc0e7b9ffc604e956b8c",
+ "qualified": false,
+ "negativeControlPassed": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0.49999999999999994,
+ "residency": [
+ {
+ "generation": 1,
+ "geometries": 1,
+ "textures": 2,
+ "bytes": 6223938
+ }
+ ],
+ "residencySamples": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-fading/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (280, 195) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ },
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (555, 195) contained 0 dark pixels, below required ratio 0.1.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0.029544753086419753,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 100,
+ "width": 150,
+ "x": 280,
+ "y": 195,
+ "maxDarkPixelRatio": 0.3,
+ "maxLuminance": 0.65,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 80,
+ "width": 85,
+ "x": 555,
+ "y": 195,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.6,
+ "minDarkPixelRatio": 0.1,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/docs/verification/vq11-decals-37005420971/summary.json b/docs/verification/vq11-decals-37005420971/summary.json
new file mode 100644
index 0000000000..eb896571ce
--- /dev/null
+++ b/docs/verification/vq11-decals-37005420971/summary.json
@@ -0,0 +1,1006 @@
+{
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "correctnessOnly": true,
+ "results": [
+ {
+ "name": "static",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/static/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "motion",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0.8042230500706451,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/motion/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.04210648148148148,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5506944444444445,
+ "nonblankPixelRatio": 0.9999729938271605
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.051812865497076026,
+ "nonblankPixelRatio": 0.9997660818713451
+ }
+ ]
+ }
+ },
+ {
+ "name": "teardown",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 128,
+ "capacity": 256,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136140,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": true,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/teardown/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.07619598765432099,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.6017264660493827,
+ "nonblankPixelRatio": 0.9999787808641976
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ }
+ ]
+ }
+ },
+ {
+ "name": "restart",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 2,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/restart/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "atlas",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275028,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/atlas/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5419116512345679,
+ "nonblankPixelRatio": 0.9999787808641976
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.1,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.12,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.4545029239766082,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "fading",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0.49999999999999994,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/fading/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.0277758487654321,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 100,
+ "width": 150,
+ "x": 280,
+ "y": 195,
+ "maxDarkPixelRatio": 0.3,
+ "maxLuminance": 0.65,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.06553333333333333,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 80,
+ "width": 85,
+ "x": 555,
+ "y": 195,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.6,
+ "minDarkPixelRatio": 0.1,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.43441176470588233,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "expired",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 1,
+ "active": 0,
+ "capacity": 256,
+ "created": 321,
+ "released": 321,
+ "geometryBytes": 0,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/expired/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.029544753086419753,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "lifecycle",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": true,
+ "measurement": {
+ "generation": 6,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [
+ {
+ "generation": 1,
+ "renderCalls": 56,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 2,
+ "renderCalls": 64,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 3,
+ "renderCalls": 72,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 4,
+ "renderCalls": 80,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 5,
+ "renderCalls": 88,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 6,
+ "renderCalls": 96,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ }
+ ],
+ "residencySamples": 6,
+ "stableResidencyFrames": 3,
+ "geometryGrowth": 0,
+ "textureGrowth": 0,
+ "memoryGrowth": 0
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/lifecycle/after.png",
+ "diagnostics": [],
+ "visual": {
+ "changedPixelRatio": 0.00017939814814814815,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5420428240740741,
+ "nonblankPixelRatio": 0.9999710648148148
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05341176470588235,
+ "nonblankPixelRatio": 0.9994823529411765
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0.05660818713450293,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "hidden-decals",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": false,
+ "negativeControlPassed": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 259794,
+ "maxDrawCalls": 4,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 0
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-decals/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (270, 185) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ },
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (550, 190) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.2,
+ "maxLuminance": 0.35,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ },
+ {
+ "name": "hidden-fading",
+ "sourceSha": "3bb9942c41dfd5c4a8690354373ad9c53259d0ee",
+ "qualified": false,
+ "negativeControlPassed": true,
+ "measurement": {
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": true,
+ "averageOpacity": 0.49999999999999994,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 1
+ },
+ "capture": {
+ "adapter": {
+ "architecture": "swiftshader",
+ "vendor": "google",
+ "features": "bgra8unorm-storage,chromium-experimental-timestamp-query-inside-passes,clip-distances,core-features-and-limits,depth-clip-control,depth32float-stencil8,float32-blendable,float32-filterable,indirect-first-instance,rg11b10ufloat-renderable,subgroup-size-control,subgroups,texture-component-swizzle,texture-compression-astc,texture-compression-astc-sliced-3d,texture-compression-bc,texture-compression-bc-sliced-3d,texture-compression-etc2,timestamp-query",
+ "limit.maxBindGroups": "4",
+ "limit.maxTextureDimension2D": "8192",
+ "limit.maxStorageBufferBindingSize": "1073741824"
+ },
+ "browserArgs": [
+ "--ozone-platform=x11",
+ "--enable-unsafe-webgpu",
+ "--disable-gpu-sandbox",
+ "--ignore-gpu-blocklist",
+ "--enable-features=Vulkan"
+ ],
+ "captureMethod": "page.screenshot",
+ "rendererKind": "webgpu",
+ "target": "web",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "screenshot": "/home/runner/work/threenative/threenative/artifacts/vq11-decals/hidden-fading/after.png",
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (280, 195) contained 0 dark pixels, below required ratio 0.01.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ },
+ {
+ "code": "TN_PLAYTEST_REGION_DARK_PIXEL_RATIO_FAILED",
+ "message": "Screenshot region at (555, 195) contained 0 dark pixels, below required ratio 0.1.",
+ "severity": "error",
+ "suggestion": "Check whether the expected foreground silhouette occupies the asserted raster region."
+ }
+ ],
+ "visual": {
+ "changedPixelRatio": 0.029544753086419753,
+ "comparisonSource": "before-after",
+ "nonblankRegions": [
+ {
+ "height": 540,
+ "width": 960,
+ "x": 0,
+ "y": 0,
+ "minNonblankPixelRatio": 0.15,
+ "darkPixelRatio": 0.5417457561728395,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 125,
+ "width": 170,
+ "x": 270,
+ "y": 185,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 90,
+ "width": 95,
+ "x": 550,
+ "y": 190,
+ "maxDarkPixelRatio": 0.001,
+ "maxLuminance": 0.35,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 100,
+ "width": 150,
+ "x": 280,
+ "y": 195,
+ "maxDarkPixelRatio": 0.3,
+ "maxLuminance": 0.65,
+ "minDarkPixelRatio": 0.01,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ },
+ {
+ "height": 80,
+ "width": 85,
+ "x": 555,
+ "y": 195,
+ "maxDarkPixelRatio": 0.65,
+ "maxLuminance": 0.6,
+ "minDarkPixelRatio": 0.1,
+ "minNonblankPixelRatio": 0.9,
+ "darkPixelRatio": 0,
+ "nonblankPixelRatio": 1
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json b/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json
new file mode 100644
index 0000000000..647538ed3b
--- /dev/null
+++ b/docs/verification/vq11-decals-native-37010879478/lifecycle-report.json
@@ -0,0 +1,54 @@
+{
+ "sourceSha": "563ab23e9448a993013cb0d58c75ab6ab83917c9",
+ "artifactDirectory": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/lifecycle",
+ "assertionResults": [
+ {
+ "details": {
+ "consoleErrors": 0,
+ "networkErrors": 0,
+ "policy": {
+ "networkErrorsOptOutReason": "The run target has no network observer; its network observation is hardwired empty, so the default network policy is waived rather than evaluated against nothing.",
+ "noConsoleErrors": true,
+ "noNetworkErrors": false,
+ "noRuntimeDiagnostics": true
+ },
+ "reason": "not-evaluated",
+ "runtimeDiagnostics": 0
+ },
+ "id": "diagnostics",
+ "pass": false
+ }
+ ],
+ "debugColliders": false,
+ "diagnostics": [
+ {
+ "code": "TN_PLAYTEST_OBSERVATION_UNAVAILABLE",
+ "fix": {
+ "instruction": "Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path."
+ },
+ "message": "Resource wait for 'state.residencyGeneration' timed out after 30150 ms; predicate {\"gte\":1,\"id\":\"state\",\"path\":\"residencyGeneration\"}, last observation 0.",
+ "path": "state",
+ "severity": "error",
+ "suggestion": "Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path."
+ }
+ ],
+ "diagnosticsPolicy": {
+ "networkErrorsOptOutReason": "The run target has no network observer; its network observation is hardwired empty, so the default network policy is waived rather than evaluated against nothing.",
+ "noConsoleErrors": true,
+ "noNetworkErrors": false,
+ "noRuntimeDiagnostics": true
+ },
+ "distance": 0,
+ "entity": "",
+ "expectMoved": false,
+ "frames": 0,
+ "input": "",
+ "movementThreshold": 0,
+ "pass": false,
+ "runtime": "native",
+ "scenario": "vq11-lifecycle",
+ "target": "desktop",
+ "trivialityOptOutCount": 0,
+ "trivialityOptOuts": [],
+ "url": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native/decals"
+}
diff --git a/docs/verification/vq11-decals-native-37010879478/motion.png b/docs/verification/vq11-decals-native-37010879478/motion.png
new file mode 100644
index 0000000000..3848ef3886
Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/motion.png differ
diff --git a/docs/verification/vq11-decals-native-37010879478/static-console.json b/docs/verification/vq11-decals-native-37010879478/static-console.json
new file mode 100644
index 0000000000..9590b4daaf
--- /dev/null
+++ b/docs/verification/vq11-decals-native-37010879478/static-console.json
@@ -0,0 +1,1234 @@
+[
+ {
+ "text": "TN_COLD_START:{\"segment\":\"process\",\"atMs\":0.000}",
+ "type": "log"
+ },
+ {
+ "text": "=== ThreeNative Native Runtime ===",
+ "type": "log"
+ },
+ {
+ "text": "Version: 0.3.4",
+ "type": "log"
+ },
+ {
+ "text": "Script: .threenative/game.js",
+ "type": "log"
+ },
+ {
+ "text": "Window: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Initializing runtime...",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Window: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Presentation cap: 60 fps",
+ "type": "log"
+ },
+ {
+ "text": "[Window] Creating window: ThreeNative (960x540)",
+ "type": "log"
+ },
+ {
+ "text": "[Window] SDL initialized",
+ "type": "log"
+ },
+ {
+ "text": "[Window] Running in hidden mode (MYSTRAL_HEADLESS=1)",
+ "type": "log"
+ },
+ {
+ "text": "[Window] Brand icon unavailable in embedded bundle; using compositor default",
+ "type": "log"
+ },
+ {
+ "text": "[Window] Actual window size: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[Window] Window created successfully",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Lifecycle watch installed; backgroundMode=pause",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Actual window size: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Initializing...",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Instance created",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Using X11 display: 0xaae1a72e9610 window: 4194350",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Creating surface for platform type 4 with display pointer",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Creating X11 surface with display: 0xaae1a72e9610 window: 0x40002e",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Surface created",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Adapter acquired successfully",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Adapter: llvmpipe (LLVM 20.1.2, 128 bits)",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Vendor: llvmpipe",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Backend: Vulkan",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe indirect-first-instance: yes",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Requesting IndirectFirstInstance feature (supported)",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe texture-compression-bc: yes",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe texture-compression-etc2: no",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe texture-compression-astc: no",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe timestamp-query: yes",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] adapter feature probe rg11b10ufloat-renderable: yes",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Device acquired successfully",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] TN_WEBGPU_FEATURES:{\"timestamp-query\":true,\"texture-compression-bc\":true,\"texture-compression-etc2\":false,\"texture-compression-astc\":false,\"indirect-first-instance\":true,\"rg11b10ufloat-renderable\":true}",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Queue acquired",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] configureSurface called: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] surface_=0xaae1a7351e20, device_=0xaae1a74719d0, adapter_=0xaae1a74553c0",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Getting surface capabilities...",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Got capabilities: formatCount=2",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Available surface formats:",
+ "type": "log"
+ },
+ {
+ "text": " [0] = 24",
+ "type": "log"
+ },
+ {
+ "text": " [1] = 23",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Using surface format: 23",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Surface configured: 960x540",
+ "type": "log"
+ },
+ {
+ "text": "[WebGPU] Present mode: fifo (vsync=true)",
+ "type": "log"
+ },
+ {
+ "text": "[JS] Creating QuickJS engine (fallback)",
+ "type": "log"
+ },
+ {
+ "text": "[QuickJS] Creating engine...",
+ "type": "log"
+ },
+ {
+ "text": "[QuickJS] Engine created successfully",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Using JS engine: QuickJS",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Fetch API initialized (file://, http://, https://)",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Web Streams API initialized (ReadableStream/WritableStream/TransformStream)",
+ "type": "log"
+ },
+ {
+ "text": "[WorkerRegistry] Initialized",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] URL and native Worker facade initialized",
+ "type": "log"
+ },
+ {
+ "text": "[DOM] Canvas element created with addEventListener, style, etc.",
+ "type": "log"
+ },
+ {
+ "text": "[log] [Mystral] WebP format support: NO",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] DOM event system initialized",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] localStorage initialized: /home/runner/.local/share/mystral/storage/bounded-decals.json",
+ "type": "log"
+ },
+ {
+ "text": "[Audio] Web Audio API bindings initialized",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_CAPTURE:{\"version\":1,\"build\":{\"identity\":\"mystral-native@0.3.4\"},\"adapter\":{\"identity\":\"native:vulkan/llvmpipe/llvmpipe (LLVM 20.1.2, 128 bits)/Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)\",\"thermal\":\"unavailable\"},\"clock\":{\"source\":\"steady\",\"originMs\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_CACHE:{\"version\":1,\"phase\":\"device\",\"mode\":\"unsupported\",\"featureGranted\":false,\"renderAttached\":0,\"computeAttached\":0,\"emptyBytes\":0,\"serializedBytes\":0,\"load\":\"unavailable\",\"loadedBytes\":0,\"loadMs\":0,\"store\":\"not-attempted\",\"storedBytes\":0,\"storeMs\":0,\"snapshotMs\":0,\"reason\":\"this build's WebGPU backend declares no pipeline cache API\"}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FORMAT:{\"native\":\"bgra8unorm\",\"render\":\"bgra8unorm\",\"bridge\":false,\"present\":\"fifo\"}",
+ "type": "log"
+ },
+ {
+ "text": "[MystralRT] No hardware RT available, using stub backend",
+ "type": "log"
+ },
+ {
+ "text": "[MystralRT] Bindings initialized (backend: none)",
+ "type": "log"
+ },
+ {
+ "text": "[EventLoop] libuv 1.51.0 initialized",
+ "type": "log"
+ },
+ {
+ "text": "[AsyncHttp] Initialized with curl_multi + libuv",
+ "type": "log"
+ },
+ {
+ "text": "[AsyncFile] Initialized with libuv thread pool",
+ "type": "log"
+ },
+ {
+ "text": "[FileWatcher] Initialized with libuv fs_event",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Runtime initialized",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"runtime_created\",\"atMs\":580.324}",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Evaluating script: threenative-playtest-mailbox.js (246 bytes)",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"compile_begin\",\"atMs\":580.344}",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"compile_complete\",\"atMs\":580.397}",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"execute_begin\",\"atMs\":580.407}",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"execute_complete\",\"atMs\":580.412}",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Desktop playtest mailbox configured",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"game_eval_begin\",\"atMs\":580.415}",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Loading script: .threenative/game.js",
+ "type": "log"
+ },
+ {
+ "text": "[Fetch] Read 212 bytes from bundle: assets.manifest.json",
+ "type": "log"
+ },
+ {
+ "text": "[Mystral] Starting main loop...",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cf01b5890b1b6101\",\"bytes\":28,\"requestId\":\"ready\",\"requestMethod\":\"\",\"requestOrder\":-1,\"pump\":{\"observed\":true,\"pumpCount\":1,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":853.834,\"maxGapMs\":0.000,\"maxGapAtMs\":-1.000,\"trailingGapMs\":0.988,\"atMs\":854.823,\"longGaps\":[],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "[Fetch] Read 4712 bytes from bundle: receiver.glb",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_NATIVE_SMOKE_READY:webgpu",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_NATIVE_SMOKE_FIRST_FRAME",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_RENDER_PROJECTION:{\"projecting\":false,\"reasonCode\":\"belowMeshFloor\",\"reason\":\"fewer than 200 batchable meshes; the mirror would cost more than it saves\",\"sourceRenderables\":0,\"resultDrawCandidates\":0,\"batches\":0,\"instancedBatches\":0,\"materialBatches\":0,\"skinnedBatches\":0,\"projectedObjects\":0,\"exactObjects\":0,\"exact\":{},\"materialChecks\":\"spread\",\"materialChecksPerFrame\":512,\"materialCheckStaleFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":1,\"pipelineIdentity\":\"native-render-1\",\"programIdentity\":\"00fc0a8ad03630cc/458120a84b226496\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":0.00027999999999999998,\"settledMs\":6.401694,\"queueMs\":0,\"serviceMs\":6.4014139999999999,\"wallMs\":6.4014139999999999,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"00fc0a8ad03630cc\",\"bytes\":1299},\"fragment\":{\"hash\":\"458120a84b226496\",\"bytes\":1595},\"cache\":\"unsupported\",\"label\":\"renderPipeline_outputColorTransform_18\"}",
+ "type": "log"
+ },
+ {
+ "text": "[warn] THREE.WebGPURenderer: Timestamp tracking is disabled.",
+ "type": "warning"
+ },
+ {
+ "text": "TN_SLOW_FRAME_OPS:{\"replayMs\":421.24257799999998,\"ops\":18,\"top\":[{\"op\":\"submit\",\"n\":2,\"ms\":421.13237700000002},{\"op\":\"render.end\",\"n\":2,\"ms\":0.075402999999999998},{\"op\":\"beginRenderPass\",\"n\":2,\"ms\":0.016678999999999999},{\"op\":\"createCommandEncoder\",\"n\":2,\"ms\":0.008175},{\"op\":\"finish\",\"n\":2,\"ms\":0.0021359999999999999},{\"op\":\"render.setBindGroup\",\"n\":2,\"ms\":0.001872}]}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":1}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_FIRST_PRESENT:{\"version\":1,\"boundaryMs\":540.37911299999996}",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"first_frame\",\"atMs\":1411.164}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_SILENCE:{\"observed\":true,\"pumpCount\":1,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":853.834,\"maxGapMs\":0.000,\"maxGapAtMs\":-1.000,\"trailingGapMs\":557.333,\"longGaps\":[],\"droppedLongGaps\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_STALL_SEGMENTS:{\"toFirstFrameMs\":1411.178,\"frameBeganAtMs\":857.006,\"gapMs\":554.172,\"segments\":{\"pipelineCompile\":{\"ms\":6.468,\"calls\":1},\"shaderCompile\":{\"ms\":0.445,\"calls\":2},\"textureUpload\":{\"ms\":0.183,\"calls\":4},\"bufferUpload\":{\"ms\":0.183,\"calls\":3},\"queueSubmit\":{\"ms\":0.000,\"calls\":0}},\"attributedMs\":7.279,\"residualMs\":546.893,\"attributedShare\":0.0131}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":1,\"requested\":1,\"emitted\":1,\"outstanding\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_END_FRAME:{\"totalMs\":533.67494299999998,\"drainMs\":0.029454000000000001,\"replayMs\":421.29363799999999,\"uiMs\":0.00078399999999999997,\"presentMs\":112.30627699999999,\"pollMs\":0.043206000000000001,\"gpuDrainMs\":0,\"otherMs\":0.0015839999999999999}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_PHASE:{\"phase\":\"endDawnFrame\",\"ms\":533.68100000000004,\"atMs\":1411.2539899999999}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_PHASE:{\"phase\":\"pollEvents\",\"ms\":557.68200000000002,\"atMs\":1411.518525}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"d51d0ecd79920230\",\"bytes\":596,\"requestId\":\"1\",\"requestMethod\":\"describe\",\"requestOrder\":1,\"pump\":{\"observed\":true,\"pumpCount\":2,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":1411.531,\"maxGapMs\":557.697,\"maxGapAtMs\":853.834,\"trailingGapMs\":1122.184,\"atMs\":2533.715,\"longGaps\":[557.697],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":320,\"released\":64,\"geometryBytes\":259896,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":false,\"renderTriangles\":128,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":320,\"released\":64,\"geometryBytes\":259896,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":false,\"renderTriangles\":128,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":2,\"pipelineIdentity\":\"native-render-2\",\"programIdentity\":\"2eb07d0bdc0da0c6/e792f11cd4d28ee0\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1827.715976,\"settledMs\":1831.635959,\"queueMs\":0,\"serviceMs\":3.919983000000002,\"wallMs\":3.919983000000002,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"2eb07d0bdc0da0c6\",\"bytes\":1953},\"fragment\":{\"hash\":\"e792f11cd4d28ee0\",\"bytes\":14203},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshStandardMaterial_19\"}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":3,\"pipelineIdentity\":\"native-render-3\",\"programIdentity\":\"2eb07d0bdc0da0c6/e792f11cd4d28ee0\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1869.3319839999999,\"settledMs\":1872.7200909999999,\"queueMs\":0,\"serviceMs\":3.3881069999999909,\"wallMs\":3.3881069999999909,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"2eb07d0bdc0da0c6\",\"bytes\":1953},\"fragment\":{\"hash\":\"e792f11cd4d28ee0\",\"bytes\":14203},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshStandardMaterial_21\"}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":4,\"pipelineIdentity\":\"native-render-4\",\"programIdentity\":\"effe698ea684daf6/957687f9bd1af7aa\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1884.9268770000001,\"settledMs\":1886.2407189999999,\"queueMs\":0,\"serviceMs\":1.313841999999795,\"wallMs\":1.313841999999795,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"effe698ea684daf6\",\"bytes\":1482},\"fragment\":{\"hash\":\"957687f9bd1af7aa\",\"bytes\":3294},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshBasicMaterial_89\"}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_EVENT:{\"version\":1,\"eventId\":5,\"pipelineIdentity\":\"native-render-5\",\"programIdentity\":\"effe698ea684daf6/957687f9bd1af7aa\",\"kind\":\"render\",\"pass\":\"unknown\",\"mode\":\"sync\",\"status\":\"created\",\"startedMs\":1894.9154160000001,\"settledMs\":1896.334564,\"queueMs\":0,\"serviceMs\":1.4191479999999501,\"wallMs\":1.4191479999999501,\"provenance\":{\"unknown\":true},\"vertex\":{\"hash\":\"effe698ea684daf6\",\"bytes\":1482},\"fragment\":{\"hash\":\"957687f9bd1af7aa\",\"bytes\":3294},\"cache\":\"unsupported\",\"label\":\"renderPipeline_MeshBasicMaterial_89\"}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_PHASE:{\"phase\":\"animationFrames\",\"ms\":1754.6659999999999,\"atMs\":3166.772508}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_FRAME_OPS:{\"replayMs\":577.880135,\"ops\":3889,\"top\":[{\"op\":\"submit\",\"n\":2,\"ms\":574.93500800000004},{\"op\":\"render.end\",\"n\":2,\"ms\":1.5463789999999999},{\"op\":\"render.setBindGroup\",\"n\":519,\"ms\":0.363147},{\"op\":\"writeBuffer\",\"n\":1552,\"ms\":0.26949899999999999},{\"op\":\"writeTexture\",\"n\":2,\"ms\":0.23605699999999999},{\"op\":\"render.setVertexBuffer\",\"n\":517,\"ms\":0.16612399999999999}]}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":2}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PIPELINE_CHECKPOINT:{\"version\":1,\"present\":2,\"requested\":5,\"emitted\":5,\"outstanding\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_END_FRAME:{\"totalMs\":583.17345999999998,\"drainMs\":0.084194000000000005,\"replayMs\":577.93781100000001,\"uiMs\":0.00084800000000000001,\"presentMs\":5.1016579999999996,\"pollMs\":0.047364999999999997,\"gpuDrainMs\":0,\"otherMs\":0.0015839999999999999}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_PHASE:{\"phase\":\"endDawnFrame\",\"ms\":583.18899999999996,\"atMs\":3749.9956529999999}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SLOW_PHASE:{\"phase\":\"pollEvents\",\"ms\":2343.7020000000002,\"atMs\":3755.2437260000002}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"19bbb4273bc7b242\",\"bytes\":168,\"requestId\":\"2\",\"requestMethod\":\"ready\",\"requestOrder\":2,\"pump\":{\"observed\":true,\"pumpCount\":3,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3755.271,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.611,\"atMs\":3759.882,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_FRAME_HITCH:{\"gapMs\":2343.24,\"uptimeMs\":3823.79,\"wallClock\":1790946973685}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":3}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"78b95576888a52b7\",\"bytes\":72,\"requestId\":\"3\",\"requestMethod\":\"advance\",\"requestOrder\":3,\"pump\":{\"observed\":true,\"pumpCount\":4,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3858.159,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":21.716,\"atMs\":3879.875,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":4}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9c7a1dd6c70ad0e0\",\"bytes\":168,\"requestId\":\"4\",\"requestMethod\":\"ready\",\"requestOrder\":4,\"pump\":{\"observed\":true,\"pumpCount\":5,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":3965.014,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.618,\"atMs\":3966.632,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":5}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"af6e700c4e3f1fca\",\"bytes\":71,\"requestId\":\"5\",\"requestMethod\":\"advance\",\"requestOrder\":5,\"pump\":{\"observed\":true,\"pumpCount\":6,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4051.185,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.065,\"atMs\":4055.251,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":6}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"adcce64027efbb36\",\"bytes\":168,\"requestId\":\"6\",\"requestMethod\":\"ready\",\"requestOrder\":6,\"pump\":{\"observed\":true,\"pumpCount\":7,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4140.260,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.689,\"atMs\":4141.950,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":7}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8f2dad1d0d1a33ab\",\"bytes\":71,\"requestId\":\"7\",\"requestMethod\":\"advance\",\"requestOrder\":7,\"pump\":{\"observed\":true,\"pumpCount\":8,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4238.374,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.149,\"atMs\":4241.524,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":8}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"07870d7be2b04b74\",\"bytes\":168,\"requestId\":\"8\",\"requestMethod\":\"ready\",\"requestOrder\":8,\"pump\":{\"observed\":true,\"pumpCount\":9,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4327.281,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.556,\"atMs\":4328.837,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":9}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"89debabef68245d0\",\"bytes\":71,\"requestId\":\"9\",\"requestMethod\":\"advance\",\"requestOrder\":9,\"pump\":{\"observed\":true,\"pumpCount\":10,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4414.522,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.995,\"atMs\":4418.518,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":10}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"2aad096ed3467ddf\",\"bytes\":169,\"requestId\":\"10\",\"requestMethod\":\"ready\",\"requestOrder\":10,\"pump\":{\"observed\":true,\"pumpCount\":11,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4504.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.653,\"atMs\":4505.915,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":11}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cdf6d448b2ecb5ae\",\"bytes\":72,\"requestId\":\"11\",\"requestMethod\":\"advance\",\"requestOrder\":11,\"pump\":{\"observed\":true,\"pumpCount\":12,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4591.308,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.871,\"atMs\":4594.180,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":12}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"58699643da11af6d\",\"bytes\":169,\"requestId\":\"12\",\"requestMethod\":\"ready\",\"requestOrder\":12,\"pump\":{\"observed\":true,\"pumpCount\":13,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4680.403,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.745,\"atMs\":4682.148,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":13}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"dd22e9348bd1a591\",\"bytes\":72,\"requestId\":\"13\",\"requestMethod\":\"advance\",\"requestOrder\":13,\"pump\":{\"observed\":true,\"pumpCount\":14,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4769.194,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.191,\"atMs\":4773.384,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":14}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"7ce7cd2a47a1920b\",\"bytes\":169,\"requestId\":\"14\",\"requestMethod\":\"ready\",\"requestOrder\":14,\"pump\":{\"observed\":true,\"pumpCount\":15,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4859.995,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.748,\"atMs\":4861.744,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":15}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c7dbbaecf9618974\",\"bytes\":72,\"requestId\":\"15\",\"requestMethod\":\"advance\",\"requestOrder\":15,\"pump\":{\"observed\":true,\"pumpCount\":16,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":4949.326,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.267,\"atMs\":4952.594,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":16}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9655fa46e0d42b79\",\"bytes\":169,\"requestId\":\"16\",\"requestMethod\":\"ready\",\"requestOrder\":16,\"pump\":{\"observed\":true,\"pumpCount\":17,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5038.885,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.641,\"atMs\":5040.526,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":17}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"cad80a66f9a59b2b\",\"bytes\":72,\"requestId\":\"17\",\"requestMethod\":\"advance\",\"requestOrder\":17,\"pump\":{\"observed\":true,\"pumpCount\":18,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5125.853,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.031,\"atMs\":5129.885,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":18}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"b4268cb69df16ee7\",\"bytes\":169,\"requestId\":\"18\",\"requestMethod\":\"ready\",\"requestOrder\":18,\"pump\":{\"observed\":true,\"pumpCount\":19,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5217.293,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.795,\"atMs\":5219.087,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":19}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1dd915307902036a\",\"bytes\":72,\"requestId\":\"19\",\"requestMethod\":\"advance\",\"requestOrder\":19,\"pump\":{\"observed\":true,\"pumpCount\":20,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5306.854,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.911,\"atMs\":5309.765,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":20}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"216b29bcb8e05b1c\",\"bytes\":169,\"requestId\":\"20\",\"requestMethod\":\"ready\",\"requestOrder\":20,\"pump\":{\"observed\":true,\"pumpCount\":21,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5395.859,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.656,\"atMs\":5397.516,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":21}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"279fed16396dcd86\",\"bytes\":72,\"requestId\":\"21\",\"requestMethod\":\"advance\",\"requestOrder\":21,\"pump\":{\"observed\":true,\"pumpCount\":22,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5484.510,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.170,\"atMs\":5488.680,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":22}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ce289c8a103aa272\",\"bytes\":169,\"requestId\":\"22\",\"requestMethod\":\"ready\",\"requestOrder\":22,\"pump\":{\"observed\":true,\"pumpCount\":23,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5573.880,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.578,\"atMs\":5575.458,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":23}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"72275fb20f5bcf70\",\"bytes\":72,\"requestId\":\"23\",\"requestMethod\":\"advance\",\"requestOrder\":23,\"pump\":{\"observed\":true,\"pumpCount\":24,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5664.399,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.315,\"atMs\":5667.714,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":24}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"93e3cef21e462b50\",\"bytes\":169,\"requestId\":\"24\",\"requestMethod\":\"ready\",\"requestOrder\":24,\"pump\":{\"observed\":true,\"pumpCount\":25,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5754.409,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.773,\"atMs\":5756.182,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":25}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"279ca0192310c4c7\",\"bytes\":72,\"requestId\":\"25\",\"requestMethod\":\"advance\",\"requestOrder\":25,\"pump\":{\"observed\":true,\"pumpCount\":26,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5842.468,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.228,\"atMs\":5846.696,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":26}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8d4bb9970b4ae526\",\"bytes\":169,\"requestId\":\"26\",\"requestMethod\":\"ready\",\"requestOrder\":26,\"pump\":{\"observed\":true,\"pumpCount\":27,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":5932.559,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.921,\"atMs\":5934.481,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":27}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"198f6d5cdea59b3a\",\"bytes\":72,\"requestId\":\"27\",\"requestMethod\":\"advance\",\"requestOrder\":27,\"pump\":{\"observed\":true,\"pumpCount\":28,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6021.134,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.081,\"atMs\":6025.214,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":28}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f25763a78bc64424\",\"bytes\":169,\"requestId\":\"28\",\"requestMethod\":\"ready\",\"requestOrder\":28,\"pump\":{\"observed\":true,\"pumpCount\":29,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6110.901,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.614,\"atMs\":6112.515,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":29}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c83dac86f371d28d\",\"bytes\":72,\"requestId\":\"29\",\"requestMethod\":\"advance\",\"requestOrder\":29,\"pump\":{\"observed\":true,\"pumpCount\":30,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6199.084,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.100,\"atMs\":6203.184,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":30}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"47b9e49bdf111499\",\"bytes\":169,\"requestId\":\"30\",\"requestMethod\":\"ready\",\"requestOrder\":30,\"pump\":{\"observed\":true,\"pumpCount\":31,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6291.960,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.703,\"atMs\":6293.663,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":31}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"823675271fc907f5\",\"bytes\":72,\"requestId\":\"31\",\"requestMethod\":\"advance\",\"requestOrder\":31,\"pump\":{\"observed\":true,\"pumpCount\":32,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6379.675,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.192,\"atMs\":6382.866,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":32}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"31c93cedd3583d2b\",\"bytes\":169,\"requestId\":\"32\",\"requestMethod\":\"ready\",\"requestOrder\":32,\"pump\":{\"observed\":true,\"pumpCount\":33,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6466.923,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":6468.543,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":33}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"640a7c0fe169f9f6\",\"bytes\":72,\"requestId\":\"33\",\"requestMethod\":\"advance\",\"requestOrder\":33,\"pump\":{\"observed\":true,\"pumpCount\":34,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6553.725,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.086,\"atMs\":6557.811,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":34}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"dd3f5c7ad916b98d\",\"bytes\":169,\"requestId\":\"34\",\"requestMethod\":\"ready\",\"requestOrder\":34,\"pump\":{\"observed\":true,\"pumpCount\":35,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6642.950,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.688,\"atMs\":6644.638,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":35}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"93e707672ccc448f\",\"bytes\":72,\"requestId\":\"35\",\"requestMethod\":\"advance\",\"requestOrder\":35,\"pump\":{\"observed\":true,\"pumpCount\":36,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6729.996,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.838,\"atMs\":6732.834,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":36}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"0b65d747bac6607f\",\"bytes\":169,\"requestId\":\"36\",\"requestMethod\":\"ready\",\"requestOrder\":36,\"pump\":{\"observed\":true,\"pumpCount\":37,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6817.951,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.770,\"atMs\":6819.720,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":37}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f8f056c81673203c\",\"bytes\":72,\"requestId\":\"37\",\"requestMethod\":\"advance\",\"requestOrder\":37,\"pump\":{\"observed\":true,\"pumpCount\":38,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6904.066,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.081,\"atMs\":6908.147,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":38}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ef2e395a3bdfb4a1\",\"bytes\":169,\"requestId\":\"38\",\"requestMethod\":\"ready\",\"requestOrder\":38,\"pump\":{\"observed\":true,\"pumpCount\":39,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":6993.401,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.537,\"atMs\":6994.937,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":39}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"e526ad18bf498959\",\"bytes\":72,\"requestId\":\"39\",\"requestMethod\":\"advance\",\"requestOrder\":39,\"pump\":{\"observed\":true,\"pumpCount\":40,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7079.625,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.197,\"atMs\":7082.822,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":40}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"81f6430656d4a536\",\"bytes\":169,\"requestId\":\"40\",\"requestMethod\":\"ready\",\"requestOrder\":40,\"pump\":{\"observed\":true,\"pumpCount\":41,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7165.846,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.456,\"atMs\":7167.301,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":41}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"117b49d130b50c15\",\"bytes\":72,\"requestId\":\"41\",\"requestMethod\":\"advance\",\"requestOrder\":41,\"pump\":{\"observed\":true,\"pumpCount\":42,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7251.308,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.776,\"atMs\":7255.084,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":42}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"70a37a9cf5efbae0\",\"bytes\":169,\"requestId\":\"42\",\"requestMethod\":\"ready\",\"requestOrder\":42,\"pump\":{\"observed\":true,\"pumpCount\":43,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7348.435,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.657,\"atMs\":7350.092,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":43}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"e31f767984275c39\",\"bytes\":72,\"requestId\":\"43\",\"requestMethod\":\"advance\",\"requestOrder\":43,\"pump\":{\"observed\":true,\"pumpCount\":44,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7433.785,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.883,\"atMs\":7438.669,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":44}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"ede510ed6aac9c42\",\"bytes\":169,\"requestId\":\"44\",\"requestMethod\":\"ready\",\"requestOrder\":44,\"pump\":{\"observed\":true,\"pumpCount\":45,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7522.708,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.542,\"atMs\":7524.250,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":45}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"477caedf27ed1792\",\"bytes\":72,\"requestId\":\"45\",\"requestMethod\":\"advance\",\"requestOrder\":45,\"pump\":{\"observed\":true,\"pumpCount\":46,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7607.341,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.009,\"atMs\":7611.349,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":46}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"347d2fcdb8d947ac\",\"bytes\":169,\"requestId\":\"46\",\"requestMethod\":\"ready\",\"requestOrder\":46,\"pump\":{\"observed\":true,\"pumpCount\":47,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7695.968,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.564,\"atMs\":7697.533,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":47}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c458ea380df770a3\",\"bytes\":72,\"requestId\":\"47\",\"requestMethod\":\"advance\",\"requestOrder\":47,\"pump\":{\"observed\":true,\"pumpCount\":48,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7793.298,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.162,\"atMs\":7796.460,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":48}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"3e262161873c549e\",\"bytes\":169,\"requestId\":\"48\",\"requestMethod\":\"ready\",\"requestOrder\":48,\"pump\":{\"observed\":true,\"pumpCount\":49,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7881.718,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.601,\"atMs\":7883.319,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":49}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"fdb55682e517f658\",\"bytes\":72,\"requestId\":\"49\",\"requestMethod\":\"advance\",\"requestOrder\":49,\"pump\":{\"observed\":true,\"pumpCount\":50,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":7968.599,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.097,\"atMs\":7972.695,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":50}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1d9b12d3ab425c3b\",\"bytes\":169,\"requestId\":\"50\",\"requestMethod\":\"ready\",\"requestOrder\":50,\"pump\":{\"observed\":true,\"pumpCount\":51,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8058.345,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.634,\"atMs\":8059.980,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":51}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"7782b2b6cb71c8dc\",\"bytes\":72,\"requestId\":\"51\",\"requestMethod\":\"advance\",\"requestOrder\":51,\"pump\":{\"observed\":true,\"pumpCount\":52,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8150.988,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.655,\"atMs\":8155.643,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":52}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"b6a103b889890fa9\",\"bytes\":169,\"requestId\":\"52\",\"requestMethod\":\"ready\",\"requestOrder\":52,\"pump\":{\"observed\":true,\"pumpCount\":53,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8247.187,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.347,\"atMs\":8249.533,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":53}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9d71a81816380853\",\"bytes\":72,\"requestId\":\"53\",\"requestMethod\":\"advance\",\"requestOrder\":53,\"pump\":{\"observed\":true,\"pumpCount\":54,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8342.500,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.664,\"atMs\":8347.164,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":54}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6f98cda10b366c4f\",\"bytes\":169,\"requestId\":\"54\",\"requestMethod\":\"ready\",\"requestOrder\":54,\"pump\":{\"observed\":true,\"pumpCount\":55,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8438.992,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.736,\"atMs\":8440.728,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":55}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"337ebeaa86ffbade\",\"bytes\":72,\"requestId\":\"55\",\"requestMethod\":\"advance\",\"requestOrder\":55,\"pump\":{\"observed\":true,\"pumpCount\":56,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8526.854,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.155,\"atMs\":8530.009,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":56}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"16cf2f9ca6fd47dd\",\"bytes\":169,\"requestId\":\"56\",\"requestMethod\":\"ready\",\"requestOrder\":56,\"pump\":{\"observed\":true,\"pumpCount\":57,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8613.976,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.586,\"atMs\":8615.562,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":57}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"da4470c0057f3901\",\"bytes\":72,\"requestId\":\"57\",\"requestMethod\":\"advance\",\"requestOrder\":57,\"pump\":{\"observed\":true,\"pumpCount\":58,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8699.938,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.049,\"atMs\":8703.987,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":58}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"271183c751d68ce3\",\"bytes\":169,\"requestId\":\"58\",\"requestMethod\":\"ready\",\"requestOrder\":58,\"pump\":{\"observed\":true,\"pumpCount\":59,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8788.066,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.644,\"atMs\":8789.710,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":59}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"de33de02ae45f990\",\"bytes\":72,\"requestId\":\"59\",\"requestMethod\":\"advance\",\"requestOrder\":59,\"pump\":{\"observed\":true,\"pumpCount\":60,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8874.535,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.077,\"atMs\":8878.612,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":60}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PRESENTS_TICK:{\"frames\":60,\"presents\":60,\"textureMB\":29,\"textures\":6,\"bufferMB\":0,\"capHz\":60}",
+ "type": "log"
+ },
+ {
+ "text": "TN_GPU_TEXTURES:{\"totalMB\":29,\"count\":6,\"buckets\":[{\"k\":\"960x540 rgba16float msaa4\",\"n\":1,\"mb\":15},{\"k\":\"960x540 depth24plus msaa4\",\"n\":1,\"mb\":7},{\"k\":\"960x540 rgba16float\",\"n\":1,\"mb\":3},{\"k\":\"960x540 depth24plus\",\"n\":1,\"mb\":1},{\"k\":\"64x64 rgba8unorm\",\"n\":1,\"mb\":0},{\"k\":\"16x16 rg16float\",\"n\":1,\"mb\":0}],\"bucketsTotal\":6}",
+ "type": "log"
+ },
+ {
+ "text": "TN_GPU_BUFFERS:{\"totalMB\":0,\"count\":1296,\"buckets\":[{\"k\":\"vertex|copysrc|copydst\",\"n\":517,\"mb\":0},{\"k\":\"uniform|copydst\",\"n\":519,\"mb\":0},{\"k\":\"index|copysrc|copydst\",\"n\":258,\"mb\":0},{\"k\":\"copydst|mapread\",\"n\":1,\"mb\":0},{\"k\":\"copysrc\",\"n\":1,\"mb\":0}]}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"18b2ef1e76e5bce8\",\"bytes\":169,\"requestId\":\"60\",\"requestMethod\":\"ready\",\"requestOrder\":60,\"pump\":{\"observed\":true,\"pumpCount\":61,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":8965.798,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.626,\"atMs\":8967.424,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":61}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"09d5f00b59a5a2e0\",\"bytes\":72,\"requestId\":\"61\",\"requestMethod\":\"advance\",\"requestOrder\":61,\"pump\":{\"observed\":true,\"pumpCount\":62,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9052.530,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.171,\"atMs\":9056.701,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":62}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c2911562e037c03e\",\"bytes\":169,\"requestId\":\"62\",\"requestMethod\":\"ready\",\"requestOrder\":62,\"pump\":{\"observed\":true,\"pumpCount\":63,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9143.789,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.601,\"atMs\":9145.390,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":63}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"0e85742f46a37379\",\"bytes\":73,\"requestId\":\"63\",\"requestMethod\":\"advance\",\"requestOrder\":63,\"pump\":{\"observed\":true,\"pumpCount\":64,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9233.151,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.170,\"atMs\":9236.321,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":64}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9baadaa99db7dd94\",\"bytes\":169,\"requestId\":\"64\",\"requestMethod\":\"ready\",\"requestOrder\":64,\"pump\":{\"observed\":true,\"pumpCount\":65,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9322.870,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.543,\"atMs\":9324.414,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":65}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9966ef2d4e6708b2\",\"bytes\":73,\"requestId\":\"65\",\"requestMethod\":\"advance\",\"requestOrder\":65,\"pump\":{\"observed\":true,\"pumpCount\":66,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9409.915,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.106,\"atMs\":9414.020,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":66}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"69d6de25bf7a182a\",\"bytes\":169,\"requestId\":\"66\",\"requestMethod\":\"ready\",\"requestOrder\":66,\"pump\":{\"observed\":true,\"pumpCount\":67,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9500.117,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.627,\"atMs\":9501.744,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":67}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"f6598f5c20cca33b\",\"bytes\":73,\"requestId\":\"67\",\"requestMethod\":\"advance\",\"requestOrder\":67,\"pump\":{\"observed\":true,\"pumpCount\":68,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9590.236,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":2.862,\"atMs\":9593.097,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":68}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"bf9d77b1364a0e80\",\"bytes\":169,\"requestId\":\"68\",\"requestMethod\":\"ready\",\"requestOrder\":68,\"pump\":{\"observed\":true,\"pumpCount\":69,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9679.332,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.679,\"atMs\":9681.011,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":69}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"af051c6ddb763330\",\"bytes\":73,\"requestId\":\"69\",\"requestMethod\":\"advance\",\"requestOrder\":69,\"pump\":{\"observed\":true,\"pumpCount\":70,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9765.724,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.146,\"atMs\":9769.869,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":70}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"3cdb38250d95b1e5\",\"bytes\":169,\"requestId\":\"70\",\"requestMethod\":\"ready\",\"requestOrder\":70,\"pump\":{\"observed\":true,\"pumpCount\":71,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9854.890,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.591,\"atMs\":9856.481,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":71}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c7c209b160e92ec6\",\"bytes\":73,\"requestId\":\"71\",\"requestMethod\":\"advance\",\"requestOrder\":71,\"pump\":{\"observed\":true,\"pumpCount\":72,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":9941.189,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.153,\"atMs\":9944.342,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":72}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"520e454b3c904f77\",\"bytes\":169,\"requestId\":\"72\",\"requestMethod\":\"ready\",\"requestOrder\":72,\"pump\":{\"observed\":true,\"pumpCount\":73,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10028.046,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":10029.666,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":73}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"52b64084ebb918ed\",\"bytes\":73,\"requestId\":\"73\",\"requestMethod\":\"advance\",\"requestOrder\":73,\"pump\":{\"observed\":true,\"pumpCount\":74,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10114.864,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.924,\"atMs\":10118.787,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":74}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6380c227074b8691\",\"bytes\":169,\"requestId\":\"74\",\"requestMethod\":\"ready\",\"requestOrder\":74,\"pump\":{\"observed\":true,\"pumpCount\":75,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10201.862,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.617,\"atMs\":10203.479,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":75}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"be3dc3260f36fe3c\",\"bytes\":73,\"requestId\":\"75\",\"requestMethod\":\"advance\",\"requestOrder\":75,\"pump\":{\"observed\":true,\"pumpCount\":76,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10288.000,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.102,\"atMs\":10292.102,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":76}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"1f677084102d9b83\",\"bytes\":169,\"requestId\":\"76\",\"requestMethod\":\"ready\",\"requestOrder\":76,\"pump\":{\"observed\":true,\"pumpCount\":77,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10378.999,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.538,\"atMs\":10380.537,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":77}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"c4d75c1cfeb82787\",\"bytes\":73,\"requestId\":\"77\",\"requestMethod\":\"advance\",\"requestOrder\":77,\"pump\":{\"observed\":true,\"pumpCount\":78,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10464.486,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.966,\"atMs\":10468.452,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":78}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"94350233bec333fd\",\"bytes\":169,\"requestId\":\"78\",\"requestMethod\":\"ready\",\"requestOrder\":78,\"pump\":{\"observed\":true,\"pumpCount\":79,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10551.853,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.557,\"atMs\":10553.410,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":79}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"d07ed5024424a452\",\"bytes\":73,\"requestId\":\"79\",\"requestMethod\":\"advance\",\"requestOrder\":79,\"pump\":{\"observed\":true,\"pumpCount\":80,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10636.856,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.068,\"atMs\":10639.924,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":80}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"89741c4f3d5d2262\",\"bytes\":169,\"requestId\":\"80\",\"requestMethod\":\"ready\",\"requestOrder\":80,\"pump\":{\"observed\":true,\"pumpCount\":81,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10724.017,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.691,\"atMs\":10725.708,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":81}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"301571c6a38e0434\",\"bytes\":73,\"requestId\":\"81\",\"requestMethod\":\"advance\",\"requestOrder\":81,\"pump\":{\"observed\":true,\"pumpCount\":82,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10809.833,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.308,\"atMs\":10814.141,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":82}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"90fedf35eedd2fcc\",\"bytes\":169,\"requestId\":\"82\",\"requestMethod\":\"ready\",\"requestOrder\":82,\"pump\":{\"observed\":true,\"pumpCount\":83,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10906.953,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.531,\"atMs\":10908.483,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":83}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"036f4c51138a9c58\",\"bytes\":73,\"requestId\":\"83\",\"requestMethod\":\"advance\",\"requestOrder\":83,\"pump\":{\"observed\":true,\"pumpCount\":84,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":10991.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.801,\"atMs\":10995.064,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":84}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"14a16ced8cf51356\",\"bytes\":169,\"requestId\":\"84\",\"requestMethod\":\"ready\",\"requestOrder\":84,\"pump\":{\"observed\":true,\"pumpCount\":85,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11077.669,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.637,\"atMs\":11079.306,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":85}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"a3ee970b2a64450f\",\"bytes\":73,\"requestId\":\"85\",\"requestMethod\":\"advance\",\"requestOrder\":85,\"pump\":{\"observed\":true,\"pumpCount\":86,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11162.818,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":4.012,\"atMs\":11166.830,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":86}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"64b8e924b0b5ab80\",\"bytes\":169,\"requestId\":\"86\",\"requestMethod\":\"ready\",\"requestOrder\":86,\"pump\":{\"observed\":true,\"pumpCount\":87,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11253.412,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.620,\"atMs\":11255.032,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":87}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"a323689e1d42692a\",\"bytes\":73,\"requestId\":\"87\",\"requestMethod\":\"advance\",\"requestOrder\":87,\"pump\":{\"observed\":true,\"pumpCount\":88,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11352.263,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":3.304,\"atMs\":11355.567,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":88}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"795a5d9268e0ad67\",\"bytes\":249,\"requestId\":\"88\",\"requestMethod\":\"ready\",\"requestOrder\":88,\"pump\":{\"observed\":true,\"pumpCount\":89,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11439.123,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":1.671,\"atMs\":11440.794,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":89}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9224ca862071dfa0\",\"bytes\":6165,\"requestId\":\"89\",\"requestMethod\":\"sample\",\"requestOrder\":89,\"pump\":{\"observed\":true,\"pumpCount\":90,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11525.048,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":23.764,\"atMs\":11548.812,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":90}",
+ "type": "log"
+ },
+ {
+ "text": "TN_COLD_START:{\"segment\":\"first_playable\",\"atMs\":11633.833}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "[log] TN_DECAL_FIXTURE:{\"generation\":1,\"active\":256,\"capacity\":256,\"created\":321,\"released\":65,\"geometryBytes\":275076,\"maxDrawCalls\":0,\"motionError\":0,\"receiverMotion\":0,\"lodHit\":true,\"renderTriangles\":2,\"receiverRemoved\":false,\"atlasFade\":false,\"averageOpacity\":1,\"residency\":[],\"residencySamples\":0,\"stableResidencyFrames\":0}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"6878ee373e5f987e\",\"bytes\":74,\"requestId\":\"90\",\"requestMethod\":\"advance\",\"requestOrder\":90,\"pump\":{\"observed\":true,\"pumpCount\":91,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11633.919,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":132.181,\"atMs\":11766.100,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":91}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8bd550e42e078f4d\",\"bytes\":6165,\"requestId\":\"91\",\"requestMethod\":\"sample\",\"requestOrder\":91,\"pump\":{\"observed\":true,\"pumpCount\":92,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11856.224,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.205,\"atMs\":11870.429,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":92}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"8e69a825d07469a4\",\"bytes\":6165,\"requestId\":\"92\",\"requestMethod\":\"sample\",\"requestOrder\":92,\"pump\":{\"observed\":true,\"pumpCount\":93,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":11961.372,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.562,\"atMs\":11975.935,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":93}",
+ "type": "log"
+ },
+ {
+ "text": "TN_PUMP_ENDPOINT:{\"path\":\"/tmp/threenative-playtest-desktop-b6Dgxs/tn-playtest-response.json\",\"stored\":true,\"payloadHash\":\"9534902c1c41adeb\",\"bytes\":6165,\"requestId\":\"93\",\"requestMethod\":\"sample\",\"requestOrder\":93,\"pump\":{\"observed\":true,\"pumpCount\":94,\"firstPumpAtMs\":853.834,\"lastPumpAtMs\":12065.898,\"maxGapMs\":2343.740,\"maxGapAtMs\":1411.531,\"trailingGapMs\":14.339,\"atMs\":12080.237,\"longGaps\":[557.697,2343.740],\"droppedLongGaps\":0}}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":94}",
+ "type": "log"
+ },
+ {
+ "text": "TN_SURFACE_FRAME:{\"view\":true,\"present\":95}",
+ "type": "log"
+ },
+ {
+ "text": "[Screenshot] First 16 bytes (BGRA raw): 47 35 23 255 47 35 23 255 47 35 23 255 47 35 23 255 ",
+ "type": "log"
+ },
+ {
+ "text": "[Screenshot] Middle bytes (BGRA raw): 47 35 23 255 47 35 23 255 47 35 23 255 47 35 23 255 ",
+ "type": "log"
+ },
+ {
+ "text": "[Screenshot] Saved: /home/runner/work/threenative/threenative/artifacts/vq11-decals-native/static/after.png (960x540)",
+ "type": "log"
+ }
+]
diff --git a/docs/verification/vq11-decals-native-37010879478/static.png b/docs/verification/vq11-decals-native-37010879478/static.png
new file mode 100644
index 0000000000..da3ebd877a
Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/static.png differ
diff --git a/docs/verification/vq11-decals-native-37010879478/summary.json b/docs/verification/vq11-decals-native-37010879478/summary.json
new file mode 100644
index 0000000000..6a6291e8dc
--- /dev/null
+++ b/docs/verification/vq11-decals-native-37010879478/summary.json
@@ -0,0 +1,544 @@
+{
+ "sourceSha": "563ab23e9448a993013cb0d58c75ab6ab83917c9",
+ "pass": false,
+ "qualification": "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.",
+ "results": [
+ {
+ "name": "static",
+ "pass": true,
+ "negativeControl": false,
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "state": {
+ "residencyGeneration": 0,
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 0,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 0
+ },
+ "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8",
+ "pixels": {
+ "assertions": [
+ {
+ "id": "visual.0.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.15,
+ "observed": 0.9999691358024692
+ }
+ },
+ {
+ "id": "visual.1.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9995294117647059
+ }
+ },
+ {
+ "id": "visual.1.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.1.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.2.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9997660818713451
+ }
+ },
+ {
+ "id": "visual.2.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05719298245614035
+ }
+ },
+ {
+ "id": "visual.2.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05719298245614035
+ }
+ },
+ {
+ "details": {
+ "consoleErrors": 0,
+ "networkErrors": 0,
+ "policy": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true
+ },
+ "runtimeDiagnostics": 0
+ },
+ "id": "diagnostics",
+ "pass": true
+ }
+ ],
+ "diagnostics": []
+ },
+ "diagnostics": []
+ },
+ {
+ "name": "motion",
+ "pass": true,
+ "negativeControl": false,
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "state": {
+ "residencyGeneration": 0,
+ "generation": 1,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 0,
+ "motionError": 0,
+ "receiverMotion": 0.7549778411745116,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 0
+ },
+ "screenshotSha256": "a80813b0c2ebdff2c817664a8780b59396bbec4844bacfba5ac25998fe001c99",
+ "pixels": {
+ "assertions": [
+ {
+ "id": "visual.0.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.15,
+ "observed": 0.9999729938271605
+ }
+ },
+ {
+ "id": "visual.1.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9995294117647059
+ }
+ },
+ {
+ "id": "visual.1.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.1.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.2.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9996491228070176
+ }
+ },
+ {
+ "id": "visual.2.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05309941520467836
+ }
+ },
+ {
+ "id": "visual.2.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05309941520467836
+ }
+ },
+ {
+ "details": {
+ "consoleErrors": 0,
+ "networkErrors": 0,
+ "policy": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true
+ },
+ "runtimeDiagnostics": 0
+ },
+ "id": "diagnostics",
+ "pass": true
+ }
+ ],
+ "diagnostics": []
+ },
+ "diagnostics": []
+ },
+ {
+ "name": "teardown",
+ "pass": true,
+ "negativeControl": false,
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "state": {
+ "residencyGeneration": 0,
+ "generation": 1,
+ "active": 128,
+ "capacity": 256,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136188,
+ "maxDrawCalls": 0,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": true,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 0
+ },
+ "screenshotSha256": "2a85f1cb78de0bd06f79534704b44170397c6a299d9b5709dd76c560c08f6880",
+ "pixels": {
+ "assertions": [
+ {
+ "id": "visual.0.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.15,
+ "observed": 0.9999807098765432
+ }
+ },
+ {
+ "id": "visual.1.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9995294117647059
+ }
+ },
+ {
+ "id": "visual.1.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.1.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "details": {
+ "consoleErrors": 0,
+ "networkErrors": 0,
+ "policy": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true
+ },
+ "runtimeDiagnostics": 0
+ },
+ "id": "diagnostics",
+ "pass": true
+ }
+ ],
+ "diagnostics": []
+ },
+ "diagnostics": []
+ },
+ {
+ "name": "restart",
+ "pass": true,
+ "negativeControl": false,
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "state": {
+ "residencyGeneration": 0,
+ "generation": 2,
+ "active": 256,
+ "capacity": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 0,
+ "motionError": 0,
+ "receiverMotion": 0,
+ "lodHit": true,
+ "renderTriangles": 2,
+ "receiverRemoved": false,
+ "atlasFade": false,
+ "averageOpacity": 1,
+ "residency": [],
+ "residencySamples": 0,
+ "stableResidencyFrames": 0
+ },
+ "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8",
+ "pixels": {
+ "assertions": [
+ {
+ "id": "visual.0.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.15,
+ "observed": 0.9999691358024692
+ }
+ },
+ {
+ "id": "visual.1.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9995294117647059
+ }
+ },
+ {
+ "id": "visual.1.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.1.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05331764705882353
+ }
+ },
+ {
+ "id": "visual.2.region",
+ "pass": true,
+ "details": {
+ "after": true,
+ "expected": {
+ "equals": true
+ },
+ "minimum": 0.9,
+ "observed": 0.9997660818713451
+ }
+ },
+ {
+ "id": "visual.2.region.darkPixels",
+ "pass": true,
+ "details": {
+ "maximumLuminance": 0.35,
+ "minimumDarkPixelRatio": 0.01,
+ "observedDarkPixelRatio": 0.05719298245614035
+ }
+ },
+ {
+ "id": "visual.2.region.maxDarkPixels",
+ "pass": true,
+ "details": {
+ "maximumDarkPixelRatio": 0.2,
+ "observedDarkPixelRatio": 0.05719298245614035
+ }
+ },
+ {
+ "details": {
+ "consoleErrors": 0,
+ "networkErrors": 0,
+ "policy": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true
+ },
+ "runtimeDiagnostics": 0
+ },
+ "id": "diagnostics",
+ "pass": true
+ }
+ ],
+ "diagnostics": []
+ },
+ "diagnostics": []
+ }
+ ],
+ "identity": {
+ "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042",
+ "capabilities": {
+ "engine": "quickjs",
+ "version": "TN_COLD_START:{\"segment\":\"process\",\"atMs\":0.000}\nMystral Native Runtime v0.3.4\nNative WebGPU JS runtime - wgpu-native + quickjs build"
+ },
+ "assetHashes": {
+ "receiver.glb": "7ed6179e17f75f3f60576f72968140773c7f65cb1b8d760240576ac4092c05c3",
+ "assets.manifest.json": "3b833a9fc96c4f3ef939840f2154f3e40aa95d4beb6cda965833b54f43cde1dc"
+ },
+ "executables": {
+ "native.ts": {
+ "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native/decals",
+ "sha256": "623b916c245470157308c7203472cffaa7a8860b88093697e16305577af95ca7",
+ "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native.js",
+ "bundleSha256": "69b662ca548cd31bd6848acdfd086befd22a612bbb396caf9e8c9e5da27d33ee"
+ },
+ "native-atlas.ts": {
+ "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-atlas/decals",
+ "sha256": "681760e85faa4cc1e0d18b67f1f05bb0b6cf65d4910d8a79a9ec9d96630426aa",
+ "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-atlas.js",
+ "bundleSha256": "ebe516e667d7d9da27f08cd2964a4a8a520cc849b2251261f781f026de47a27b"
+ },
+ "native-hidden.ts": {
+ "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden/decals",
+ "sha256": "165d1ce5cf07db055c92947644f840efaca58e4b75bf4e8e24fc4967f61eed28",
+ "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden.js",
+ "bundleSha256": "43f5d19cf13531736384038fa9e372f90c45f7d2558335fa717070412acac08c"
+ },
+ "native-hidden-fading.ts": {
+ "path": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden-fading/decals",
+ "sha256": "6bcf02f0fd25ccc8ef2cebfc2210db64ad88b7328f8107f6ea685bd36bd97527",
+ "bundle": "/home/runner/work/threenative/threenative/artifacts/vq11-decals-native/variants/native-hidden-fading.js",
+ "bundleSha256": "b451d8a9c4982b9e22c7f82c40d02d879037dc78f8dadecb459c60d82022f7c5"
+ }
+ }
+ },
+ "error": "AssertionError [ERR_ASSERTION]: [{\"code\":\"TN_PLAYTEST_OBSERVATION_UNAVAILABLE\",\"fix\":{\"instruction\":\"Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path.\"},\"message\":\"Resource wait for 'state.residencyGeneration' timed out after 30150 ms; predicate {\\\"gte\\\":1,\\\"id\\\":\\\"state\\\",\\\"path\\\":\\\"residencyGeneration\\\"}, last observation 0.\",\"path\":\"state\",\"severity\":\"error\",\"suggestion\":\"Increase timeoutMs only when the transition is expected to take longer; otherwise fix the producer or resource path.\"}]\n\nfalse !== true\n\n at assertNativeDecalCapture (/home/runner/work/threenative/threenative/packages/create-threenative/__tests__/fixtures/bounded-decals/native-proof.ts:73:10)\n at (/home/runner/work/threenative/threenative/packages/create-threenative/__tests__/fixtures/bounded-decals/verify-native.ts:167:7)"
+}
diff --git a/docs/verification/vq11-decals-native-37010879478/teardown.png b/docs/verification/vq11-decals-native-37010879478/teardown.png
new file mode 100644
index 0000000000..1823155528
Binary files /dev/null and b/docs/verification/vq11-decals-native-37010879478/teardown.png differ
diff --git a/docs/verification/vq11-decals-native-37015640669/atlas.png b/docs/verification/vq11-decals-native-37015640669/atlas.png
new file mode 100644
index 0000000000..82992fcef5
Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/atlas.png differ
diff --git a/docs/verification/vq11-decals-native-37015640669/expired.png b/docs/verification/vq11-decals-native-37015640669/expired.png
new file mode 100644
index 0000000000..5351a90ffb
Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/expired.png differ
diff --git a/docs/verification/vq11-decals-native-37015640669/fading.png b/docs/verification/vq11-decals-native-37015640669/fading.png
new file mode 100644
index 0000000000..a4d602f580
Binary files /dev/null and b/docs/verification/vq11-decals-native-37015640669/fading.png differ
diff --git a/docs/verification/vq11-decals-native-37015640669/provenance.json b/docs/verification/vq11-decals-native-37015640669/provenance.json
new file mode 100644
index 0000000000..795d8cb902
--- /dev/null
+++ b/docs/verification/vq11-decals-native-37015640669/provenance.json
@@ -0,0 +1,245 @@
+{
+ "sourceSha": "8f85fff19913c955a44b7b85d2cc117b972bd19c",
+ "runUrl": "https://github.com/ThreeNativeHQ/threenative/actions/runs/37015640669",
+ "artifactId": 11231490134,
+ "artifactSha256": "07925a63d4091f2a53d06ef7b178619d775ef9185890949afb4645c83e9d519f",
+ "qualification": "Packaged Linux ARM QuickJS software-adapter correctness. No mobile or hardware-performance claim.",
+ "pass": true,
+ "capture": {
+ "adapter": {
+ "architecture": "unknown",
+ "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)",
+ "device": "llvmpipe (LLVM 20.1.2, 128 bits)",
+ "vendor": "llvmpipe"
+ },
+ "browserArgs": [],
+ "captureMethod": "device.screenshot",
+ "rendererKind": "webgpu",
+ "target": "desktop",
+ "viewport": {
+ "height": 540,
+ "width": 960
+ }
+ },
+ "runtimeSha256": "da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042",
+ "runtimeVersion": "Mystral Native Runtime v0.3.4; wgpu-native + QuickJS",
+ "assetHashes": {
+ "receiver.glb": "7ed6179e17f75f3f60576f72968140773c7f65cb1b8d760240576ac4092c05c3",
+ "assets.manifest.json": "3b833a9fc96c4f3ef939840f2154f3e40aa95d4beb6cda965833b54f43cde1dc"
+ },
+ "builds": {
+ "native.ts": {
+ "executableSha256": "6d53ae54426ee3226a06373978fa1ea081c02d1667ceafa191bf70a1657a7fa7",
+ "bundleSha256": "391f98f6391b915798aa10053b71a03a652d1b07a72062d29448309435ac051a"
+ },
+ "native-atlas.ts": {
+ "executableSha256": "a8bda56b2a103f0ae6f1330c5f35f56dfb2c7dc0d4b2ce672b2424f5eff05b26",
+ "bundleSha256": "683bc8d337886e7f9ca803c09d0ea89393e5914d17c7c58cfe2536fbe2db49d5"
+ },
+ "native-hidden.ts": {
+ "executableSha256": "a404951c4b9f9e73d44589b307253383590b185dcc33aae1cad4bdc83fcfc4d5",
+ "bundleSha256": "54e4a82de7e6920b74d87beb66c995dd40bbf3012efcaf06989ec14a04e26abe"
+ },
+ "native-hidden-fading.ts": {
+ "executableSha256": "511bfb911a6909a09043151fdfc65ea124b1ced21559b80c27c93144c8d6c7cd",
+ "bundleSha256": "5f538d5bdde31205935bf18c22214b641748e98a8636a7ba73e37ea06ee2d329"
+ }
+ },
+ "results": [
+ {
+ "name": "static",
+ "pass": true,
+ "negativeControl": false,
+ "png": "../vq11-decals-native-37010879478/static.png",
+ "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "motion",
+ "pass": true,
+ "negativeControl": false,
+ "png": "../vq11-decals-native-37010879478/motion.png",
+ "screenshotSha256": "a80813b0c2ebdff2c817664a8780b59396bbec4844bacfba5ac25998fe001c99",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "teardown",
+ "pass": true,
+ "negativeControl": false,
+ "png": "../vq11-decals-native-37010879478/teardown.png",
+ "screenshotSha256": "2a85f1cb78de0bd06f79534704b44170397c6a299d9b5709dd76c560c08f6880",
+ "active": 128,
+ "created": 321,
+ "released": 193,
+ "geometryBytes": 136188,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "restart",
+ "pass": true,
+ "negativeControl": false,
+ "png": "../vq11-decals-native-37010879478/static.png",
+ "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "lifecycle",
+ "pass": true,
+ "negativeControl": false,
+ "png": "../vq11-decals-native-37010879478/static.png",
+ "screenshotSha256": "ffb1bc6c5abaf5e4c55c258740c549731e81a8dc9c48c9b449ec7ddd27b95fa8",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": [],
+ "residency": [
+ {
+ "generation": 1,
+ "renderCalls": 18,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 2,
+ "renderCalls": 230,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 3,
+ "renderCalls": 258,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 4,
+ "renderCalls": 286,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 5,
+ "renderCalls": 314,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ },
+ {
+ "generation": 6,
+ "renderCalls": 342,
+ "stableFrames": 3,
+ "geometries": 259,
+ "textures": 4,
+ "bytes": 6522894
+ }
+ ],
+ "growth": {
+ "geometryGrowth": 0,
+ "textureGrowth": 0,
+ "memoryGrowth": 0
+ }
+ },
+ {
+ "name": "atlas",
+ "pass": true,
+ "negativeControl": false,
+ "png": "atlas.png",
+ "screenshotSha256": "4ecb9a664104a750608b1326bf18bbed367f03d0ed88c021b19a94f9d91fcd0f",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 1,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "fading",
+ "pass": true,
+ "negativeControl": false,
+ "png": "fading.png",
+ "screenshotSha256": "4740d99ae7b3ba976a3ea9a49c218021443a7e870c9dbc4da72bc5479f55a554",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 0.4749999999999973,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "expired",
+ "pass": true,
+ "negativeControl": false,
+ "png": "expired.png",
+ "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e",
+ "active": 0,
+ "created": 321,
+ "released": 321,
+ "geometryBytes": 0,
+ "maxDrawCalls": 516,
+ "averageOpacity": 0,
+ "failedPixelAssertions": []
+ },
+ {
+ "name": "hidden-decals",
+ "pass": true,
+ "negativeControl": true,
+ "png": "expired.png",
+ "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 259794,
+ "maxDrawCalls": 4,
+ "averageOpacity": 1,
+ "failedPixelAssertions": ["visual.1.region.darkPixels", "visual.2.region.darkPixels"]
+ },
+ {
+ "name": "hidden-fading",
+ "pass": true,
+ "negativeControl": true,
+ "png": "expired.png",
+ "screenshotSha256": "12304589dcd60b2f774176717c8910ddddc7cdef39d5a75fe6e526ec5caef56e",
+ "active": 256,
+ "created": 321,
+ "released": 65,
+ "geometryBytes": 275076,
+ "maxDrawCalls": 516,
+ "averageOpacity": 0.4749999999999973,
+ "failedPixelAssertions": ["visual.3.region.darkPixels", "visual.4.region.darkPixels"]
+ }
+ ]
+}
diff --git a/examples/abyss-framework/dist.build-report.json b/examples/abyss-framework/dist.build-report.json
index 74a4aee87b..34c3adacd7 100644
--- a/examples/abyss-framework/dist.build-report.json
+++ b/examples/abyss-framework/dist.build-report.json
@@ -2,10 +2,10 @@
"artifact": {
"kind": "directory",
"name": "dist",
- "sha256": "279c9028a54c8fa7ef092c5ef7e3a3b43ab38e9600cc6bcd0e2c1243691701a7"
+ "sha256": "00610e60c69c4bb14aab51c193fd747d3ba2f87d5583aef8e5261f6e67815dfe"
},
"measured": {
- "artifactBytes": 12563552,
+ "artifactBytes": 12577621,
"packagedAssetBytes": 786109
},
"manifestSha256": "e96b9804e18d941afd1774ff628452ab4db9c199a7230ee00cba306cf58ad036",
diff --git a/examples/abyss-framework/package.json b/examples/abyss-framework/package.json
index deafce5432..e37fe7a7e9 100644
--- a/examples/abyss-framework/package.json
+++ b/examples/abyss-framework/package.json
@@ -23,6 +23,7 @@
"devDependencies": {
"@tailwindcss/vite": "catalog:",
"@threenative/assets": "workspace:*",
+ "@threenative/playtest": "workspace:*",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/three": "0.185.3",
diff --git a/examples/abyss-framework/playtests/tone.playtest.json b/examples/abyss-framework/playtests/tone.playtest.json
new file mode 100644
index 0000000000..bb0c1710dc
--- /dev/null
+++ b/examples/abyss-framework/playtests/tone.playtest.json
@@ -0,0 +1,22 @@
+{
+ "name": "tone-exposure",
+ "schemaVersion": 1,
+ "target": "web",
+ "viewport": { "width": 1280, "height": 720 },
+ "warmupFrames": 2,
+ "artifacts": { "screenshots": "after" },
+ "steps": [{ "kind": "wait", "label": "rendered", "waitTicks": 2 }],
+ "assert": {
+ "tone": [
+ {
+ "atStep": "rendered",
+ "mean": { "min": 140, "max": 210 },
+ "p99": { "min": 230 },
+ "clipFraction": { "max": 0.02 },
+ "blackFraction": { "max": 0.05 }
+ },
+ { "mean": { "min": 140, "max": 210 }, "p99": { "min": 230 } }
+ ],
+ "diagnostics": { "noConsoleErrors": true, "noNetworkErrors": true }
+ }
+}
diff --git a/examples/abyss-framework/src/render/toneCalibration.ts b/examples/abyss-framework/src/render/toneCalibration.ts
new file mode 100644
index 0000000000..927f697b74
--- /dev/null
+++ b/examples/abyss-framework/src/render/toneCalibration.ts
@@ -0,0 +1,37 @@
+import {
+ AmbientLight,
+ BoxGeometry,
+ DirectionalLight,
+ Mesh,
+ MeshBasicMaterial,
+ MeshStandardMaterial,
+ OrthographicCamera,
+ PlaneGeometry,
+ Scene,
+ SphereGeometry,
+} from "three";
+
+/** Neutral ramp and lit 3D subjects make the exposure mutation inspectable in a real render. */
+export function createToneCalibration(): { camera: OrthographicCamera; scene: Scene } {
+ const scene = new Scene();
+ const camera = new OrthographicCamera(-2, 2, 1.125, -1.125, 0.1, 20);
+ camera.position.z = 5;
+ const panel = new PlaneGeometry(4 / 256, 2.25);
+ for (let value = 0; value < 256; value += 1) {
+ const material = new MeshBasicMaterial();
+ material.color.setRGB(value / 255, value / 255, value / 255);
+ const strip = new Mesh(panel, material);
+ strip.position.x = -2 + (value + 0.5) * (4 / 256);
+ scene.add(strip);
+ }
+ const material = new MeshStandardMaterial({ color: 0x999999, roughness: 0.35 });
+ const sphere = new Mesh(new SphereGeometry(0.32, 32, 24), material);
+ sphere.position.set(-0.65, 0, 0.4);
+ const cube = new Mesh(new BoxGeometry(0.48, 0.48, 0.48), material);
+ cube.position.set(0.65, 0, 0.4);
+ cube.rotation.set(0.3, 0.5, 0);
+ const key = new DirectionalLight(0xffffff, 3);
+ key.position.set(-2, 3, 4);
+ scene.add(sphere, cube, new AmbientLight(0xffffff, 0.4), key);
+ return { camera, scene };
+}
diff --git a/examples/abyss-framework/src/tone-main.ts b/examples/abyss-framework/src/tone-main.ts
new file mode 100644
index 0000000000..d5ead5a4c4
--- /dev/null
+++ b/examples/abyss-framework/src/tone-main.ts
@@ -0,0 +1,27 @@
+import { installThreePlaytestBridge } from "@threenative/playtest/three";
+import { LinearToneMapping, WebGPURenderer } from "three/webgpu";
+import { createToneCalibration } from "./render/toneCalibration.js";
+
+const { camera, scene } = createToneCalibration();
+const renderer = new WebGPURenderer({ antialias: false });
+renderer.setPixelRatio(1);
+renderer.setSize(1280, 720);
+renderer.toneMapping = LinearToneMapping;
+renderer.toneMappingExposure = new URLSearchParams(location.search).has("underexposed") ? 0.25 : 1;
+document.body.appendChild(renderer.domElement);
+await renderer.init();
+await renderer.renderAsync(scene, camera);
+let tick = 0;
+installThreePlaytestBridge({
+ camera,
+ diagnostics: () => [],
+ fixedStep: async (ticks) => {
+ tick += ticks;
+ await renderer.renderAsync(scene, camera);
+ return ticks;
+ },
+ renderer,
+ resources: { read: () => ({ exposure: renderer.toneMappingExposure }) },
+ scene,
+ tick: () => tick,
+});
diff --git a/examples/abyss-framework/tone.html b/examples/abyss-framework/tone.html
new file mode 100644
index 0000000000..8c7a72ec21
--- /dev/null
+++ b/examples/abyss-framework/tone.html
@@ -0,0 +1,5 @@
+
+
+ Tone exposure calibration
+
+
diff --git a/examples/abyss-framework/tone.vite.config.ts b/examples/abyss-framework/tone.vite.config.ts
new file mode 100644
index 0000000000..63fb18f205
--- /dev/null
+++ b/examples/abyss-framework/tone.vite.config.ts
@@ -0,0 +1,12 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+
+// A deterministic fixture with no cooked assets. Use the freshly built public playtest bridge.
+export default defineConfig({
+ publicDir: false,
+ build: {
+ emptyOutDir: true,
+ outDir: "../../artifacts/tone-fixture",
+ rollupOptions: { input: fileURLToPath(new URL("tone.html", import.meta.url)) },
+ },
+});
diff --git a/examples/prd476-fluid-particles/index.html b/examples/prd476-fluid-particles/index.html
new file mode 100644
index 0000000000..6201860c1d
--- /dev/null
+++ b/examples/prd476-fluid-particles/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ PRD-476 fluid particles
+
+
+
+
+
+
diff --git a/examples/prd476-fluid-particles/package.json b/examples/prd476-fluid-particles/package.json
new file mode 100644
index 0000000000..10801707cf
--- /dev/null
+++ b/examples/prd476-fluid-particles/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "prd476-fluid-particles",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:desktop": "node ../../packages/runtime-native/scripts/bundle.mjs --project . --entry src/game.ts --target desktop --output dist/prd476-fluid-particles-native.js",
+ "build": "vite build",
+ "dev": "vite",
+ "typecheck": "tsc --noEmit -p tsconfig.json"
+ },
+ "dependencies": {
+ "@threenative/core": "workspace:*",
+ "@threenative/physics": "workspace:*",
+ "@threenative/playtest": "workspace:*",
+ "three": "catalog:"
+ },
+ "devDependencies": {
+ "@types/three": "0.185.3",
+ "typescript": "catalog:",
+ "vite": "catalog:"
+ }
+}
diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json
new file mode 100644
index 0000000000..343791f5ba
--- /dev/null
+++ b/examples/prd476-fluid-particles/playtests/fluid-particles-coupling.playtest.json
@@ -0,0 +1,49 @@
+{
+ "name": "prd476-fluid-particles-coupling",
+ "schemaVersion": 1,
+ "subject": "a dense sphere sinks, a light box floats on particle water, and the solver reads their transforms every step",
+ "target": "web",
+ "viewport": { "width": 1280, "height": 720 },
+ "warmupFrames": 20,
+ "artifacts": { "screenshots": true },
+ "steps": [
+ {
+ "kind": "wait",
+ "label": "falling",
+ "waitTicks": 60,
+ "release": true,
+ "screenshot": "falling"
+ },
+ { "kind": "wait", "label": "splash", "waitTicks": 40, "release": true, "screenshot": "splash" },
+ { "kind": "wait", "label": "settling", "waitTicks": 300, "release": true },
+ {
+ "kind": "wait",
+ "label": "settled",
+ "waitTicks": 300,
+ "release": true,
+ "screenshot": "settled"
+ }
+ ],
+ "assert": {
+ "diagnostics": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true,
+ "runtimeReady": true
+ },
+ "resources": [
+ { "id": "GameState", "path": "steps", "changed": true },
+ { "id": "GameState", "path": "count", "gte": 2000 },
+ { "id": "GameState", "path": "sphereY", "lte": 0.45 },
+ { "id": "GameState", "path": "sphereSpeed", "lte": 0.2 },
+ { "id": "GameState", "path": "boxSurfaceGap", "lte": 0.2 },
+ { "id": "GameState", "path": "surfaceDisturbance", "gte": 0.05 }
+ ],
+ "visual": [
+ {
+ "region": { "x": 0, "y": 0, "width": 1280, "height": 720, "minNonblankPixelRatio": 0.01 },
+ "frameDiff": { "minChangedPixelRatio": 0.002 }
+ }
+ ]
+ }
+}
diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json
new file mode 100644
index 0000000000..8ea929271c
--- /dev/null
+++ b/examples/prd476-fluid-particles/playtests/fluid-particles-desktop.playtest.json
@@ -0,0 +1,141 @@
+{
+ "name": "prd476-fluid-particles-native-dam-break",
+ "schemaVersion": 1,
+ "subject": "a released dam-break column runs across the tank and settles on the GPU",
+ "target": "desktop",
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "warmupFrames": 20,
+ "artifacts": {
+ "screenshots": true,
+ "console": true
+ },
+ "steps": [
+ {
+ "kind": "wait",
+ "label": "gated",
+ "waitTicks": 30,
+ "release": true,
+ "screenshot": "gated"
+ },
+ {
+ "kind": "wait",
+ "label": "running",
+ "waitTicks": 90,
+ "release": true,
+ "screenshot": "running"
+ },
+ {
+ "kind": "wait",
+ "label": "settle-0",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-1",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-2",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-3",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-4",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-5",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-6",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-7",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-8",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-9",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-10",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settle-11",
+ "waitTicks": 50,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settled",
+ "waitTicks": 50,
+ "release": true,
+ "screenshot": "settled"
+ }
+ ],
+ "assert": {
+ "resources": [
+ {
+ "id": "GameState",
+ "path": "steps",
+ "changed": true
+ },
+ {
+ "id": "GameState",
+ "path": "peakFrontX",
+ "gte": 1.0
+ },
+ {
+ "id": "GameState",
+ "path": "maxSpeed",
+ "lte": 1.0
+ },
+ {
+ "id": "GameState",
+ "path": "meanCompression",
+ "lte": 0.05,
+ "allowTrivial": "Native stats readbacks have landed before the first observation, so this held invariant is satisfied from the start; peakFrontX and maxSpeed prove the water moved and settled."
+ },
+ {
+ "id": "GameState",
+ "path": "inBounds",
+ "equals": 1,
+ "allowTrivial": "Native stats readbacks have landed before the first observation, so this held invariant is satisfied from the start; peakFrontX and maxSpeed prove the water moved and settled."
+ }
+ ]
+ }
+}
diff --git a/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json b/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json
new file mode 100644
index 0000000000..2530189376
--- /dev/null
+++ b/examples/prd476-fluid-particles/playtests/fluid-particles.playtest.json
@@ -0,0 +1,97 @@
+{
+ "name": "prd476-fluid-particles-dam-break",
+ "schemaVersion": 1,
+ "subject": "a released dam-break column runs across the tank and settles on the GPU",
+ "target": "web",
+ "viewport": {
+ "width": 1280,
+ "height": 720
+ },
+ "warmupFrames": 20,
+ "artifacts": {
+ "screenshots": true
+ },
+ "steps": [
+ {
+ "kind": "wait",
+ "label": "gated",
+ "waitTicks": 30,
+ "release": true,
+ "screenshot": "gated"
+ },
+ {
+ "kind": "wait",
+ "label": "running",
+ "waitTicks": 90,
+ "release": true,
+ "screenshot": "running"
+ },
+ {
+ "kind": "wait",
+ "label": "settling",
+ "waitTicks": 300,
+ "release": true
+ },
+ {
+ "kind": "wait",
+ "label": "settled",
+ "waitTicks": 400,
+ "release": true,
+ "screenshot": "settled"
+ }
+ ],
+ "assert": {
+ "diagnostics": {
+ "noConsoleErrors": true,
+ "noNetworkErrors": true,
+ "noRuntimeDiagnostics": true,
+ "runtimeReady": true
+ },
+ "resources": [
+ {
+ "id": "GameState",
+ "path": "steps",
+ "changed": true
+ },
+ {
+ "id": "GameState",
+ "path": "count",
+ "gte": 1600
+ },
+ {
+ "id": "GameState",
+ "path": "peakFrontX",
+ "gte": 1.0
+ },
+ {
+ "id": "GameState",
+ "path": "meanCompression",
+ "lte": 0.05
+ },
+ {
+ "id": "GameState",
+ "path": "maxSpeed",
+ "lte": 1.0
+ },
+ {
+ "id": "GameState",
+ "path": "inBounds",
+ "equals": 1
+ }
+ ],
+ "visual": [
+ {
+ "region": {
+ "x": 0,
+ "y": 0,
+ "width": 1280,
+ "height": 720,
+ "minNonblankPixelRatio": 0.01
+ },
+ "frameDiff": {
+ "minChangedPixelRatio": 0.002
+ }
+ }
+ ]
+ }
+}
diff --git a/examples/prd476-fluid-particles/src/conformance.js b/examples/prd476-fluid-particles/src/conformance.js
new file mode 100644
index 0000000000..e38257857a
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/conformance.js
@@ -0,0 +1,112 @@
+import { vec3 } from "three/tsl";
+import * as three from "three/webgpu";
+import { FluidParticles3D } from "../../../packages/core/src/fluid-particles.ts";
+import { warmUpScene } from "../../../packages/core/src/warmup.ts";
+import {
+ assertCondition,
+ startVisualScene,
+} from "../../../packages/runtime-native/conformance/scenes/shared/scene-support.js";
+
+const STEPS = 90;
+const BOUNDS = { min: [-1.1, 0.0968, -0.55], max: [1.1, 2, 0.55] };
+
+function isNativeConformanceRuntime() {
+ return String(globalThis.__TN_ASSET_BASE__ ?? "").endsWith(":0/");
+}
+
+async function waitForSubmittedWork(renderer) {
+ await renderer.backend?.device?.queue?.onSubmittedWorkDone?.();
+}
+
+/** A small block of water drops, splashes and settles; the same passes run on web and desktop. */
+export async function startScene(canvas, dimensions) {
+ return startVisualScene(
+ canvas,
+ dimensions,
+ "fluid-particles",
+ async ({ camera, renderer, scene }) => {
+ const water = new FluidParticles3D({
+ bounds: BOUNDS,
+ capacity: 600,
+ readbackEvery: 1,
+ spacing: 0.22,
+ });
+ const computeRenderer = {
+ kind: "webgpu",
+ compute: (node) => renderer.compute(node),
+ readback: (attribute) => renderer.getArrayBufferAsync(attribute),
+ };
+ water.attachRenderer(computeRenderer);
+ const warmup = await warmUpScene(renderer, scene, camera, {
+ computeNodes: water.warmupNodes,
+ });
+ assertCondition(
+ warmup.computeCompiled === water.warmupNodes.length,
+ "fluid particles warm-up must compile every ordered solver pass",
+ );
+
+ const placed = water.fill([-0.9, 0.9, -0.44], [-0.1, 1.8, 0.44]);
+ assertCondition(placed >= 100, `fluid particles must place a block, placed ${placed}`);
+ for (let index = 0; index < STEPS; index += 1) water.process(computeRenderer);
+ await waitForSubmittedWork(renderer);
+ assertCondition(water.steps === STEPS, "fluid particles must advance every fixed step");
+
+ let stats = null;
+ if (!isNativeConformanceRuntime()) {
+ // The readback lands asynchronously; give it a few frames before judging the solver.
+ for (let attempt = 0; attempt < 40 && water.stats === undefined; attempt += 1) {
+ water.process(computeRenderer);
+ await waitForSubmittedWork(renderer);
+ await new Promise((resolve) => setTimeout(resolve, 25));
+ }
+ stats = water.stats ?? null;
+ assertCondition(stats !== null, "fluid particles stats readback must land on web");
+ assertCondition(
+ stats.count === placed,
+ `live particles ${stats.count} must equal ${placed}`,
+ );
+ assertCondition(
+ stats.meanCompression < 0.05,
+ `compression ${stats.meanCompression} must stay low`,
+ );
+ for (let axis = 0; axis < 3; axis += 1) {
+ assertCondition(
+ stats.min[axis] >= BOUNDS.min[axis] - 0.01 &&
+ stats.max[axis] <= BOUNDS.max[axis] + 0.01,
+ "fluid particles must stay inside their bounds",
+ );
+ }
+ console.log(`FLUID_PARTICLES_STATS:${JSON.stringify(stats)}`);
+ } else {
+ console.log(
+ "FLUID_PARTICLES_READBACK:skipped; native readback is verified by parity capture",
+ );
+ }
+
+ const material = new three.SpriteNodeMaterial({ depthWrite: true });
+ const particle = water.positions.toAttribute();
+ material.positionNode = particle.xyz;
+ material.scaleNode = particle.w.mul(0.17);
+ material.colorNode = vec3(0.25, 0.75, 1);
+ const points = new three.Sprite(material);
+ points.count = water.capacity;
+ points.frustumCulled = false;
+ scene.add(points);
+ camera.position.set(0, 1.2, 4.2);
+ camera.lookAt(0, 0.8, 0);
+
+ // The solver keeps stepping per frame so a capture sees the same settled state on both hosts.
+ return {
+ detail: {
+ fixedSteps: water.steps,
+ particles: placed,
+ meanCompression: stats === null ? null : Number(stats.meanCompression.toFixed(6)),
+ warmupNodes: water.warmupNodes.length,
+ },
+ mesh: points,
+ water,
+ };
+ },
+ { background: 0x05070e },
+ );
+}
diff --git a/examples/prd476-fluid-particles/src/coupling.ts b/examples/prd476-fluid-particles/src/coupling.ts
new file mode 100644
index 0000000000..ea051ebc0b
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/coupling.ts
@@ -0,0 +1,148 @@
+import { FluidParticles3D, type ICtx, Scene, type SceneFrame } from "@threenative/core";
+import {
+ Buoyancy3D,
+ CollisionShape3D,
+ type IPhysicsContext,
+ RigidBody3D,
+} from "@threenative/physics";
+import { BoxGeometry, Mesh, MeshBasicMaterial, SphereGeometry } from "three";
+import { createPointsView } from "./render/points.js";
+import { type IFluidParticlesState, INITIAL_STATE } from "./state.js";
+
+/** The bodies fall ~6.6 m (about 70 steps) onto the pool; its rest level is read at step 55. */
+const LEVEL_STEP = 55;
+const WATER_DENSITY = 1000;
+
+interface IBodySpec {
+ readonly name: "sphere" | "boxLight" | "boxSmall";
+ readonly density: number;
+ readonly start: readonly [number, number, number];
+ readonly half: readonly [number, number, number];
+}
+
+const SPHERE_RADIUS = 0.3;
+const BODIES: readonly IBodySpec[] = [
+ {
+ name: "sphere",
+ density: 1900,
+ start: [-1, 8, 0],
+ half: [SPHERE_RADIUS, SPHERE_RADIUS, SPHERE_RADIUS],
+ },
+ { name: "boxLight", density: 550, start: [1.2, 8.5, 0], half: [0.4, 0.4, 0.4] },
+ { name: "boxSmall", density: 300, start: [1.2, 9.5, -0.8], half: [0.25, 0.25, 0.25] },
+];
+
+export class CouplingScene extends Scene {
+ static override readonly initialState: IFluidParticlesState = INITIAL_STATE;
+
+ override enter(
+ ctx: ICtx,
+ ): SceneFrame {
+ const water = new FluidParticles3D({ capacity: 4000, readbackEvery: 1 });
+ ctx.add(water);
+ ctx.add(createPointsView(water, ctx.scene, ctx.camera));
+ water.fill([-2.8, 0.1, -1.5], [2.8, 1.3, 1.5]);
+
+ // The tank floor the bodies can rest on; its top is the water's floor.
+ const floor = new RigidBody3D({
+ collisionLayer: 1,
+ physics: ctx.physics,
+ position: { x: 0, y: -1, z: 0 },
+ shape: CollisionShape3D.box(8, 2, 5),
+ type: "fixed",
+ });
+
+ const bodies = BODIES.map((spec) => {
+ const isSphere = spec.name === "sphere";
+ const mesh = new Mesh(
+ isSphere
+ ? new SphereGeometry(spec.half[0], 20, 14)
+ : new BoxGeometry(spec.half[0] * 2, spec.half[1] * 2, spec.half[2] * 2),
+ new MeshBasicMaterial({ color: isSphere ? 0xf2a65a : 0xe8e0c8 }),
+ );
+ mesh.position.set(...spec.start);
+ ctx.add(mesh);
+ const volume = isSphere
+ ? (4 / 3) * Math.PI * spec.half[0] ** 3
+ : 8 * spec.half[0] * spec.half[1] * spec.half[2];
+ const body = new RigidBody3D({
+ collisionLayer: 1,
+ mass: spec.density * volume,
+ object: mesh,
+ physics: ctx.physics,
+ shape: isSphere
+ ? CollisionShape3D.sphere(spec.half[0])
+ : CollisionShape3D.box(spec.half[0] * 2, spec.half[1] * 2, spec.half[2] * 2),
+ });
+ const [hx, hy, hz] = spec.half;
+ new Buoyancy3D({
+ body,
+ density: WATER_DENSITY,
+ drag: isSphere ? 800 : 2500,
+ gravity: 9.81,
+ // One point at the centre: heave only. Points off-centre would feed surface noise into
+ // torque, and Rapier angular damping is not on the body options.
+ hullPoints: [{ position: [0, 0, 0] }],
+ pointSpacing: hy * 2,
+ surface: water,
+ volume,
+ });
+ return { body, mesh, spec };
+ });
+ const sphere = bodies[0] as (typeof bodies)[number];
+ const box = bodies[1] as (typeof bodies)[number];
+
+ // Each fixed step the solver learns where the bodies are, so its particles flow around them.
+ ctx.afterPhysics(() => {
+ water.setColliders(
+ bodies.map(({ mesh, spec }) =>
+ spec.name === "sphere"
+ ? {
+ kind: "sphere",
+ center: mesh.position.toArray() as [number, number, number],
+ radius: spec.half[0],
+ }
+ : {
+ kind: "box",
+ center: mesh.position.toArray() as [number, number, number],
+ halfExtents: spec.half,
+ rotation: mesh.quaternion.toArray() as [number, number, number, number],
+ },
+ ),
+ );
+ });
+
+ let level0 = 0;
+ let disturbance = 0;
+ return (frame) => {
+ void floor; // the fixed floor body must outlive the scene
+ const stats = water.stats;
+ if (water.steps === LEVEL_STEP) level0 = water.heightAt(sphere.spec.start[0], 0);
+ if (water.steps > LEVEL_STEP)
+ disturbance = Math.max(
+ disturbance,
+ Math.abs(water.heightAt(sphere.spec.start[0], 0) - level0),
+ );
+ const surfaceAtBox = water.heightAt(box.mesh.position.x, box.mesh.position.z);
+ const landed = stats !== undefined;
+ frame.state.set({
+ count: stats?.count ?? 0,
+ steps: water.steps,
+ meanCompression: stats?.meanCompression ?? 1,
+ maxSpeed: stats?.maxSpeed ?? 99,
+ staleFrames: stats?.staleFrames ?? 0,
+ sphereY: sphere.mesh.position.y,
+ sphereSpeed: Math.hypot(
+ sphere.body.linearVelocity.x,
+ sphere.body.linearVelocity.y,
+ sphere.body.linearVelocity.z,
+ ),
+ surfaceRest: level0,
+ boxY: box.mesh.position.y,
+ boxSurfaceGap: landed ? Math.abs(box.mesh.position.y - surfaceAtBox) : 99,
+ surfaceDisturbance: disturbance,
+ surface: surfaceAtBox,
+ });
+ };
+ }
+}
diff --git a/examples/prd476-fluid-particles/src/game.ts b/examples/prd476-fluid-particles/src/game.ts
new file mode 100644
index 0000000000..c450550257
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/game.ts
@@ -0,0 +1,117 @@
+import { FluidParticles3D, type ICtx, Scene, type SceneFrame, defineGame } from "@threenative/core";
+import { playtest } from "@threenative/core/playtest";
+import { type IPhysicsContext, rapier } from "@threenative/physics";
+import { CouplingScene } from "./coupling.js";
+import { createPointsView } from "./render/points.js";
+import { type IFluidParticlesState, INITIAL_STATE } from "./state.js";
+
+/** The dam gate comes out after this many fixed steps (0.8 s, as in the lab). */
+const GATE_STEPS = 48;
+const GATE = { kind: "box", center: [-0.9, 2.4, 0], halfExtents: [0.05, 2.4, 1.6] } as const;
+
+class DamBreakScene extends Scene {
+ static override readonly initialState: IFluidParticlesState = INITIAL_STATE;
+
+ override enter(
+ ctx: ICtx,
+ ): SceneFrame {
+ const query = new URLSearchParams(globalThis.location?.search ?? "");
+ const knob = (name: string): number | undefined =>
+ query.has(name) ? Number(query.get(name)) : undefined;
+ const water = new FluidParticles3D({
+ capacity: 6000,
+ viscosity: knob("visc"),
+ cohesion: knob("coh"),
+ iterations: knob("iter"),
+ vorticity: knob("vort"),
+ });
+ ctx.add(water);
+ ctx.add(createPointsView(water, ctx.scene, ctx.camera));
+ // ?stress=1 fills the whole tank to capacity (6,000 particles) and keeps no gate.
+ const stress = new URLSearchParams(globalThis.location?.search ?? "").has("stress");
+ if (stress) water.fill([-2.8, 0.1, -1.5], [2.8, 3.6, 1.5]);
+ else {
+ water.setColliders([GATE]);
+ water.fill([-2.8, 0.1, -1.5], [-1.0, 2.8, 1.5]);
+ }
+
+ if (new URLSearchParams(globalThis.location?.search ?? "").has("bench")) {
+ // Back-to-back solver steps timed to GPU completion: the cost of one fixed step, with no
+ // presentation in the number.
+ const device = (
+ ctx.renderer.raw as {
+ backend?: { device?: { queue: { onSubmittedWorkDone(): Promise } } };
+ }
+ ).backend?.device;
+ (globalThis as Record).__fluidBench = async (steps: number) => {
+ await device?.queue.onSubmittedWorkDone();
+ const start = performance.now();
+ for (let step = 0; step < steps; step += 1) water.process(ctx.renderer);
+ await device?.queue.onSubmittedWorkDone();
+ return (performance.now() - start) / steps;
+ };
+ // GPU-side cost per step from timestamp queries (browser `allow_unsafe_apis` dawn feature);
+ // the pool holds 2,048 queries at two per pass, so keep `steps` under ~40.
+ const raw = ctx.renderer.raw as {
+ backend?: { trackTimestamp?: boolean };
+ info?: { compute?: { timestamp?: number } };
+ resolveTimestampsAsync?: (type: string) => Promise;
+ };
+ (globalThis as Record).__fluidGpuMs = async (steps: number) => {
+ if (raw.backend === undefined || raw.resolveTimestampsAsync === undefined) return -1;
+ raw.backend.trackTimestamp = true;
+ for (let step = 0; step < steps; step += 1) water.process(ctx.renderer);
+ await device?.queue.onSubmittedWorkDone();
+ await raw.resolveTimestampsAsync("compute");
+ raw.backend.trackTimestamp = false;
+ return (raw.info?.compute?.timestamp ?? -1) / steps;
+ };
+ }
+ (globalThis as Record).__fluidProbe = () => ({
+ steps: water.steps,
+ stats: water.stats,
+ });
+ let peakFrontX = 0;
+ return (frame) => {
+ if (water.steps === GATE_STEPS) water.setColliders([]);
+ const stats = water.stats;
+ const { min, max } = water.bounds;
+ const slack = 0.01;
+ peakFrontX = Math.max(peakFrontX, stats?.max[0] ?? 0);
+ frame.state.set({
+ count: stats?.count ?? 0,
+ steps: water.steps,
+ meanCompression: stats?.meanCompression ?? 1,
+ maxSpeed: stats?.maxSpeed ?? 99,
+ frontX: stats?.max[0] ?? 0,
+ peakFrontX,
+ minY: stats?.min[1] ?? 0,
+ maxY: stats?.max[1] ?? 0,
+ inBounds:
+ stats !== undefined &&
+ [0, 1, 2].every(
+ (axis) =>
+ (stats.min[axis] as number) >= (min[axis] as number) - slack &&
+ (stats.max[axis] as number) <= (max[axis] as number) + slack,
+ )
+ ? 1
+ : 0,
+ released: water.steps > GATE_STEPS ? 1 : 0,
+ staleFrames: stats?.staleFrames ?? 0,
+ });
+ };
+ }
+}
+
+const game = defineGame({
+ step: 1 / 60,
+ plugins: [rapier({ gravity: { x: 0, y: -9.81, z: 0 } }), playtest()],
+ render: { preferWebGPU: true },
+ scenes: { coupling: CouplingScene, dam: DamBreakScene },
+ start:
+ new URLSearchParams(globalThis.location?.search ?? "").get("scene") === "coupling"
+ ? "coupling"
+ : "dam",
+});
+
+export default game;
diff --git a/examples/prd476-fluid-particles/src/main.ts b/examples/prd476-fluid-particles/src/main.ts
new file mode 100644
index 0000000000..3d75795336
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/main.ts
@@ -0,0 +1,3 @@
+import game from "./game.js";
+
+void game.start();
diff --git a/examples/prd476-fluid-particles/src/render/points.ts b/examples/prd476-fluid-particles/src/render/points.ts
new file mode 100644
index 0000000000..f9d5a711a4
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/render/points.ts
@@ -0,0 +1,45 @@
+import type { FluidParticles3D } from "@threenative/core";
+import {
+ BoxGeometry,
+ type Camera,
+ Color,
+ EdgesGeometry,
+ Group,
+ LineBasicMaterial,
+ LineSegments,
+ type Scene,
+} from "three";
+import { vec3 } from "three/tsl";
+import { Sprite, SpriteNodeMaterial } from "three/webgpu";
+import { createWaterVolume } from "./water-volume.js";
+
+/** The raymarched water by default; `?points` swaps in the debug look, one flat dot per particle. */
+export function createPointsView(water: FluidParticles3D, scene: Scene, camera: Camera): Group {
+ scene.background = new Color(0x0e2238);
+ camera.position.set(0, 3.0, 7.6);
+ camera.lookAt(0, 1.3, 0);
+
+ const view = new Group();
+ const material = new SpriteNodeMaterial({ transparent: false, depthWrite: true });
+ const particle = water.positions.toAttribute();
+ const speed = water.velocities.toAttribute();
+ material.positionNode = particle.xyz;
+ material.scaleNode = particle.w.mul(water.spacing * 0.9);
+ material.colorNode = vec3(0.25, 0.75, 1).add(speed.w.mul(0.6));
+ const points = new Sprite(material);
+ points.count = water.capacity;
+ points.frustumCulled = false;
+ const debug = new URLSearchParams(globalThis.location?.search ?? "").has("points");
+ if (debug) view.add(points);
+ else view.add(createWaterVolume(water));
+
+ const { min, max } = water.bounds;
+ const size = [max[0] - min[0], max[1] - min[1], max[2] - min[2]] as const;
+ const tank = new LineSegments(
+ new EdgesGeometry(new BoxGeometry(...size)),
+ new LineBasicMaterial({ color: 0x6f95b8 }),
+ );
+ tank.position.set(min[0] + size[0] / 2, min[1] + size[1] / 2, min[2] + size[2] / 2);
+ view.add(tank);
+ return view;
+}
diff --git a/examples/prd476-fluid-particles/src/render/water-volume.ts b/examples/prd476-fluid-particles/src/render/water-volume.ts
new file mode 100644
index 0000000000..ac894540d1
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/render/water-volume.ts
@@ -0,0 +1,193 @@
+import type { FluidParticles3D } from "@threenative/core";
+import { BackSide, BoxGeometry, Mesh } from "three";
+import * as tsl from "three/tsl";
+import { MeshBasicNodeMaterial } from "three/webgpu";
+
+// The look is this file's: a raymarch of the solver's density volume with a refracted checker
+// floor, Beer-Lambert absorption and Schlick Fresnel. Swap any of it without touching packages/.
+// quality-allow: TSL nodes are typed per swizzle by three 0.185; this file is arithmetic over them.
+// biome-ignore lint/suspicious/noExplicitAny: see above.
+type N = any;
+const {
+ Break,
+ Fn,
+ If,
+ Loop,
+ cameraPosition,
+ clamp,
+ dot,
+ exp,
+ float,
+ floor,
+ hash,
+ max,
+ min,
+ mix,
+ select,
+ normalize,
+ pow,
+ positionWorld,
+ reflect,
+ refract,
+ texture3D,
+ vec3,
+ vec4,
+ // biome-ignore lint/suspicious/noExplicitAny: see above.
+} = tsl as unknown as Record;
+
+export interface IWaterVolumeOptions {
+ /** Ray steps through the tank. 72 resolves 0.11 m voxels across a 7 m diagonal. */
+ readonly steps?: number;
+ readonly deep?: readonly [number, number, number];
+ readonly shallow?: readonly [number, number, number];
+ /** Absorption per metre of water, per channel. */
+ readonly absorption?: readonly [number, number, number];
+}
+
+const DENSITY_THRESHOLD = 0.5;
+
+export function createWaterVolume(
+ water: FluidParticles3D,
+ options: IWaterVolumeOptions = {},
+): Mesh {
+ const steps = options.steps ?? 72;
+ const deep = vec3(...(options.deep ?? [0.0, 0.2, 0.45]));
+ const shallow = vec3(...(options.shallow ?? [0.15, 0.72, 0.95]));
+ const absorption = vec3(...(options.absorption ?? [0.7, 0.22, 0.1]));
+ const { min: lo, max: hi } = water.bounds;
+ const bmin = vec3(...lo);
+ const bmax = vec3(...hi);
+ const size = vec3(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]);
+ const voxel = water.voxelSize;
+ // The density texture spans whole voxels, a little past the bounds on the long axes.
+ const extent = vec3(...water.volumeSize.map((count) => count * voxel));
+ const volume = water.density;
+
+ // Sample inside the walls: the field fades over the last spacing next to a wall, which would
+ // read as a free surface pressed against the glass. Clamping keeps the water full to the wall.
+ const pad = water.spacing * 1.2;
+ const inner = (p: N): N =>
+ vec3(
+ clamp(p.x, bmin.x.add(pad), bmax.x.sub(pad)),
+ max(p.y, bmin.y.add(pad)),
+ clamp(p.z, bmin.z.add(pad), bmax.z.sub(pad)),
+ );
+ const density = (p: N): N => texture3D(volume, inner(p).sub(bmin).div(extent).clamp(0, 1)).x;
+ const gradient = (p: N): N => {
+ const e = voxel * 1.5;
+ return vec3(
+ density(p.add(vec3(e, 0, 0))).sub(density(p.sub(vec3(e, 0, 0)))),
+ density(p.add(vec3(0, e, 0))).sub(density(p.sub(vec3(0, e, 0)))),
+ density(p.add(vec3(0, 0, e))).sub(density(p.sub(vec3(0, 0, e)))),
+ );
+ };
+ const sky = (d: N): N =>
+ mix(vec3(0.7, 0.85, 1.0), vec3(0.12, 0.3, 0.6), clamp(d.y.mul(1.4), 0, 1));
+ const floorColor = (p: N): N => {
+ const cell = floor(p.x.mul(2))
+ .add(floor(p.z.mul(2)))
+ .mod(2);
+ return mix(vec3(0.12, 0.2, 0.3), vec3(0.28, 0.42, 0.55), cell);
+ };
+
+ const march = Fn(() => {
+ const origin = cameraPosition;
+ const direction = normalize(positionWorld.sub(cameraPosition));
+ const inverse = vec3(1).div(direction);
+ const t1 = bmin.sub(origin).mul(inverse);
+ const t2 = bmax.sub(origin).mul(inverse);
+ const near = max(max(min(t1.x, t2.x), min(t1.y, t2.y)), max(min(t1.z, t2.z), 0));
+ const far = min(min(max(t1.x, t2.x), max(t1.y, t2.y)), max(t1.z, t2.z));
+ const stride = far.sub(near).div(steps);
+ const t = near
+ .add(stride.mul(hash(positionWorld.x.mul(131).add(positionWorld.y.mul(197)))))
+ .toVar();
+ const hit = float(0).toVar();
+ const hitPoint = vec3(0).toVar();
+ const previous = float(0).toVar();
+ // A ray that starts under water (looking through the glass) travels until it leaves the water;
+ // one that starts in air travels until it meets the surface.
+ const entry = origin.add(direction.mul(near));
+ const inside = density(entry).greaterThan(DENSITY_THRESHOLD);
+ previous.assign(density(origin.add(direction.mul(t))));
+ Loop(steps, () => {
+ const p = origin.add(direction.mul(t));
+ const d = density(p);
+ const crossed = select(
+ inside,
+ d.lessThan(DENSITY_THRESHOLD),
+ d.greaterThan(DENSITY_THRESHOLD),
+ );
+ If(crossed.and(hit.lessThan(0.5)), () => {
+ const k = clamp(
+ float(DENSITY_THRESHOLD)
+ .sub(previous)
+ .div(max(d.sub(previous).abs(), 1e-4).mul(select(inside, float(-1), float(1)))),
+ 0,
+ 1,
+ );
+ hitPoint.assign(origin.add(direction.mul(t.sub(stride.mul(float(1).sub(k))))));
+ hit.assign(1);
+ Break();
+ });
+ previous.assign(d);
+ t.addAssign(stride);
+ });
+
+ // Through the glass: Beer-Lambert along the path in water, then whatever the ray reaches.
+ const farPoint = origin.add(direction.mul(far));
+ const leave = select(hit.greaterThan(0.5), hitPoint, farPoint);
+ const path = clamp(leave.sub(entry).length().mul(2.2), 0, 6);
+ const trans = exp(absorption.mul(path).negate());
+ const sideBody = mix(deep, shallow, clamp(float(1.1).sub(path.mul(0.25)), 0, 1));
+ const underside = mix(sky(vec3(direction.x, direction.y.negate(), direction.z)), shallow, 0.35);
+ const onFloor = leave.y.lessThan(lo[1] + 0.05);
+ const sideEnd = select(
+ hit.greaterThan(0.5),
+ underside,
+ select(onFloor, floorColor(leave).mul(0.8), mix(deep, shallow, 0.45)),
+ );
+ const sideColor = sideEnd.mul(trans).add(sideBody.mul(vec3(1).sub(trans)));
+
+ // From the air: gradient normal, refracted floor, Fresnel, sun glint.
+ const normal = normalize(
+ gradient(hitPoint)
+ .negate()
+ .add(vec3(0, 1e-3, 0)),
+ );
+ const facing = clamp(dot(normal, direction.negate()), 0, 1);
+ const fresnel = float(0.02).add(float(0.98).mul(pow(float(1).sub(facing), 5)));
+ const bent = refract(direction, normal, float(1 / 1.33));
+ const toFloor = max(hitPoint.y.sub(lo[1]), 0.02).div(max(bent.y.negate(), 0.05));
+ const floorPoint = hitPoint.add(bent.mul(toFloor));
+ const through = clamp(toFloor, 0, 6);
+ const transmittance = exp(absorption.mul(through).negate());
+ const body = mix(deep, shallow, clamp(through.mul(-0.4).add(1.2), 0, 1));
+ const refracted = floorColor(floorPoint)
+ .mul(transmittance)
+ .add(body.mul(vec3(1).sub(transmittance)));
+ const mirror = sky(reflect(direction, normal));
+ const sun = pow(
+ clamp(dot(reflect(direction, normal), normalize(vec3(0.4, 0.8, 0.3))), 0, 1),
+ 90,
+ ).mul(1.2);
+ const airColor = mix(refracted, mirror, fresnel).add(sun);
+ const color = select(inside, sideColor, airColor);
+ return vec4(color, select(inside, float(1), hit));
+ });
+
+ const material = new MeshBasicNodeMaterial({
+ side: BackSide,
+ transparent: true,
+ depthWrite: false,
+ });
+ material.toneMapped = false;
+ const shaded = march();
+ material.colorNode = shaded.xyz;
+ material.opacityNode = shaded.w;
+ const mesh = new Mesh(new BoxGeometry(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]), material);
+ mesh.position.set((lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2);
+ mesh.frustumCulled = false;
+ mesh.renderOrder = 2;
+ return mesh;
+}
diff --git a/examples/prd476-fluid-particles/src/state.ts b/examples/prd476-fluid-particles/src/state.ts
new file mode 100644
index 0000000000..79bb9cd3e5
--- /dev/null
+++ b/examples/prd476-fluid-particles/src/state.ts
@@ -0,0 +1,45 @@
+export interface IFluidParticlesState extends Record {
+ count: number;
+ steps: number;
+ meanCompression: number;
+ maxSpeed: number;
+ frontX: number;
+ peakFrontX: number;
+ minY: number;
+ maxY: number;
+ inBounds: number;
+ released: number;
+ staleFrames: number;
+ /** Coupling scene: 99 until the body exists, then metres. */
+ sphereY: number;
+ sphereSpeed: number;
+ boxY: number;
+ /** |box centre - heightAt(box)|, 99 until the surface has landed. */
+ boxSurfaceGap: number;
+ /** Largest |surface - resting level| seen at the drop point after the sphere is released. */
+ surfaceDisturbance: number;
+ surface: number;
+ /** Surface height at the drop point before the sphere arrives. */
+ surfaceRest: number;
+}
+
+export const INITIAL_STATE: IFluidParticlesState = {
+ count: 0,
+ steps: 0,
+ meanCompression: 1,
+ maxSpeed: 99,
+ frontX: 0,
+ peakFrontX: 0,
+ minY: 0,
+ maxY: 0,
+ inBounds: 0,
+ released: 0,
+ staleFrames: 0,
+ sphereY: 99,
+ sphereSpeed: 99,
+ boxY: 99,
+ boxSurfaceGap: 99,
+ surfaceDisturbance: 0,
+ surface: 0,
+ surfaceRest: 0,
+};
diff --git a/examples/prd476-fluid-particles/tsconfig.json b/examples/prd476-fluid-particles/tsconfig.json
new file mode 100644
index 0000000000..ea5c89279a
--- /dev/null
+++ b/examples/prd476-fluid-particles/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "types": ["vite/client"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/package.json b/package.json
index 516f952332..a1770f17f3 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
"check:flight-cost": "tsx scripts/check-flight-cost.ts --max-mean-ms 0.35",
"ci:fast": "bash scripts/ci-fast.sh",
"ci:local": "bash scripts/ci-local.sh",
+ "ci:runners": "bash scripts/ci-runners.sh",
+ "ci:runners:install": "bash scripts/ci-runners.sh install",
"coverage": "vitest run --coverage",
"gate:doctor": "tsx scripts/gate-cli.ts doctor",
"gate:resume": "tsx scripts/gate-cli.ts resume",
@@ -75,7 +77,8 @@
"test:ci": "TN_SUITE_PREBUILT=1 bash scripts/run-test-suite.sh",
"test:playtest": "pnpm --filter @threenative/playtest build && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtest/moves.json --scenario examples/abyss-framework/playtest/camera.json --scenario examples/abyss-framework/playtests/movement-axis.playtest.json --scenario examples/abyss-framework/playtests/zoom-input.playtest.json --url http://127.0.0.1:5180 --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/navigation.playtest.json --url 'http://127.0.0.1:5180/?navigation' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/streaming.playtest.json --url 'http://127.0.0.1:5180/?streaming' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed",
"test:playtest:ci": "test -f packages/playtest/dist/runner/cli.js || { echo 'TN_PREBUILT_PLAYWRIGHT_RUNNER_MISSING: packages/playtest/dist/runner/cli.js' >&2; exit 1; }; sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtest/moves.json --scenario examples/abyss-framework/playtest/camera.json --scenario examples/abyss-framework/playtests/movement-axis.playtest.json --scenario examples/abyss-framework/playtests/zoom-input.playtest.json --url http://127.0.0.1:5180 --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed --allow-software && sh scripts/xvfb.sh node packages/playtest/dist/runner/cli.js --scenario examples/abyss-framework/playtests/navigation.playtest.json --url 'http://127.0.0.1:5180/?navigation' --server-command 'pnpm --filter abyss-framework dev --host 127.0.0.1 --port 5180 --strictPort' --browser-recipe webgpu --headed --allow-software",
- "test:templates": "sh scripts/xvfb.sh tsx scripts/verify-template-playtests.ts",
+ "test:templates": "sh scripts/xvfb.sh tsx scripts/verify-template-playtests.ts && pnpm test:tone",
+ "test:tone": "sh scripts/xvfb.sh tsx scripts/verify-tone-exposure.ts",
"test:vfx-gallery": "pnpm --filter @threenative/playtest build && pnpm --filter @threenative/core build && pnpm --filter vfx-gallery build && tsx scripts/vfx-gallery-visual.ts",
"typecheck": "tsc --noEmit -p tsconfig.json && pnpm -r --if-present run typecheck",
"verify:golden-path": "sh scripts/xvfb.sh tsx scripts/verify-golden-path.ts",
diff --git a/packages/assets/__tests__/audio-fixtures.ts b/packages/assets/__tests__/audio-fixtures.ts
index 6dde64b379..299d5342c7 100644
--- a/packages/assets/__tests__/audio-fixtures.ts
+++ b/packages/assets/__tests__/audio-fixtures.ts
@@ -63,11 +63,13 @@ function writeSample(
bits: number,
float: boolean,
): void {
- const clamped = Math.max(-1, Math.min(1, value));
+ // Float32 WAV is not clamped: values above 1.0 are the headroom a Float32 source can hold, and a
+ // fixture that silently clipped them would make that unrepresentable in any test.
if (float) {
- buffer.writeFloatLE(clamped, at);
+ buffer.writeFloatLE(value, at);
return;
}
+ const clamped = Math.max(-1, Math.min(1, value));
if (bits === 8) {
buffer.writeUInt8(Math.round(clamped * 127) + 128, at);
return;
diff --git a/packages/assets/__tests__/audio-pass.spec.ts b/packages/assets/__tests__/audio-pass.spec.ts
index 23c68297a5..619dff52ce 100644
--- a/packages/assets/__tests__/audio-pass.spec.ts
+++ b/packages/assets/__tests__/audio-pass.spec.ts
@@ -573,6 +573,60 @@ describe("the audio pass pass-through", () => {
expect(compiled.outputBytes.equals(bed)).toBe(true);
});
+ it("should ship a float WAV whose samples exceed 1.0 unchanged, because that is headroom", async () => {
+ // A Float32 WAV is allowed above 1.0 and a storm bed uses it: rain's own wind clip peaks at
+ // 1.053, and the headroom above full scale is deliberate mix, not a defect to normalise away.
+ // Clamping it into [-1, 1] here would make that unrepresentable in a test at all.
+ const above = wavClip({ float: true, frames: RATE, sample: () => 1.053 });
+
+ const compiled = await compileAudio("threenative-audio-float-headroom-", "wind.wav", above, {
+ audio: { overrides: [{ glob: "wind.wav", conditioning: "none" }] },
+ });
+
+ expect(compiled.outputBytes.equals(above)).toBe(true);
+ expect(compiled.entry.audio).toMatchObject({ container: "RIFF/WAVE", reencoded: false });
+ expect((compiled.entry.audio as Record).peakBefore).toBeCloseTo(1.053, 6);
+ expect((compiled.entry.audio as Record).peakAfter).toBeCloseTo(1.053, 6);
+ });
+
+ it("should show what the default pass does to that headroom, so the declaration is not free", async () => {
+ // A sine rather than a constant, because a constant is pure DC and the pass removes DC before
+ // it looks at the peak at all.
+ const above = wavClip({
+ float: true,
+ frames: RATE,
+ sample: (frame) => 1.053 * Math.sin((2 * Math.PI * 220 * frame) / RATE),
+ });
+
+ const cooked = await compileAudio("threenative-audio-float-drift-", "wind.wav", above);
+
+ const audio = cooked.entry.audio as Record;
+ // The ceiling attenuates, and the Vorbis round trip lands a little above it, so the peak the
+ // player hears is neither the authored one nor its absence. That is the drift the declaration
+ // exists to prevent, and the tolerance here is the codec's overshoot, not a fudge.
+ expect(Number(audio.peakAfter)).toBeLessThan(1.053);
+ expect(Number(audio.peakAfter)).toBeCloseTo(10 ** (-1 / 20), 1);
+ });
+
+ it("should assert a declared float loop's seam on the bytes it ships", async () => {
+ const loop = wavClip({
+ float: true,
+ frames: RATE * 2,
+ sample: (frame) => 1.053 * Math.sin((2 * Math.PI * 80 * frame) / RATE),
+ });
+
+ const compiled = await compileAudio("threenative-audio-float-seam-", "bed.wav", loop, {
+ audio: {
+ overrides: [{ conditioning: "none", glob: "bed.wav", loop: { crossFadeMs: 0 } }],
+ },
+ });
+
+ expect(Number((compiled.entry.audio as Record).seamRatio)).toBeLessThanOrEqual(
+ 1.5,
+ );
+ expect(compiled.outputBytes.equals(loop)).toBe(true);
+ });
+
it("should ship an already-conditioned Ogg source untouched instead of re-encoding it", async () => {
// Sixteen of wildwood's nineteen clips are already mono, already under the ceiling and carry
// no DC: re-encoding them cost about 4% more bytes and a generation of lossy Vorbis to
diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md
index 659c64933c..298d40e98d 100644
--- a/packages/core/AGENTS.md
+++ b/packages/core/AGENTS.md
@@ -52,6 +52,18 @@ six-second walk on a 289-tile ring paid ~270 ms for them: `TN_TERRAIN_VALIDATE=1
`?tnTerrainValidate=1` or `validate: true` runs them, and `maxSeamGap`, `maxVisualSeamGap` and
`maxLodPop` report `undefined` while they are off, never an unmeasured `0`.
+`SnowField` is the subpath's numerical snow record: indentation, displaced bank, compaction and
+disturbance channels over one canonical `Heightfield`, plus load-dependent penetration and bounded
+sparse recovery. It writes `terrain + depth + bank - indent` back through
+`Heightfield.updateHeights`, so queries, rendered geometry and collider export keep reading one
+surface. The game supplies the terrain, the snow depth, the contact footprint, the load in newtons
+and the supported area; the field owns no boot, tread, material, particle or camera. `depth`,
+`hardness`, `yieldFraction`, `maxBank` and `responseTime` are its only response coefficients.
+`heightAt` and `normalAt` keep `Heightfield`'s out-of-region error contract; `sample` returns zeros
+outside the resident region so a broad sweep needs no bounds check. It is a heightfield
+approximation, never granular snow, avalanche simulation, melting or a calibrated material law,
+and it does not encode to a texture — GPU encoding belongs in `src/render/`.
+
`picking.ts` is the one place a third-party dependency other than `three` and `zustand`
reaches core. It is contained deliberately: `ScenePicker` builds a hierarchy on first use,
patches no `three` prototype, and a game that never calls `ctx.raycast` never builds one.
diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md
index b6d9c49368..ee7a0076a4 100644
--- a/packages/core/CLAUDE.md
+++ b/packages/core/CLAUDE.md
@@ -54,6 +54,18 @@ six-second walk on a 289-tile ring paid ~270 ms for them: `TN_TERRAIN_VALIDATE=1
`?tnTerrainValidate=1` or `validate: true` runs them, and `maxSeamGap`, `maxVisualSeamGap` and
`maxLodPop` report `undefined` while they are off, never an unmeasured `0`.
+`SnowField` is the subpath's numerical snow record: indentation, displaced bank, compaction and
+disturbance channels over one canonical `Heightfield`, plus load-dependent penetration and bounded
+sparse recovery. It writes `terrain + depth + bank - indent` back through
+`Heightfield.updateHeights`, so queries, rendered geometry and collider export keep reading one
+surface. The game supplies the terrain, the snow depth, the contact footprint, the load in newtons
+and the supported area; the field owns no boot, tread, material, particle or camera. `depth`,
+`hardness`, `yieldFraction`, `maxBank` and `responseTime` are its only response coefficients.
+`heightAt` and `normalAt` keep `Heightfield`'s out-of-region error contract; `sample` returns zeros
+outside the resident region so a broad sweep needs no bounds check. It is a heightfield
+approximation, never granular snow, avalanche simulation, melting or a calibrated material law,
+and it does not encode to a texture — GPU encoding belongs in `src/render/`.
+
`picking.ts` is the one place a third-party dependency other than `three` and `zustand`
reaches core. It is contained deliberately: `ScenePicker` builds a hierarchy on first use,
patches no `three` prototype, and a game that never calls `ctx.raycast` never builds one.
diff --git a/packages/core/__tests__/audio.spec.ts b/packages/core/__tests__/audio.spec.ts
index 32e4594ef3..b27867b317 100644
--- a/packages/core/__tests__/audio.spec.ts
+++ b/packages/core/__tests__/audio.spec.ts
@@ -1,6 +1,11 @@
import { AudioContext, Object3D, PerspectiveCamera, Vector3 } from "three";
import { describe, expect, it } from "vitest";
-import { AudioBus, audioRuntimeSnapshot, resetAudioCueLedger } from "../src/audio.js";
+import {
+ AudioBus,
+ type IAudioCompressorOptions,
+ audioRuntimeSnapshot,
+ resetAudioCueLedger,
+} from "../src/audio.js";
interface IFakeAudioParam {
value: number;
@@ -43,15 +48,42 @@ interface IFakeSource {
}
/**
- * @param browser Give the context the parts only a browser has — a biquad filter factory and
- * cancellable params. The default is the native host's narrower surface.
+ * Every edge the fake graph was asked to make, in order.
+ *
+ * Routing is the whole question a compressor and a per-voice low-pass turn on — which node feeds
+ * which — and a fake whose `connect` returns `undefined` can only prove a node was built. These
+ * two record the edge, so a test can read the chain the bus actually wired.
+ */
+let graphEdges: Array<{ from: unknown; to: unknown }> = [];
+
+/** Every gain node the fake handed out, in order: the listener's first, then one per pooled voice. */
+let madeGains: unknown[] = [];
+
+function connect(this: unknown, to: unknown): void {
+ graphEdges.push({ from: this, to });
+}
+
+function disconnect(this: unknown): void {
+ graphEdges.push({ from: this, to: null });
+}
+
+/** Did `from` feed `to` at any point? */
+function wired(from: unknown, to: unknown): boolean {
+ return graphEdges.some((edge) => edge.from === from && edge.to === to);
+}
+
+/**
+ * @param browser Give the context the parts only a browser has — cancellable params. The default
+ * is the native host's narrower surface, which now carries the filter and the compressor.
*/
function audioContext(browser = false): globalThis.AudioContext {
+ graphEdges = [];
+ madeGains = [];
const context = {
createBufferSource: () => ({
- connect: () => undefined,
+ connect,
detune: parameter(0, browser),
- disconnect: () => undefined,
+ disconnect,
loop: false,
loopEnd: 0,
loopStart: 0,
@@ -63,15 +95,33 @@ function audioContext(browser = false): globalThis.AudioContext {
},
stop: () => undefined,
}),
- createGain: () => ({
- connect: () => undefined,
- disconnect: () => undefined,
- gain: parameter(1, browser),
+ createGain: () => {
+ const node = { connect, disconnect, gain: parameter(1, browser) };
+ madeGains.push(node);
+ return node;
+ },
+ createDynamicsCompressor: () => ({
+ attack: parameter(0.003),
+ connect,
+ disconnect,
+ knee: parameter(30),
+ ratio: parameter(12),
+ release: parameter(0.25),
+ threshold: parameter(-24),
+ }),
+ // The native host binds this once the low-pass gap closed, so the "narrow surface" fake
+ // carries it. Its params are inert like every other native param: they take a write and
+ // report it back, which is all the portable contract reads.
+ createBiquadFilter: () => ({
+ connect,
+ disconnect,
+ frequency: parameter(350),
+ type: "lowpass" as const,
}),
createPanner: () => ({
- connect: () => undefined,
+ connect,
distanceModel: "inverse" as const,
- disconnect: () => undefined,
+ disconnect,
maxDistance: 10_000,
panningModel: "HRTF" as const,
refDistance: 1,
@@ -83,8 +133,8 @@ function audioContext(browser = false): globalThis.AudioContext {
...(browser
? {
createBiquadFilter: () => ({
- connect: () => undefined,
- disconnect: () => undefined,
+ connect,
+ disconnect,
frequency: parameter(20_000, true),
type: "lowpass" as const,
}),
@@ -588,16 +638,120 @@ describe("AudioBus", () => {
try {
bus.play(buffer, { detune: 40, lowpassHz: 900 });
- // Both are accepted and both do nothing on the native host. Reporting is the difference
- // between a mix that is quietly flat everywhere and one a build can be failed on.
- expect(bus.unsupported).toEqual(["detune", "lowpassHz"]);
- expect(audioRuntimeSnapshot().unsupported).toEqual(["detune", "lowpassHz"]);
+ // `lowpassHz` is honoured on the native host now, because it binds a biquad. `detune` is
+ // still an inert stand-in there, and reporting it is the difference between a mix that is
+ // quietly flat everywhere and one a build can be failed on.
+ expect(bus.unsupported).toEqual(["detune"]);
+ expect(audioRuntimeSnapshot().unsupported).toEqual(["detune"]);
} finally {
bus.dispose();
}
});
- it("should report nothing unsupported on a runtime that honours the shaping", async () => {
+ it("sums every voice into a game-configured compressor ahead of the bus volume", async () => {
+ const context = audioContext();
+ const made: Array> = [];
+ const original = context.createDynamicsCompressor as unknown as () => unknown;
+ context.createDynamicsCompressor = () => {
+ const node = original.call(context) as Record;
+ made.push(node);
+ return node as unknown as DynamicsCompressorNode;
+ };
+ const bus = new AudioBus({
+ camera: new PerspectiveCamera(),
+ compressor: { attack: 0.004, knee: 6, ratio: 5, release: 0.18, threshold: -15 },
+ gestureTarget: new EventTarget(),
+ });
+
+ try {
+ await bus.unlock();
+ // The mix the source ran: a compressor holding three summed voices down. Every number is
+ // the game's, so the bus must not invent one.
+ expect(made).toHaveLength(1);
+ const node = made[0];
+ const values = Object.fromEntries(
+ Object.entries(node ?? {}).map(([name, p]) => [name, p.value]),
+ );
+ expect(values).toEqual({ attack: 0.004, knee: 6, ratio: 5, release: 0.18, threshold: -15 });
+ // The compressor is reachable so a mixer can read `reduction` off it.
+ expect(bus.compressor).toBe(node);
+
+ // The routing, which is the part that was wrong: voices reach the compressor, the
+ // compressor reaches the bus volume gain. Three's own `listener.filter` slot would have put
+ // it on the far side of that gain, compressing a level the volume had already moved.
+ const masterGain = bus.listener.gain;
+ expect(madeGains[0]).toBe(masterGain);
+ expect(wired(node, masterGain)).toBe(true);
+ bus.play(buffer);
+ expect(madeGains[1]).not.toBe(masterGain);
+ expect(wired(madeGains[1], node)).toBe(true);
+ expect(bus.listener.getFilter()).toBeNull();
+
+ bus.dispose();
+ // And it leaves with the bus, so a disposed bus is not still holding voices.
+ expect(graphEdges).toContainEqual({ from: node, to: null });
+ } finally {
+ bus.dispose();
+ }
+ });
+
+ it("should fail closed on a compressor option it cannot honour", () => {
+ audioContext();
+ const camera = new PerspectiveCamera();
+ const at = (over: Partial) => () =>
+ new AudioBus({
+ camera,
+ compressor: { attack: 0.01, knee: 6, ratio: 5, release: 0.2, threshold: -15, ...over },
+ });
+
+ expect(at({ attack: Number.NaN })).toThrow(/compressor\.attack/u);
+ expect(at({ attack: 1.5 })).toThrow(/compressor\.attack/u);
+ expect(at({ release: 1.5 })).toThrow(/compressor\.release/u);
+ expect(at({ release: Number.POSITIVE_INFINITY })).toThrow(/compressor\.release/u);
+ expect(at({ knee: -1 })).toThrow(/compressor\.knee/u);
+ expect(at({ knee: 41 })).toThrow(/compressor\.knee/u);
+ expect(at({ ratio: 0.5 })).toThrow(/compressor\.ratio/u);
+ expect(at({ ratio: 21 })).toThrow(/compressor\.ratio/u);
+ expect(at({ threshold: 12 })).toThrow(/compressor\.threshold/u);
+ expect(at({ threshold: -140 })).toThrow(/compressor\.threshold/u);
+ // A half-spelled compressor is not a compressor with defaults; it is a mistake.
+ expect(
+ () =>
+ new AudioBus({
+ camera,
+ compressor: {
+ knee: 6,
+ ratio: 5,
+ release: 0.2,
+ threshold: -15,
+ } as unknown as IAudioCompressorOptions,
+ }),
+ ).toThrow(/compressor\.attack/u);
+
+ // Zero is inside the platform's range for attack and release — an instant response, not a
+ // mistake — so refusing it would be this engine being stricter than the Web Audio it mirrors.
+ for (const instant of [0, 1]) {
+ const bus = new AudioBus({
+ camera,
+ compressor: { attack: instant, knee: 0, ratio: 20, release: instant, threshold: -100 },
+ });
+ expect(bus.compressor).toBeDefined();
+ bus.dispose();
+ }
+
+ // A runtime with no dynamics node is named, not silently left uncompressed.
+ const bare = audioContext();
+ Reflect.deleteProperty(bare, "createDynamicsCompressor");
+ const bus = new AudioBus({
+ camera,
+ compressor: { attack: 0.01, knee: 6, ratio: 5, release: 0.2, threshold: -15 },
+ });
+ expect(bus.unsupported).toEqual(["compressor"]);
+ expect(bus.compressor).toBeUndefined();
+ bus.dispose();
+ });
+
+ it("reports nothing unsupported for a bus that honours the shaping", async () => {
audioContext(true);
const bus = new AudioBus({ camera: new PerspectiveCamera() });
await bus.unlock();
diff --git a/packages/core/__tests__/fixtures/snow-field-source.json b/packages/core/__tests__/fixtures/snow-field-source.json
new file mode 100644
index 0000000000..e7b822e343
--- /dev/null
+++ b/packages/core/__tests__/fixtures/snow-field-source.json
@@ -0,0 +1,3401 @@
+{
+ "channels": ["indent", "bank", "compaction", "disturbance"],
+ "points": [
+ [-0.45, -0.45],
+ [-0.4, -0.45],
+ [-0.35, -0.45],
+ [-0.3, -0.45],
+ [-0.25, -0.45],
+ [-0.2, -0.45],
+ [-0.15, -0.45],
+ [-0.1, -0.45],
+ [-0.05, -0.45],
+ [0, -0.45],
+ [0.05, -0.45],
+ [0.1, -0.45],
+ [0.15, -0.45],
+ [0.2, -0.45],
+ [0.25, -0.45],
+ [0.3, -0.45],
+ [0.35, -0.45],
+ [0.4, -0.45],
+ [0.45, -0.45],
+ [0.5, -0.45],
+ [0.55, -0.45],
+ [0.6, -0.45],
+ [0.65, -0.45],
+ [0.7, -0.45],
+ [0.75, -0.45],
+ [0.8, -0.45],
+ [0.85, -0.45],
+ [0.9, -0.45],
+ [0.95, -0.45],
+ [-0.45, -0.4],
+ [-0.4, -0.4],
+ [-0.35, -0.4],
+ [-0.3, -0.4],
+ [-0.25, -0.4],
+ [-0.2, -0.4],
+ [-0.15, -0.4],
+ [-0.1, -0.4],
+ [-0.05, -0.4],
+ [0, -0.4],
+ [0.05, -0.4],
+ [0.1, -0.4],
+ [0.15, -0.4],
+ [0.2, -0.4],
+ [0.25, -0.4],
+ [0.3, -0.4],
+ [0.35, -0.4],
+ [0.4, -0.4],
+ [0.45, -0.4],
+ [0.5, -0.4],
+ [0.55, -0.4],
+ [0.6, -0.4],
+ [0.65, -0.4],
+ [0.7, -0.4],
+ [0.75, -0.4],
+ [0.8, -0.4],
+ [0.85, -0.4],
+ [0.9, -0.4],
+ [0.95, -0.4],
+ [-0.45, -0.35],
+ [-0.4, -0.35],
+ [-0.35, -0.35],
+ [-0.3, -0.35],
+ [-0.25, -0.35],
+ [-0.2, -0.35],
+ [-0.15, -0.35],
+ [-0.1, -0.35],
+ [-0.05, -0.35],
+ [0, -0.35],
+ [0.05, -0.35],
+ [0.1, -0.35],
+ [0.15, -0.35],
+ [0.2, -0.35],
+ [0.25, -0.35],
+ [0.3, -0.35],
+ [0.35, -0.35],
+ [0.4, -0.35],
+ [0.45, -0.35],
+ [0.5, -0.35],
+ [0.55, -0.35],
+ [0.6, -0.35],
+ [0.65, -0.35],
+ [0.7, -0.35],
+ [0.75, -0.35],
+ [0.8, -0.35],
+ [0.85, -0.35],
+ [0.9, -0.35],
+ [0.95, -0.35],
+ [-0.45, -0.3],
+ [-0.4, -0.3],
+ [-0.35, -0.3],
+ [-0.3, -0.3],
+ [-0.25, -0.3],
+ [-0.2, -0.3],
+ [-0.15, -0.3],
+ [-0.1, -0.3],
+ [-0.05, -0.3],
+ [0, -0.3],
+ [0.05, -0.3],
+ [0.1, -0.3],
+ [0.15, -0.3],
+ [0.2, -0.3],
+ [0.25, -0.3],
+ [0.3, -0.3],
+ [0.35, -0.3],
+ [0.4, -0.3],
+ [0.45, -0.3],
+ [0.5, -0.3],
+ [0.55, -0.3],
+ [0.6, -0.3],
+ [0.65, -0.3],
+ [0.7, -0.3],
+ [0.75, -0.3],
+ [0.8, -0.3],
+ [0.85, -0.3],
+ [0.9, -0.3],
+ [0.95, -0.3],
+ [-0.45, -0.25],
+ [-0.4, -0.25],
+ [-0.35, -0.25],
+ [-0.3, -0.25],
+ [-0.25, -0.25],
+ [-0.2, -0.25],
+ [-0.15, -0.25],
+ [-0.1, -0.25],
+ [-0.05, -0.25],
+ [0, -0.25],
+ [0.05, -0.25],
+ [0.1, -0.25],
+ [0.15, -0.25],
+ [0.2, -0.25],
+ [0.25, -0.25],
+ [0.3, -0.25],
+ [0.35, -0.25],
+ [0.4, -0.25],
+ [0.45, -0.25],
+ [0.5, -0.25],
+ [0.55, -0.25],
+ [0.6, -0.25],
+ [0.65, -0.25],
+ [0.7, -0.25],
+ [0.75, -0.25],
+ [0.8, -0.25],
+ [0.85, -0.25],
+ [0.9, -0.25],
+ [0.95, -0.25],
+ [-0.45, -0.2],
+ [-0.4, -0.2],
+ [-0.35, -0.2],
+ [-0.3, -0.2],
+ [-0.25, -0.2],
+ [-0.2, -0.2],
+ [-0.15, -0.2],
+ [-0.1, -0.2],
+ [-0.05, -0.2],
+ [0, -0.2],
+ [0.05, -0.2],
+ [0.1, -0.2],
+ [0.15, -0.2],
+ [0.2, -0.2],
+ [0.25, -0.2],
+ [0.3, -0.2],
+ [0.35, -0.2],
+ [0.4, -0.2],
+ [0.45, -0.2],
+ [0.5, -0.2],
+ [0.55, -0.2],
+ [0.6, -0.2],
+ [0.65, -0.2],
+ [0.7, -0.2],
+ [0.75, -0.2],
+ [0.8, -0.2],
+ [0.85, -0.2],
+ [0.9, -0.2],
+ [0.95, -0.2],
+ [-0.45, -0.15],
+ [-0.4, -0.15],
+ [-0.35, -0.15],
+ [-0.3, -0.15],
+ [-0.25, -0.15],
+ [-0.2, -0.15],
+ [-0.15, -0.15],
+ [-0.1, -0.15],
+ [-0.05, -0.15],
+ [0, -0.15],
+ [0.05, -0.15],
+ [0.1, -0.15],
+ [0.15, -0.15],
+ [0.2, -0.15],
+ [0.25, -0.15],
+ [0.3, -0.15],
+ [0.35, -0.15],
+ [0.4, -0.15],
+ [0.45, -0.15],
+ [0.5, -0.15],
+ [0.55, -0.15],
+ [0.6, -0.15],
+ [0.65, -0.15],
+ [0.7, -0.15],
+ [0.75, -0.15],
+ [0.8, -0.15],
+ [0.85, -0.15],
+ [0.9, -0.15],
+ [0.95, -0.15],
+ [-0.45, -0.1],
+ [-0.4, -0.1],
+ [-0.35, -0.1],
+ [-0.3, -0.1],
+ [-0.25, -0.1],
+ [-0.2, -0.1],
+ [-0.15, -0.1],
+ [-0.1, -0.1],
+ [-0.05, -0.1],
+ [0, -0.1],
+ [0.05, -0.1],
+ [0.1, -0.1],
+ [0.15, -0.1],
+ [0.2, -0.1],
+ [0.25, -0.1],
+ [0.3, -0.1],
+ [0.35, -0.1],
+ [0.4, -0.1],
+ [0.45, -0.1],
+ [0.5, -0.1],
+ [0.55, -0.1],
+ [0.6, -0.1],
+ [0.65, -0.1],
+ [0.7, -0.1],
+ [0.75, -0.1],
+ [0.8, -0.1],
+ [0.85, -0.1],
+ [0.9, -0.1],
+ [0.95, -0.1],
+ [-0.45, -0.05],
+ [-0.4, -0.05],
+ [-0.35, -0.05],
+ [-0.3, -0.05],
+ [-0.25, -0.05],
+ [-0.2, -0.05],
+ [-0.15, -0.05],
+ [-0.1, -0.05],
+ [-0.05, -0.05],
+ [0, -0.05],
+ [0.05, -0.05],
+ [0.1, -0.05],
+ [0.15, -0.05],
+ [0.2, -0.05],
+ [0.25, -0.05],
+ [0.3, -0.05],
+ [0.35, -0.05],
+ [0.4, -0.05],
+ [0.45, -0.05],
+ [0.5, -0.05],
+ [0.55, -0.05],
+ [0.6, -0.05],
+ [0.65, -0.05],
+ [0.7, -0.05],
+ [0.75, -0.05],
+ [0.8, -0.05],
+ [0.85, -0.05],
+ [0.9, -0.05],
+ [0.95, -0.05],
+ [-0.45, 0],
+ [-0.4, 0],
+ [-0.35, 0],
+ [-0.3, 0],
+ [-0.25, 0],
+ [-0.2, 0],
+ [-0.15, 0],
+ [-0.1, 0],
+ [-0.05, 0],
+ [0, 0],
+ [0.05, 0],
+ [0.1, 0],
+ [0.15, 0],
+ [0.2, 0],
+ [0.25, 0],
+ [0.3, 0],
+ [0.35, 0],
+ [0.4, 0],
+ [0.45, 0],
+ [0.5, 0],
+ [0.55, 0],
+ [0.6, 0],
+ [0.65, 0],
+ [0.7, 0],
+ [0.75, 0],
+ [0.8, 0],
+ [0.85, 0],
+ [0.9, 0],
+ [0.95, 0],
+ [-0.45, 0.05],
+ [-0.4, 0.05],
+ [-0.35, 0.05],
+ [-0.3, 0.05],
+ [-0.25, 0.05],
+ [-0.2, 0.05],
+ [-0.15, 0.05],
+ [-0.1, 0.05],
+ [-0.05, 0.05],
+ [0, 0.05],
+ [0.05, 0.05],
+ [0.1, 0.05],
+ [0.15, 0.05],
+ [0.2, 0.05],
+ [0.25, 0.05],
+ [0.3, 0.05],
+ [0.35, 0.05],
+ [0.4, 0.05],
+ [0.45, 0.05],
+ [0.5, 0.05],
+ [0.55, 0.05],
+ [0.6, 0.05],
+ [0.65, 0.05],
+ [0.7, 0.05],
+ [0.75, 0.05],
+ [0.8, 0.05],
+ [0.85, 0.05],
+ [0.9, 0.05],
+ [0.95, 0.05],
+ [-0.45, 0.1],
+ [-0.4, 0.1],
+ [-0.35, 0.1],
+ [-0.3, 0.1],
+ [-0.25, 0.1],
+ [-0.2, 0.1],
+ [-0.15, 0.1],
+ [-0.1, 0.1],
+ [-0.05, 0.1],
+ [0, 0.1],
+ [0.05, 0.1],
+ [0.1, 0.1],
+ [0.15, 0.1],
+ [0.2, 0.1],
+ [0.25, 0.1],
+ [0.3, 0.1],
+ [0.35, 0.1],
+ [0.4, 0.1],
+ [0.45, 0.1],
+ [0.5, 0.1],
+ [0.55, 0.1],
+ [0.6, 0.1],
+ [0.65, 0.1],
+ [0.7, 0.1],
+ [0.75, 0.1],
+ [0.8, 0.1],
+ [0.85, 0.1],
+ [0.9, 0.1],
+ [0.95, 0.1],
+ [-0.45, 0.15],
+ [-0.4, 0.15],
+ [-0.35, 0.15],
+ [-0.3, 0.15],
+ [-0.25, 0.15],
+ [-0.2, 0.15],
+ [-0.15, 0.15],
+ [-0.1, 0.15],
+ [-0.05, 0.15],
+ [0, 0.15],
+ [0.05, 0.15],
+ [0.1, 0.15],
+ [0.15, 0.15],
+ [0.2, 0.15],
+ [0.25, 0.15],
+ [0.3, 0.15],
+ [0.35, 0.15],
+ [0.4, 0.15],
+ [0.45, 0.15],
+ [0.5, 0.15],
+ [0.55, 0.15],
+ [0.6, 0.15],
+ [0.65, 0.15],
+ [0.7, 0.15],
+ [0.75, 0.15],
+ [0.8, 0.15],
+ [0.85, 0.15],
+ [0.9, 0.15],
+ [0.95, 0.15],
+ [-0.45, 0.2],
+ [-0.4, 0.2],
+ [-0.35, 0.2],
+ [-0.3, 0.2],
+ [-0.25, 0.2],
+ [-0.2, 0.2],
+ [-0.15, 0.2],
+ [-0.1, 0.2],
+ [-0.05, 0.2],
+ [0, 0.2],
+ [0.05, 0.2],
+ [0.1, 0.2],
+ [0.15, 0.2],
+ [0.2, 0.2],
+ [0.25, 0.2],
+ [0.3, 0.2],
+ [0.35, 0.2],
+ [0.4, 0.2],
+ [0.45, 0.2],
+ [0.5, 0.2],
+ [0.55, 0.2],
+ [0.6, 0.2],
+ [0.65, 0.2],
+ [0.7, 0.2],
+ [0.75, 0.2],
+ [0.8, 0.2],
+ [0.85, 0.2],
+ [0.9, 0.2],
+ [0.95, 0.2],
+ [-0.45, 0.25],
+ [-0.4, 0.25],
+ [-0.35, 0.25],
+ [-0.3, 0.25],
+ [-0.25, 0.25],
+ [-0.2, 0.25],
+ [-0.15, 0.25],
+ [-0.1, 0.25],
+ [-0.05, 0.25],
+ [0, 0.25],
+ [0.05, 0.25],
+ [0.1, 0.25],
+ [0.15, 0.25],
+ [0.2, 0.25],
+ [0.25, 0.25],
+ [0.3, 0.25],
+ [0.35, 0.25],
+ [0.4, 0.25],
+ [0.45, 0.25],
+ [0.5, 0.25],
+ [0.55, 0.25],
+ [0.6, 0.25],
+ [0.65, 0.25],
+ [0.7, 0.25],
+ [0.75, 0.25],
+ [0.8, 0.25],
+ [0.85, 0.25],
+ [0.9, 0.25],
+ [0.95, 0.25],
+ [-0.45, 0.3],
+ [-0.4, 0.3],
+ [-0.35, 0.3],
+ [-0.3, 0.3],
+ [-0.25, 0.3],
+ [-0.2, 0.3],
+ [-0.15, 0.3],
+ [-0.1, 0.3],
+ [-0.05, 0.3],
+ [0, 0.3],
+ [0.05, 0.3],
+ [0.1, 0.3],
+ [0.15, 0.3],
+ [0.2, 0.3],
+ [0.25, 0.3],
+ [0.3, 0.3],
+ [0.35, 0.3],
+ [0.4, 0.3],
+ [0.45, 0.3],
+ [0.5, 0.3],
+ [0.55, 0.3],
+ [0.6, 0.3],
+ [0.65, 0.3],
+ [0.7, 0.3],
+ [0.75, 0.3],
+ [0.8, 0.3],
+ [0.85, 0.3],
+ [0.9, 0.3],
+ [0.95, 0.3],
+ [-0.45, 0.35],
+ [-0.4, 0.35],
+ [-0.35, 0.35],
+ [-0.3, 0.35],
+ [-0.25, 0.35],
+ [-0.2, 0.35],
+ [-0.15, 0.35],
+ [-0.1, 0.35],
+ [-0.05, 0.35],
+ [0, 0.35],
+ [0.05, 0.35],
+ [0.1, 0.35],
+ [0.15, 0.35],
+ [0.2, 0.35],
+ [0.25, 0.35],
+ [0.3, 0.35],
+ [0.35, 0.35],
+ [0.4, 0.35],
+ [0.45, 0.35],
+ [0.5, 0.35],
+ [0.55, 0.35],
+ [0.6, 0.35],
+ [0.65, 0.35],
+ [0.7, 0.35],
+ [0.75, 0.35],
+ [0.8, 0.35],
+ [0.85, 0.35],
+ [0.9, 0.35],
+ [0.95, 0.35],
+ [-0.45, 0.4],
+ [-0.4, 0.4],
+ [-0.35, 0.4],
+ [-0.3, 0.4],
+ [-0.25, 0.4],
+ [-0.2, 0.4],
+ [-0.15, 0.4],
+ [-0.1, 0.4],
+ [-0.05, 0.4],
+ [0, 0.4],
+ [0.05, 0.4],
+ [0.1, 0.4],
+ [0.15, 0.4],
+ [0.2, 0.4],
+ [0.25, 0.4],
+ [0.3, 0.4],
+ [0.35, 0.4],
+ [0.4, 0.4],
+ [0.45, 0.4],
+ [0.5, 0.4],
+ [0.55, 0.4],
+ [0.6, 0.4],
+ [0.65, 0.4],
+ [0.7, 0.4],
+ [0.75, 0.4],
+ [0.8, 0.4],
+ [0.85, 0.4],
+ [0.9, 0.4],
+ [0.95, 0.4],
+ [-0.45, 0.45],
+ [-0.4, 0.45],
+ [-0.35, 0.45],
+ [-0.3, 0.45],
+ [-0.25, 0.45],
+ [-0.2, 0.45],
+ [-0.15, 0.45],
+ [-0.1, 0.45],
+ [-0.05, 0.45],
+ [0, 0.45],
+ [0.05, 0.45],
+ [0.1, 0.45],
+ [0.15, 0.45],
+ [0.2, 0.45],
+ [0.25, 0.45],
+ [0.3, 0.45],
+ [0.35, 0.45],
+ [0.4, 0.45],
+ [0.45, 0.45],
+ [0.5, 0.45],
+ [0.55, 0.45],
+ [0.6, 0.45],
+ [0.65, 0.45],
+ [0.7, 0.45],
+ [0.75, 0.45],
+ [0.8, 0.45],
+ [0.85, 0.45],
+ [0.9, 0.45],
+ [0.95, 0.45],
+ [3.25, -2.5],
+ [3.3, -2.5],
+ [3.35, -2.5],
+ [3.4, -2.5],
+ [3.45, -2.5],
+ [3.5, -2.5],
+ [3.55, -2.5],
+ [3.6, -2.5],
+ [3.65, -2.5],
+ [3.7, -2.5],
+ [3.75, -2.5],
+ [3.25, -2.45],
+ [3.3, -2.45],
+ [3.35, -2.45],
+ [3.4, -2.45],
+ [3.45, -2.45],
+ [3.5, -2.45],
+ [3.55, -2.45],
+ [3.6, -2.45],
+ [3.65, -2.45],
+ [3.7, -2.45],
+ [3.75, -2.45],
+ [3.25, -2.4],
+ [3.3, -2.4],
+ [3.35, -2.4],
+ [3.4, -2.4],
+ [3.45, -2.4],
+ [3.5, -2.4],
+ [3.55, -2.4],
+ [3.6, -2.4],
+ [3.65, -2.4],
+ [3.7, -2.4],
+ [3.75, -2.4],
+ [3.25, -2.35],
+ [3.3, -2.35],
+ [3.35, -2.35],
+ [3.4, -2.35],
+ [3.45, -2.35],
+ [3.5, -2.35],
+ [3.55, -2.35],
+ [3.6, -2.35],
+ [3.65, -2.35],
+ [3.7, -2.35],
+ [3.75, -2.35],
+ [3.25, -2.3],
+ [3.3, -2.3],
+ [3.35, -2.3],
+ [3.4, -2.3],
+ [3.45, -2.3],
+ [3.5, -2.3],
+ [3.55, -2.3],
+ [3.6, -2.3],
+ [3.65, -2.3],
+ [3.7, -2.3],
+ [3.75, -2.3],
+ [3.25, -2.25],
+ [3.3, -2.25],
+ [3.35, -2.25],
+ [3.4, -2.25],
+ [3.45, -2.25],
+ [3.5, -2.25],
+ [3.55, -2.25],
+ [3.6, -2.25],
+ [3.65, -2.25],
+ [3.7, -2.25],
+ [3.75, -2.25],
+ [3.25, -2.2],
+ [3.3, -2.2],
+ [3.35, -2.2],
+ [3.4, -2.2],
+ [3.45, -2.2],
+ [3.5, -2.2],
+ [3.55, -2.2],
+ [3.6, -2.2],
+ [3.65, -2.2],
+ [3.7, -2.2],
+ [3.75, -2.2],
+ [3.25, -2.15],
+ [3.3, -2.15],
+ [3.35, -2.15],
+ [3.4, -2.15],
+ [3.45, -2.15],
+ [3.5, -2.15],
+ [3.55, -2.15],
+ [3.6, -2.15],
+ [3.65, -2.15],
+ [3.7, -2.15],
+ [3.75, -2.15],
+ [3.25, -2.1],
+ [3.3, -2.1],
+ [3.35, -2.1],
+ [3.4, -2.1],
+ [3.45, -2.1],
+ [3.5, -2.1],
+ [3.55, -2.1],
+ [3.6, -2.1],
+ [3.65, -2.1],
+ [3.7, -2.1],
+ [3.75, -2.1],
+ [3.25, -2.05],
+ [3.3, -2.05],
+ [3.35, -2.05],
+ [3.4, -2.05],
+ [3.45, -2.05],
+ [3.5, -2.05],
+ [3.55, -2.05],
+ [3.6, -2.05],
+ [3.65, -2.05],
+ [3.7, -2.05],
+ [3.75, -2.05],
+ [3.25, -2],
+ [3.3, -2],
+ [3.35, -2],
+ [3.4, -2],
+ [3.45, -2],
+ [3.5, -2],
+ [3.55, -2],
+ [3.6, -2],
+ [3.65, -2],
+ [3.7, -2],
+ [3.75, -2]
+ ],
+ "scenario": {
+ "depth": 0.28,
+ "depthOverride": 0.12,
+ "recovery": { "deposition": 0.0012, "dt": 0.016666666666666666, "steps": 120, "wind": 0.4 },
+ "resolution": 256,
+ "size": 8,
+ "stamps": [
+ { "angle": 0, "hardness": 0.3, "mass": 80, "x": 0, "z": 0 },
+ { "angle": 0.9, "hardness": 0.55, "mass": 80, "x": 0.62, "z": 0.18 },
+ { "angle": -0.4, "hardness": 0.1, "mass": 80, "x": 3.5, "z": -2.25 }
+ ]
+ },
+ "source": "ICEFIELD.html lines 85-193, sha256 85b170815f8f149a9bea757f80d4362cb5e0cc6b2e33dbf5ab1efab9a4dd02f7",
+ "afterStamps": {
+ "activeCells": 995,
+ "samples": [
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000035703, 0, 0.000599934],
+ [0, 0.000405119, 0, 0.005633255],
+ [0, 0.000538718, 0, 0.007871535],
+ [0, 0.000532961, 0, 0.007871606],
+ [0, 0.000617861, 0, 0.007871535],
+ [0, 0.000408953, 0, 0.005633255],
+ [0, 0.000030732, 0, 0.000599934],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000071264, 0, 0.000915865],
+ [0, 0.003760299, 0, 0.064208327],
+ [0, 0.020084126, 0, 0.277716852],
+ [0, 0.027941626, 0, 0.330799313],
+ [0, 0.023029346, 0, 0.330800657],
+ [0, 0.017840283, 0, 0.330799313],
+ [0, 0.018343353, 0, 0.277716852],
+ [0, 0.005290187, 0, 0.064208327],
+ [0, 0.000053733, 0, 0.000915865],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002147998, 0, 0.031283871],
+ [0, 0.028038135, 0, 0.371994022],
+ [0.018600822, 0.01217859, 0.154569686, 0.397090864],
+ [0.032534779, 0.009958278, 0.272997692, 0.515769904],
+ [0.02905577, 0.012080435, 0.273001228, 0.515773411],
+ [0.032534779, 0.012366598, 0.272997692, 0.515769904],
+ [0.018600822, 0.01341942, 0.154569686, 0.397090864],
+ [0, 0.02415247, 0, 0.371994022],
+ [0, 0.00236099, 0, 0.031283871],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 4.19e-7, 0, 0.000008391],
+ [0, 0.004935801, 0, 0.066831582],
+ [0, 0.022543855, 0, 0.354962819],
+ [0.073344391, 0, 0.610203097, 0.773387963],
+ [0.094187243, 0, 0.788999975, 1],
+ [0.093049863, 0, 0.788999975, 1],
+ [0.094187243, 0, 0.788999975, 1],
+ [0.073344391, 0, 0.610203097, 0.773387963],
+ [0, 0.026266987, 0, 0.354962819],
+ [0, 0.00444189, 0, 0.066831582],
+ [0, 4.24e-7, 0, 0.000008391],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00000548, 0, 0.000077666],
+ [0, 0.004849419, 0, 0.072292401],
+ [0, 0.025343678, 0, 0.347090297],
+ [0.075971119, 0, 0.628883886, 0.797064507],
+ [0.093807134, 0, 0.788999975, 1],
+ [0.093649067, 0, 0.788999975, 1],
+ [0.093807134, 0, 0.788999975, 1],
+ [0.075971119, 0, 0.628883886, 0.797064507],
+ [0, 0.022726852, 0, 0.347090297],
+ [0, 0.005240806, 0, 0.072292401],
+ [0, 0.000005742, 0, 0.000077666],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00003654, 0, 0.000830616],
+ [0, 0.000248805, 0, 0.004815521],
+ [0, 0.000104484, 0, 0.002264198],
+ [0, 0.00000566, 0, 0.000099314],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000008365, 0, 0.00011807],
+ [0, 0.005982716, 0, 0.082747657],
+ [0.000035566, 0.021774311, 0.000286691, 0.332525472],
+ [0.078746187, 0, 0.660099006, 0.836627383],
+ [0.096790859, 0, 0.788999975, 1],
+ [0.092486952, 0, 0.788999975, 1],
+ [0.096790859, 0, 0.788999975, 1],
+ [0.078746187, 0, 0.660099006, 0.836627383],
+ [0.000035566, 0.023933547, 0.000286691, 0.332525472],
+ [0, 0.005617323, 0, 0.082747657],
+ [0, 0.000008024, 0, 0.00011807],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000651755, 0, 0.011932793],
+ [0, 0.006758632, 0, 0.163115198],
+ [0, 0.014978947, 0, 0.294104705],
+ [0, 0.015060322, 0, 0.243539342],
+ [0, 0.002255629, 0, 0.052214956],
+ [0, 0.000043012, 0, 0.001154393],
+ [0, 5.38e-7, 0, 0.00000879],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000013406, 0, 0.000193931],
+ [0, 0.006539432, 0, 0.097366123],
+ [0.000602873, 0.022802183, 0.005148844, 0.313333687],
+ [0.086322183, 0, 0.696212279, 0.882398331],
+ [0.099103782, 0, 0.788999975, 1],
+ [0.096387144, 0, 0.788999975, 1],
+ [0.099103782, 0, 0.788999975, 1],
+ [0.086322183, 0, 0.696212279, 0.882398331],
+ [0.000602873, 0.020714984, 0.005148844, 0.313333687],
+ [0, 0.006993052, 0, 0.097366123],
+ [0, 0.000014269, 0, 0.000193931],
+ [0, 0, 0, 0],
+ [0, 0.000053955, 0, 0.001475737],
+ [0, 0.006786654, 0, 0.141621431],
+ [0, 0.022032062, 0, 0.410183427],
+ [0.018058237, 0.002265099, 0.223765778, 0.309117222],
+ [0.001356602, 0.014360191, 0.019168744, 0.266678796],
+ [0, 0.020369235, 0, 0.391515791],
+ [0, 0.003266921, 0, 0.082213857],
+ [0, 0.000158067, 0, 0.00271444],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000022185, 0, 0.000324396],
+ [0, 0.008157867, 0, 0.11486867],
+ [0.002559091, 0.019751675, 0.020001691, 0.292512006],
+ [0.093622403, 0, 0.728926271, 0.923860939],
+ [0.096671458, 0, 0.788999975, 1],
+ [0.09918569, 0, 0.788999975, 1],
+ [0.096671458, 0, 0.788999975, 1],
+ [0.093622403, 0, 0.728926271, 0.923860939],
+ [0.002559091, 0.020629148, 0.020001691, 0.292512006],
+ [0, 0.00792807, 0, 0.11486867],
+ [0, 0.000021302, 0, 0.000324396],
+ [0, 0.000041845, 0, 0.000893688],
+ [0, 0.004940509, 0, 0.095857396],
+ [0.000499557, 0.019988515, 0.006534515, 0.390358752],
+ [0.025258226, 0.002078524, 0.33800573, 0.446861105],
+ [0.065777926, 0, 0.82078192, 0.96961835],
+ [0.057536448, 0, 0.71064973, 0.839515344],
+ [0.019853104, 0.005875143, 0.242976991, 0.420023752],
+ [0, 0.020655479, 0, 0.382731869],
+ [0, 0.010947881, 0, 0.205876769],
+ [0, 0.001114326, 0, 0.025450763],
+ [0, 0.000007543, 0, 0.000153819],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000033294, 0, 0.000500484],
+ [0, 0.009088855, 0, 0.132496313],
+ [0.005260505, 0.018783491, 0.040131336, 0.267692207],
+ [0.095599199, 0, 0.753679813, 0.955234252],
+ [0.102382902, 0, 0.788999975, 1],
+ [0.101353987, 0, 0.788999975, 1],
+ [0.102382902, 0, 0.788999975, 1],
+ [0.095599199, 0, 0.753679813, 0.955234252],
+ [0.005260505, 0.017917798, 0.040131336, 0.267692207],
+ [0, 0.00933763, 0, 0.132496313],
+ [0, 0.000036419, 0, 0.000520953],
+ [0, 0.001165021, 0, 0.023917343],
+ [0, 0.022368297, 0, 0.379195845],
+ [0.012074262, 0.006962687, 0.166679427, 0.380370243],
+ [0.060980928, 0, 0.799995713, 0.945062877],
+ [0.06488937, 0, 0.846499979, 1],
+ [0.068942852, 0, 0.846499979, 1],
+ [0.068719378, 0, 0.839506875, 0.991738786],
+ [0.034499525, 0.001569376, 0.41155218, 0.517713506],
+ [0.00275499, 0.023191517, 0.03223732, 0.409280722],
+ [0, 0.013691512, 0, 0.321384386],
+ [0, 0.001607219, 0, 0.042214908],
+ [0, 0.000144131, 0, 0.002380918],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00004619, 0, 0.000678265],
+ [0, 0.010293001, 0, 0.146216106],
+ [0.007709117, 0.01021141, 0.05825951, 0.212426338],
+ [0.097571084, 0, 0.767541419, 0.972802814],
+ [0.104874093, 0, 0.788999975, 1],
+ [0.1025517, 0, 0.788999975, 1],
+ [0.104874093, 0, 0.788999975, 1],
+ [0.097571084, 0, 0.767541419, 0.972802814],
+ [0.007709117, 0.00854759, 0.05825951, 0.212426338],
+ [0, 0.010213657, 0, 0.146216106],
+ [0, 0.000069129, 0, 0.001092548],
+ [0, 0.00456715, 0, 0.100219958],
+ [0.002491793, 0.012976398, 0.030876572, 0.269290947],
+ [0.057111011, 0, 0.709835881, 0.838553905],
+ [0.064705619, 0, 0.846499901, 0.999999907],
+ [0.066894214, 0, 0.846499979, 1],
+ [0.067324807, 0, 0.846499979, 1],
+ [0.06960851, 0, 0.846499979, 1],
+ [0.065131286, 0, 0.83339493, 0.984518547],
+ [0.055204078, 0, 0.636894802, 0.752386091],
+ [0.013057399, 0.010088329, 0.148646526, 0.381492593],
+ [0, 0.020854034, 0, 0.417889166],
+ [0, 0.006870216, 0, 0.137181566],
+ [0, 0.000239647, 0, 0.004187725],
+ [0, 0.000001373, 0, 0.00003433],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000050626, 0, 0.000764218],
+ [0, 0.010591394, 0, 0.152062941],
+ [0.00868918, 0.008606454, 0.066347217, 0.223452099],
+ [0.10326156, 0, 0.772383702, 0.978940072],
+ [0.103643746, 0, 0.788999975, 1],
+ [0.09924269, 0, 0.788999975, 1],
+ [0.103643746, 0, 0.788999975, 1],
+ [0.10326156, 0, 0.772383702, 0.978940072],
+ [0.00868918, 0.01035817, 0.066347217, 0.223452099],
+ [0, 0.010644467, 0, 0.152062941],
+ [0, 0.000068487, 0, 0.001017918],
+ [0, 0.00342837, 0, 0.074207094],
+ [0.000177135, 0.017077518, 0.002194929, 0.358274797],
+ [0.03715465, 0.003366579, 0.467958393, 0.606630614],
+ [0.066632571, 0, 0.825030097, 0.97463686],
+ [0.068841251, 0, 0.846499979, 1],
+ [0.061297624, 0, 0.846499979, 1],
+ [0.070080035, 0, 0.846499979, 1],
+ [0.072754938, 0, 0.846499979, 1],
+ [0.068456591, 0, 0.846499979, 1],
+ [0.066468228, 0, 0.75755761, 0.89492926],
+ [0.022099846, 0, 0.2466818, 0.29141383],
+ [0.001866689, 0.019948359, 0.020817651, 0.402441943],
+ [0, 0.007849952, 0, 0.196557266],
+ [0, 0.000725833, 0, 0.012251181],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00005303, 0, 0.000766015],
+ [0, 0.010680219, 0, 0.152171837],
+ [0.008804639, 0.01050638, 0.066449991, 0.223593271],
+ [0.101890195, 0, 0.772356784, 0.978905927],
+ [0.102230046, 0, 0.788999975, 1],
+ [0.103576601, 0, 0.788999975, 1],
+ [0.102230046, 0, 0.788999975, 1],
+ [0.101890195, 0, 0.772356784, 0.978905927],
+ [0.008804639, 0.008299192, 0.066449991, 0.223593271],
+ [0, 0.01061468, 0, 0.152171837],
+ [0, 0.000050253, 0, 0.00077326],
+ [0, 0.000367486, 0, 0.0073999],
+ [0, 0.010890123, 0, 0.206619384],
+ [0.001106367, 0.02018717, 0.013653037, 0.436316418],
+ [0.025764218, 0.002226467, 0.332110482, 0.436751056],
+ [0.068348223, 0, 0.826017181, 0.975802946],
+ [0.071249769, 0, 0.846499979, 1],
+ [0.068951926, 0, 0.846499979, 1],
+ [0.071089581, 0, 0.846499979, 1],
+ [0.074506367, 0, 0.846499979, 1],
+ [0.074192417, 0, 0.846499979, 1],
+ [0.074111421, 0, 0.82654161, 0.976422481],
+ [0.038902547, 0.001851731, 0.448219156, 0.558642154],
+ [0, 0.017799625, 0, 0.39290174],
+ [0, 0.004928018, 0, 0.08979881],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000011162, 0, 0.000211075],
+ [0, 0.005561418, 0, 0.079497537],
+ [0.000006165, 0.026290367, 0.000046106, 0.378171491],
+ [0.030208363, 0.013177246, 0.22592375, 0.46892793],
+ [0.036503113, 0.011226523, 0.273001228, 0.515773411],
+ [0.03583558, 0.011512619, 0.273001228, 0.515773411],
+ [0.036503113, 0.01363486, 0.273001228, 0.515773411],
+ [0.030208363, 0.01415576, 0.22592375, 0.46892793],
+ [0.000006165, 0.023568469, 0.000046106, 0.378171491],
+ [0, 0.005766474, 0, 0.079497537],
+ [0, 0.000011823, 0, 0.000211075],
+ [0, 0.000001335, 0, 0.000036782],
+ [0, 0.000572796, 0, 0.009042708],
+ [0, 0.007986929, 0, 0.161738191],
+ [0, 0.016241769, 0, 0.394635052],
+ [0.023514748, 0.007002985, 0.285072464, 0.446200308],
+ [0.066975986, 0, 0.783824296, 0.925959013],
+ [0.070897078, 0, 0.846499979, 1],
+ [0.073716707, 0, 0.846499979, 1],
+ [0.072200318, 0, 0.846499979, 1],
+ [0.072895552, 0, 0.846499979, 1],
+ [0.075904453, 0, 0.846499979, 1],
+ [0.056464015, 0, 0.64369987, 0.760425146],
+ [0.000095008, 0.019933505, 0.001085153, 0.348476932],
+ [0, 0.005015872, 0, 0.112331775],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000291016, 0, 0.003772369],
+ [0, 0.006764536, 0, 0.118109665],
+ [0, 0.023183641, 0, 0.312032468],
+ [0, 0.028132758, 0, 0.330800657],
+ [0, 0.022943812, 0, 0.330800657],
+ [0, 0.018031461, 0, 0.330800657],
+ [0, 0.0204048, 0, 0.312032468],
+ [0, 0.009432704, 0, 0.118109665],
+ [0, 0.000239176, 0, 0.003772369],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000123222, 0, 0.002520441],
+ [0, 0.004493114, 0, 0.093118775],
+ [0, 0.020376897, 0, 0.414455348],
+ [0.010520237, 0.001365878, 0.122924989, 0.1742868],
+ [0.065406071, 0.000223592, 0.744524296, 0.882354346],
+ [0.07504564, 0, 0.846499979, 1],
+ [0.069865699, 0, 0.846499979, 1],
+ [0.074207917, 0, 0.846499979, 1],
+ [0.069724427, 0, 0.83036444, 0.98093852],
+ [0.015496485, 0.00981962, 0.172838331, 0.402189924],
+ [0, 0.021244745, 0, 0.396235973],
+ [0, 0.00141391, 0, 0.031778858],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 9.84e-7, 0, 0.000019599],
+ [0, 0.000127837, 0, 0.00147572],
+ [0, 0.00044437, 0, 0.00696401],
+ [0, 0.000476095, 0, 0.007871606],
+ [0, 0.000560999, 0, 0.007871606],
+ [0, 0.000555237, 0, 0.007871606],
+ [0, 0.000461505, 0, 0.00696401],
+ [0, 0.000116251, 0, 0.00147572],
+ [0, 0.000001107, 0, 0.000019599],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 4.63e-7, 0, 0.000012542],
+ [0, 0.000116676, 0, 0.001900417],
+ [0, 0.005137175, 0, 0.093780712],
+ [0.000056742, 0.015629876, 0.000642975, 0.376683306],
+ [0.011840388, 0.012822097, 0.133405636, 0.388048293],
+ [0.054946292, 0, 0.637672225, 0.753304461],
+ [0.075806813, 0, 0.845443398, 0.99875182],
+ [0.068674978, 0, 0.809560891, 0.956362556],
+ [0.022633138, 0, 0.265010098, 0.313065666],
+ [0.00028137, 0.019457041, 0.003241381, 0.374175416],
+ [0, 0.002959685, 0, 0.074305293],
+ [0, 0.000076313, 0, 0.001344509],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000047179, 0, 0.00083043],
+ [0, 0.001773941, 0, 0.040416774],
+ [0, 0.014832955, 0, 0.3424016],
+ [0.004156747, 0.017914788, 0.05017412, 0.359260085],
+ [0.048499561, 0.000773256, 0.544355761, 0.661574199],
+ [0.039140421, 0.002494278, 0.436500854, 0.5547936],
+ [0, 0.018421567, 0, 0.442374103],
+ [0, 0.00771897, 0, 0.144594245],
+ [0, 0.000046378, 0, 0.000994351],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000018636, 0, 0.000361616],
+ [0, 0.001515939, 0, 0.029370503],
+ [0, 0.011626355, 0, 0.235313224],
+ [0.001401362, 0.020209466, 0.015628235, 0.437557867],
+ [0.001120853, 0.021758119, 0.012499946, 0.434193347],
+ [0, 0.007472837, 0, 0.184268172],
+ [0, 0.000566082, 0, 0.009231487],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000217131, 0, 0.001871454],
+ [0, 0.018326641, 0, 0.148355855],
+ [0.008456822, 0.02592693, 0.04377579, 0.351931363],
+ [0.088784135, 0.004501063, 0.459577513, 0.672590443],
+ [0.042537104, 0.009621285, 0.221653477, 0.380012912],
+ [0.000600289, 0.03736433, 0.003185588, 0.333195133],
+ [0, 0.036454737, 0, 0.422655811],
+ [0, 0.027057849, 0, 0.216470031],
+ [0, 0.000689632, 0, 0.00903928],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002893137, 0, 0.028102585],
+ [0, 0.041061901, 0, 0.392523764],
+ [0.050686975, 0.014914172, 0.262008481, 0.467586203],
+ [0.143627465, 0, 0.742999971, 1],
+ [0.142407369, 0, 0.740285891, 0.996347125],
+ [0.109928552, 0, 0.592343776, 0.797232571],
+ [0.060809336, 0.013276995, 0.315807434, 0.49707071],
+ [0.009939433, 0.032140643, 0.05144994, 0.40350916],
+ [0, 0.015489773, 0, 0.158863941],
+ [0, 0, 0, 0],
+ [0, 0.000024972, 0, 0.000302562],
+ [0, 0.014754982, 0, 0.130412795],
+ [0, 0.031228702, 0, 0.389381215],
+ [0.104772918, 0, 0.540796671, 0.727855547],
+ [0.144856119, 0, 0.742999971, 1],
+ [0.141403835, 0, 0.742999971, 1],
+ [0.143724479, 0, 0.742999971, 1],
+ [0.143549259, 0, 0.742999971, 1],
+ [0.043088783, 0.015511357, 0.223042443, 0.480191575],
+ [0, 0.038664038, 0, 0.290329458],
+ [0, 0.000002197, 0, 0.000020832],
+ [0, 0.001278782, 0, 0.015540274],
+ [0, 0.035939443, 0, 0.319325382],
+ [0.037195697, 0.014872137, 0.196174443, 0.357541061],
+ [0.14418113, 0, 0.724200047, 0.974697275],
+ [0.146535546, 0, 0.742999971, 1],
+ [0.14431986, 0, 0.742999971, 1],
+ [0.14520586, 0, 0.742999971, 1],
+ [0.125065214, 0, 0.643388439, 0.865933335],
+ [0.006552442, 0.029913471, 0.033884798, 0.343025277],
+ [0, 0.011226771, 0, 0.117120145],
+ [0, 0.000098847, 0, 0.000896796],
+ [0, 0.014032069, 0, 0.124107006],
+ [0.005511944, 0.036371214, 0.027498872, 0.413881733],
+ [0.121255986, 0.001750178, 0.59043352, 0.808168819],
+ [0.1473838, 0, 0.742999971, 1],
+ [0.149302127, 0, 0.742999971, 1],
+ [0.147704458, 0, 0.742999971, 1],
+ [0.146828637, 0, 0.740467879, 0.996592069],
+ [0.063928879, 0, 0.327443729, 0.440704882],
+ [0, 0.041468909, 0, 0.368757183],
+ [0, 0.001262307, 0, 0.014576263],
+ [0, 0.001060498, 0, 0.011665029],
+ [0, 0.042081902, 0, 0.345431656],
+ [0.036415535, 0.019599894, 0.173942556, 0.396824163],
+ [0.143421085, 0, 0.719438776, 0.968289104],
+ [0.153917738, 0, 0.742999971, 1],
+ [0.14807751, 0, 0.742999971, 1],
+ [0.146972875, 0, 0.742999971, 1],
+ [0.132741388, 0, 0.689382405, 0.927836379],
+ [0.01846471, 0.018284399, 0.09600393, 0.349321746],
+ [0, 0.026675882, 0, 0.26758718],
+ [0, 0.000117749, 0, 0.001052298],
+ [0, 0.00771062, 0, 0.074776992],
+ [0.000464894, 0.035206671, 0.002166531, 0.370844266],
+ [0.102697328, 0, 0.485532206, 0.653475385],
+ [0.157739671, 0, 0.742999971, 1],
+ [0.150854593, 0, 0.742999971, 1],
+ [0.150699029, 0, 0.742999971, 1],
+ [0.152688194, 0, 0.742999971, 1],
+ [0.117266373, 0.002631629, 0.569889141, 0.781878547],
+ [0.00504039, 0.046400808, 0.024570831, 0.44567775],
+ [0, 0.012331886, 0, 0.110073033],
+ [0, 0, 0, 0],
+ [0, 0.028344113, 0, 0.251321786],
+ [0.026950294, 0.020163395, 0.125730982, 0.324874938],
+ [0.158010038, 0, 0.736402423, 0.991120395],
+ [0.152442818, 0, 0.742999971, 1],
+ [0.156933082, 0, 0.742999971, 1],
+ [0.158005321, 0, 0.742999971, 1],
+ [0.157295524, 0, 0.742999971, 1],
+ [0.066585924, 0.007109917, 0.316112729, 0.517996875],
+ [0.000037514, 0.039796557, 0.000181915, 0.369994612],
+ [0, 0.002456926, 0, 0.01986481],
+ [0, 0, 0, 0],
+ [2.8e-8, 0.040608135, 1.28e-7, 0.396294931],
+ [0.07534295, 0.010681124, 0.351012575, 0.571318232],
+ [0.158859889, 0, 0.742999971, 1],
+ [0.159326811, 0, 0.742999971, 1],
+ [0.154939645, 0, 0.742999971, 1],
+ [0.159248398, 0, 0.742999971, 1],
+ [0.143915247, 0, 0.672429985, 0.905020179],
+ [0.011225421, 0.02668756, 0.052749752, 0.301716547],
+ [0, 0.01860207, 0, 0.181474939],
+ [0, 0.000191195, 0, 0.002166625],
+ [0, 0, 0, 0],
+ [0, 0.028505516, 0, 0.260573703],
+ [0.008449908, 0.017081003, 0.039370917, 0.249393425],
+ [0.074850136, 0.005061574, 0.34870517, 0.524867657],
+ [0.132621458, 0.00058138, 0.644805654, 0.871750379],
+ [0.154416851, 0, 0.722839531, 0.972866158],
+ [0.159284478, 0, 0.742074509, 0.998754425],
+ [0.087556635, 0.002200472, 0.407939087, 0.563487501],
+ [0, 0.038836572, 0, 0.406291852],
+ [0, 0.00676271, 0, 0.05520572],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00299193, 0, 0.028818236],
+ [0, 0.021175486, 0, 0.200856033],
+ [0.000337417, 0.040255055, 0.00157193, 0.371186538],
+ [0.010671383, 0.033280569, 0.050846861, 0.426134376],
+ [0.043170632, 0.016082665, 0.201119886, 0.390063484],
+ [0.101313463, 0, 0.472012867, 0.63527976],
+ [0.025997051, 0.024828062, 0.12117359, 0.383334762],
+ [0, 0.029243723, 0, 0.310752146],
+ [0, 0.00098219, 0, 0.0096936],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0]
+ ]
+ },
+ "afterRecovery": {
+ "activeCells": 875,
+ "samples": [
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000404908, 0, 0.00528973],
+ [0, 0.00053844, 0, 0.007391517],
+ [0, 0.000532685, 0, 0.007391583],
+ [0, 0.00061754, 0, 0.007391517],
+ [0, 0.00040874, 0, 0.00528973],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000071227, 0, 0.000860014],
+ [0, 0.003751841, 0, 0.060190363],
+ [0, 0.020073636, 0, 0.260781281],
+ [0, 0.027927058, 0, 0.310626724],
+ [0, 0.023017457, 0, 0.310627993],
+ [0, 0.017831044, 0, 0.310626724],
+ [0, 0.018333839, 0, 0.260781281],
+ [0, 0.005281863, 0, 0.060190363],
+ [0, 0.000053706, 0, 0.000860014],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002055101, 0, 0.028405512],
+ [0, 0.02802358, 0, 0.349309271],
+ [0.017325834, 0.01217227, 0.145219298, 0.372875731],
+ [0.03125971, 0.009953108, 0.256483265, 0.4843176],
+ [0.027780701, 0.012074159, 0.256486585, 0.484320886],
+ [0.03125971, 0.012360132, 0.256483265, 0.4843176],
+ [0.017325834, 0.013412446, 0.145219298, 0.372875731],
+ [0, 0.024139944, 0, 0.349309271],
+ [0, 0.002273829, 0, 0.028405512],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.004933227, 0, 0.062756097],
+ [0, 0.022532088, 0, 0.333316709],
+ [0.070944542, 0, 0.573290147, 0.726225565],
+ [0.09178756, 0, 0.741271138, 0.939018428],
+ [0.09065018, 0, 0.741271138, 0.939018428],
+ [0.09178756, 0, 0.741271138, 0.939018428],
+ [0.070944542, 0, 0.573290147, 0.726225565],
+ [0, 0.026253328, 0, 0.333316709],
+ [0, 0.004439593, 0, 0.062756097],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.0048469, 0, 0.067883919],
+ [0, 0.025330515, 0, 0.325924232],
+ [0.073571436, 0, 0.590840941, 0.748458413],
+ [0.091407451, 0, 0.741271138, 0.939018428],
+ [0.091249384, 0, 0.741271138, 0.939018428],
+ [0.091407451, 0, 0.741271138, 0.939018428],
+ [0.073571436, 0, 0.590840941, 0.748458413],
+ [0, 0.022715108, 0, 0.325924232],
+ [0, 0.005238095, 0, 0.067883919],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.005979585, 0, 0.077701583],
+ [0, 0.021763019, 0.000269348, 0.312247616],
+ [0.076346504, 0, 0.620167705, 0.785608599],
+ [0.094391176, 0, 0.741271138, 0.939018428],
+ [0.090087269, 0, 0.741271138, 0.939018428],
+ [0.094391176, 0, 0.741271138, 0.939018428],
+ [0.076346504, 0, 0.620167705, 0.785608599],
+ [0, 0.023921144, 0.000269348, 0.312247616],
+ [0, 0.005614399, 0, 0.077701583],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000582922, 0, 0.009878053],
+ [0, 0.006755097, 0, 0.153168185],
+ [0, 0.014971171, 0, 0.276169809],
+ [0, 0.015052421, 0, 0.228687975],
+ [0, 0.002216224, 0, 0.048371355],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.006536034, 0, 0.091428613],
+ [0, 0.022790417, 0.004837376, 0.294226196],
+ [0.0839225, 0, 0.654096262, 0.828588402],
+ [0.096704099, 0, 0.741271138, 0.939018428],
+ [0.093987461, 0, 0.741271138, 0.939018428],
+ [0.096704099, 0, 0.741271138, 0.939018428],
+ [0.0839225, 0, 0.654096262, 0.828588402],
+ [0, 0.020704232, 0.004837376, 0.294226196],
+ [0, 0.006989422, 0, 0.091428613],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.006763469, 0, 0.132609277],
+ [0, 0.022020589, 0, 0.385169833],
+ [0.015883265, 0.002263921, 0.210229512, 0.290266824],
+ [0, 0.014352666, 0.018009176, 0.250416371],
+ [0, 0.020358683, 0, 0.367640583],
+ [0, 0.00325231, 0, 0.076875142],
+ [0, 0.000141054, 0, 0.002110758],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.008153652, 0, 0.107863818],
+ [0.000834092, 0.019741465, 0.018791725, 0.274674213],
+ [0.09122272, 0, 0.684831524, 0.867522525],
+ [0.094271775, 0, 0.741271138, 0.939018428],
+ [0.096786007, 0, 0.741271138, 0.939018428],
+ [0.094271775, 0, 0.741271138, 0.939018428],
+ [0.09122272, 0, 0.684831524, 0.867522525],
+ [0.000834092, 0.0206184, 0.018791725, 0.274674213],
+ [0, 0.00792394, 0, 0.107863818],
+ [0, 0, 0, 0],
+ [0, 0.000029337, 0, 0.000641707],
+ [0, 0.00492799, 0, 0.089849209],
+ [0.000199558, 0.019978149, 0.006139223, 0.366554121],
+ [0.023045664, 0.002077441, 0.317558822, 0.419610926],
+ [0.063378117, 0, 0.771130202, 0.910489347],
+ [0.055136536, 0, 0.6676603, 0.788320444],
+ [0.018203106, 0.00587207, 0.228278592, 0.394410189],
+ [0, 0.020644739, 0, 0.359392368],
+ [0, 0.010942227, 0, 0.193322119],
+ [0, 0.001073109, 0, 0.023230945],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.009084117, 0, 0.124416517],
+ [0.003535492, 0.018773723, 0.037703677, 0.251367978],
+ [0.093199516, 0, 0.70808753, 0.896982701],
+ [0.099983219, 0, 0.741271138, 0.939018428],
+ [0.098954304, 0, 0.741271138, 0.939018428],
+ [0.099983219, 0, 0.741271138, 0.939018428],
+ [0.093199516, 0, 0.70808753, 0.896982701],
+ [0.003535492, 0.017908475, 0.037703677, 0.251367978],
+ [0, 0.009332753, 0, 0.124416517],
+ [0, 0, 0, 0],
+ [0, 0.001164415, 0, 0.022458824],
+ [0, 0.022356775, 0, 0.356071984],
+ [0.011077554, 0.006959041, 0.156596466, 0.357174651],
+ [0.058581153, 0, 0.751601424, 0.887431425],
+ [0.06248951, 0, 0.795292497, 0.939018428],
+ [0.066543168, 0, 0.795292497, 0.939018428],
+ [0.06631968, 0, 0.788722478, 0.931261156],
+ [0.032099455, 0.001568557, 0.386656162, 0.486142614],
+ [0.002122175, 0.023179458, 0.03028719, 0.384322232],
+ [0, 0.013684395, 0, 0.301785909],
+ [0, 0.00160639, 0, 0.039640593],
+ [0, 0.000112165, 0, 0.00167618],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.010287621, 0, 0.137299651],
+ [0.005984103, 0.010206074, 0.054735241, 0.199472283],
+ [0.095171401, 0, 0.721110597, 0.913479814],
+ [0.10247441, 0, 0.741271138, 0.939018428],
+ [0.100152017, 0, 0.741271138, 0.939018428],
+ [0.10247441, 0, 0.741271138, 0.939018428],
+ [0.095171401, 0, 0.721110597, 0.913479814],
+ [0.005984103, 0.008543154, 0.054735241, 0.199472283],
+ [0, 0.010208376, 0, 0.137299651],
+ [0, 0, 0, 0],
+ [0, 0.004564787, 0, 0.094108402],
+ [0.001408976, 0.012969657, 0.029008759, 0.252869227],
+ [0.054711195, 0, 0.666895798, 0.78741766],
+ [0.062305676, 0, 0.79529243, 0.939018347],
+ [0.064494531, 0, 0.795292497, 0.939018428],
+ [0.06492512, 0, 0.795292497, 0.939018428],
+ [0.067208775, 0, 0.795292497, 0.939018428],
+ [0.062731332, 0, 0.78298023, 0.924481066],
+ [0.052804284, 0, 0.598367109, 0.706504497],
+ [0.011943196, 0.010083063, 0.139654472, 0.358228647],
+ [0, 0.020843277, 0, 0.392405691],
+ [0, 0.006844884, 0, 0.12825052],
+ [0, 0.000238346, 0, 0.00390472],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.0105859, 0, 0.142789924],
+ [0.006964167, 0.008601965, 0.062333688, 0.209825675],
+ [0.100861877, 0, 0.725659929, 0.919242889],
+ [0.101244063, 0, 0.741271138, 0.939018428],
+ [0.096843007, 0, 0.741271138, 0.939018428],
+ [0.101244063, 0, 0.741271138, 0.939018428],
+ [0.100861877, 0, 0.725659929, 0.919242889],
+ [0.006964167, 0.01035276, 0.062333688, 0.209825675],
+ [0, 0.010638935, 0, 0.142789924],
+ [0, 0, 0, 0],
+ [0, 0.003426592, 0, 0.069681837],
+ [0, 0.017068614, 0.002062151, 0.336426702],
+ [0.035130972, 0.003364834, 0.439650156, 0.569637372],
+ [0.064232869, 0, 0.775121443, 0.915201954],
+ [0.066441568, 0, 0.795292497, 0.939018428],
+ [0.05889763, 0, 0.795292497, 0.939018428],
+ [0.067680352, 0, 0.795292497, 0.939018428],
+ [0.070355255, 0, 0.795292497, 0.939018428],
+ [0.066056901, 0, 0.795292497, 0.939018428],
+ [0.064068414, 0, 0.711730515, 0.840355091],
+ [0.019699796, 0, 0.231759348, 0.273642964],
+ [0.001592472, 0.019938051, 0.01955833, 0.377900507],
+ [0, 0.007845895, 0, 0.184570881],
+ [0, 0.000673599, 0, 0.010238976],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.010674645, 0, 0.142892181],
+ [0.007079626, 0.01050088, 0.06243023, 0.209958248],
+ [0.099490512, 0, 0.725634777, 0.919210886],
+ [0.099830363, 0, 0.741271138, 0.939018428],
+ [0.101176918, 0, 0.741271138, 0.939018428],
+ [0.099830363, 0, 0.741271138, 0.939018428],
+ [0.099490512, 0, 0.725634777, 0.919210886],
+ [0.007079626, 0.008294874, 0.06243023, 0.209958248],
+ [0, 0.010609131, 0, 0.142892181],
+ [0, 0, 0, 0],
+ [0, 0.000303066, 0, 0.005969269],
+ [0, 0.010884481, 0, 0.194019417],
+ [0.000973627, 0.020176616, 0.012827125, 0.409709233],
+ [0.023692443, 0.002225306, 0.312020143, 0.410117377],
+ [0.06594854, 0, 0.77604882, 0.916297038],
+ [0.068850073, 0, 0.795292497, 0.939018428],
+ [0.066552096, 0, 0.795292497, 0.939018428],
+ [0.068689898, 0, 0.795292497, 0.939018428],
+ [0.072106684, 0, 0.795292497, 0.939018428],
+ [0.071792734, 0, 0.795292497, 0.939018428],
+ [0.071711682, 0, 0.776541524, 0.916878708],
+ [0.036680834, 0.001850768, 0.42110509, 0.524575325],
+ [0, 0.017790354, 0, 0.368942005],
+ [0, 0.00490558, 0, 0.083821033],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.005558514, 0, 0.074649648],
+ [0, 0.026276675, 0.000043317, 0.35511008],
+ [0.028933368, 0.013170377, 0.212256939, 0.440332],
+ [0.035228282, 0.01122073, 0.256486585, 0.484320886],
+ [0.034560749, 0.011506595, 0.256486585, 0.484320886],
+ [0.035228282, 0.013627772, 0.256486585, 0.484320886],
+ [0.028933368, 0.01414838, 0.212256939, 0.440332],
+ [0, 0.023556222, 0.000043317, 0.35511008],
+ [0, 0.005763477, 0, 0.074649648],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000556127, 0, 0.008151438],
+ [0, 0.007982791, 0, 0.151875174],
+ [0, 0.016233329, 0, 0.370569515],
+ [0.021747554, 0.006999331, 0.267827603, 0.41899043],
+ [0.0645761, 0, 0.736408297, 0.869492604],
+ [0.068497376, 0, 0.795292497, 0.939018428],
+ [0.071317024, 0, 0.795292497, 0.939018428],
+ [0.069800635, 0, 0.795292497, 0.939018428],
+ [0.070495869, 0, 0.795292497, 0.939018428],
+ [0.07350477, 0, 0.795292497, 0.939018428],
+ [0.054064129, 0, 0.604760553, 0.714053373],
+ [0, 0.019923182, 0.001019509, 0.327226306],
+ [0, 0.00501326, 0, 0.105481633],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000250679, 0, 0.002932722],
+ [0, 0.006760589, 0, 0.110899644],
+ [0, 0.023171628, 0, 0.293004304],
+ [0, 0.028118215, 0, 0.310627993],
+ [0, 0.022931815, 0, 0.310627993],
+ [0, 0.018022057, 0, 0.310627993],
+ [0, 0.020394266, 0, 0.293004304],
+ [0, 0.009427347, 0, 0.110899644],
+ [0, 0.00019093, 0, 0.002932722],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00011277, 0, 0.002188138],
+ [0, 0.004490715, 0, 0.087438913],
+ [0, 0.02036625, 0, 0.389181323],
+ [0.008265529, 0.001365165, 0.115488896, 0.163658518],
+ [0.063006206, 0.000223477, 0.69948572, 0.828546993],
+ [0.072645957, 0, 0.795292497, 0.939018428],
+ [0.067466016, 0, 0.795292497, 0.939018428],
+ [0.071808234, 0, 0.795292497, 0.939018428],
+ [0.067324716, 0, 0.780133061, 0.921119347],
+ [0.014531214, 0.009814492, 0.162382767, 0.37766383],
+ [0, 0.02123376, 0, 0.372072967],
+ [0, 0.001184306, 0, 0.02627428],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000125558, 0, 0.00134432],
+ [0, 0.000444138, 0, 0.006539334],
+ [0, 0.000475849, 0, 0.007391583],
+ [0, 0.000560708, 0, 0.007391583],
+ [0, 0.000554948, 0, 0.007391583],
+ [0, 0.000461264, 0, 0.006539334],
+ [0, 0.000113702, 0, 0.00134432],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000116326, 0, 0.001777167],
+ [0, 0.005078354, 0, 0.086606888],
+ [0.00003096, 0.015621715, 0.000604079, 0.353712656],
+ [0.010443923, 0.01281544, 0.12533551, 0.364384587],
+ [0.05254638, 0, 0.599097476, 0.707366942],
+ [0.07340713, 0, 0.794299854, 0.937846392],
+ [0.066275224, 0, 0.760588005, 0.898042135],
+ [0.020233082, 0, 0.248978884, 0.293974436],
+ [0, 0.019446879, 0.003045299, 0.351357704],
+ [0, 0.002953102, 0, 0.069700005],
+ [0, 0.000067463, 0, 0.001117041],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.001755082, 0, 0.037643848],
+ [0, 0.014825208, 0, 0.321521512],
+ [0.003763013, 0.017905505, 0.047138934, 0.33735191],
+ [0.046212264, 0.000772858, 0.511426138, 0.621230563],
+ [0.036984338, 0.002492984, 0.410095682, 0.520961472],
+ [0, 0.018412011, 0, 0.415397435],
+ [0, 0.007693368, 0, 0.135237088],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000006829, 0, 0.000131956],
+ [0, 0.001508088, 0, 0.027400056],
+ [0, 0.011620294, 0, 0.220963484],
+ [0.001096672, 0.02019888, 0.014682837, 0.410874873],
+ [0.000898195, 0.021746755, 0.011743788, 0.407715641],
+ [0, 0.00746894, 0, 0.173031235],
+ [0, 0.000552309, 0, 0.008331299],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000212587, 0, 0.001710458],
+ [0, 0.018315123, 0, 0.139288099],
+ [0.007542795, 0.025913409, 0.041127677, 0.330470075],
+ [0.086788587, 0.004498714, 0.431776321, 0.631574988],
+ [0.040587146, 0.009616295, 0.208244972, 0.356839185],
+ [0, 0.037344933, 0.002992881, 0.312876502],
+ [0, 0.036435717, 0, 0.396881714],
+ [0, 0.027043741, 0, 0.203269336],
+ [0, 0.00068365, 0, 0.008447941],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002859387, 0, 0.026163478],
+ [0, 0.041040596, 0, 0.368587034],
+ [0.049044151, 0.014906417, 0.246158732, 0.439072153],
+ [0.141227782, 0, 0.698053718, 0.939018428],
+ [0.140007686, 0, 0.695503837, 0.935588372],
+ [0.107528803, 0, 0.556511147, 0.74861616],
+ [0.058409444, 0.013270052, 0.296703318, 0.46675856],
+ [0.009390965, 0.032123929, 0.048337597, 0.378902633],
+ [0, 0.01547736, 0, 0.149132623],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.014747351, 0, 0.122460041],
+ [0, 0.031212441, 0, 0.365636248],
+ [0.102373235, 0, 0.508082294, 0.683469823],
+ [0.142456436, 0, 0.698053718, 0.939018428],
+ [0.139004152, 0, 0.698053718, 0.939018428],
+ [0.141324796, 0, 0.698053718, 0.939018428],
+ [0.141149576, 0, 0.698053718, 0.939018428],
+ [0.041738961, 0.015503338, 0.209549977, 0.450908855],
+ [0, 0.038644092, 0, 0.272624819],
+ [0, 0, 0, 0],
+ [0, 0.001270793, 0, 0.014527404],
+ [0, 0.035920812, 0, 0.299852478],
+ [0.035095733, 0.014864399, 0.184307144, 0.335737657],
+ [0.141781447, 0, 0.680391014, 0.915258805],
+ [0.144135863, 0, 0.698053718, 0.939018428],
+ [0.141920177, 0, 0.698053718, 0.939018428],
+ [0.142806177, 0, 0.698053718, 0.939018428],
+ [0.122665531, 0, 0.604467967, 0.813127342],
+ [0.005846408, 0.029897862, 0.031835004, 0.322107137],
+ [0, 0.011218686, 0, 0.109959619],
+ [0, 0.000092746, 0, 0.00079736],
+ [0, 0.014024716, 0, 0.11653876],
+ [0.004836933, 0.03635221, 0.025835382, 0.38864264],
+ [0.118856258, 0.00174927, 0.554716497, 0.758885595],
+ [0.144984117, 0, 0.698053718, 0.939018428],
+ [0.146902444, 0, 0.698053718, 0.939018428],
+ [0.145304775, 0, 0.698053718, 0.939018428],
+ [0.144428954, 0, 0.695674805, 0.935818324],
+ [0.061528837, 0, 0.307635729, 0.413830085],
+ [0, 0.041447277, 0, 0.346269903],
+ [0, 0.001261654, 0, 0.013687383],
+ [0, 0.00086125, 0, 0.008827311],
+ [0, 0.042060186, 0, 0.32436677],
+ [0.03439293, 0.019589737, 0.163420256, 0.372625306],
+ [0.14102139, 0, 0.675917807, 0.909241337],
+ [0.151518055, 0, 0.698053718, 0.939018428],
+ [0.145677827, 0, 0.698053718, 0.939018428],
+ [0.144573192, 0, 0.698053718, 0.939018428],
+ [0.130341705, 0, 0.647679633, 0.871255522],
+ [0.017646788, 0.018274835, 0.090196368, 0.328019762],
+ [0, 0.026661949, 0, 0.251269326],
+ [0, 0.000085007, 0, 0.000660522],
+ [0, 0.007706615, 0, 0.070216982],
+ [0, 0.03518835, 0.002035472, 0.348229682],
+ [0.100297444, 0, 0.456160886, 0.61362547],
+ [0.155339988, 0, 0.698053718, 0.939018428],
+ [0.14845491, 0, 0.698053718, 0.939018428],
+ [0.148299346, 0, 0.698053718, 0.939018428],
+ [0.150288511, 0, 0.698053718, 0.939018428],
+ [0.114866589, 0.002630267, 0.535414895, 0.73419837],
+ [0.004665376, 0.046376571, 0.023084472, 0.41849968],
+ [0, 0.012280871, 0, 0.103045675],
+ [0, 0, 0, 0],
+ [0, 0.028329352, 0, 0.235995871],
+ [0.025313029, 0.020152807, 0.11812512, 0.305063553],
+ [0.155610355, 0, 0.691855245, 0.930680487],
+ [0.150043135, 0, 0.698053718, 0.939018428],
+ [0.154533399, 0, 0.698053718, 0.939018428],
+ [0.155605638, 0, 0.698053718, 0.939018428],
+ [0.154895841, 0, 0.698053718, 0.939018428],
+ [0.064767412, 0.007106231, 0.296990139, 0.486408743],
+ [0.000025796, 0.039775735, 0.00017091, 0.347431812],
+ [0, 0.002442872, 0, 0.01851953],
+ [0, 0, 0, 0],
+ [0, 0.040587082, 1.2e-7, 0.372128258],
+ [0.073693076, 0.010675597, 0.329778828, 0.536478516],
+ [0.156460206, 0, 0.698053718, 0.939018428],
+ [0.156927128, 0, 0.698053718, 0.939018428],
+ [0.152539962, 0, 0.698053718, 0.939018428],
+ [0.156848715, 0, 0.698053718, 0.939018428],
+ [0.141515564, 0, 0.631752707, 0.849830739],
+ [0.010056818, 0.026673723, 0.049558769, 0.283317459],
+ [0, 0.018592432, 0, 0.170408344],
+ [0, 0.000143263, 0, 0.001696362],
+ [0, 0, 0, 0],
+ [0, 0.028490577, 0, 0.244683543],
+ [0.00705771, 0.017072083, 0.036989255, 0.234185069],
+ [0.072719935, 0.005058938, 0.327610852, 0.492860605],
+ [0.130221707, 0.000581079, 0.605799381, 0.818589968],
+ [0.152017168, 0, 0.679112838, 0.913539261],
+ [0.156884795, 0, 0.697184238, 0.937848809],
+ [0.08515667, 0.002199326, 0.383261678, 0.529125162],
+ [0, 0.038816351, 0, 0.381515636],
+ [0, 0.006720648, 0, 0.051454974],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002990371, 0, 0.027060861],
+ [0, 0.021164471, 0, 0.188607563],
+ [0.000168667, 0.040234116, 0.001476839, 0.348551028],
+ [0.010071355, 0.033263156, 0.047770992, 0.400148133],
+ [0.041718203, 0.016074294, 0.188953539, 0.366276847],
+ [0.09891376, 0, 0.443459423, 0.596539483],
+ [0.024778292, 0.024815227, 0.113843437, 0.359958373],
+ [0, 0.029228436, 0, 0.291802069],
+ [0, 0.00089496, 0, 0.008292256],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0]
+ ]
+ },
+ "afterDepth": {
+ "activeCells": 875,
+ "samples": [
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000404908, 0, 0.00528973],
+ [0, 0.00053844, 0, 0.007391517],
+ [0, 0.000532685, 0, 0.007391583],
+ [0, 0.00061754, 0, 0.007391517],
+ [0, 0.00040874, 0, 0.00528973],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000071227, 0, 0.000860014],
+ [0, 0.003751841, 0, 0.060190363],
+ [0, 0.020073636, 0, 0.260781281],
+ [0, 0.027927058, 0, 0.310626724],
+ [0, 0.023017457, 0, 0.310627993],
+ [0, 0.017831044, 0, 0.310626724],
+ [0, 0.018333839, 0, 0.260781281],
+ [0, 0.005281863, 0, 0.060190363],
+ [0, 0.000053706, 0, 0.000860014],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002055101, 0, 0.028405512],
+ [0, 0.02802358, 0, 0.349309271],
+ [0.017325834, 0.01217227, 0.145219298, 0.372875731],
+ [0.03125971, 0.009953108, 0.256483265, 0.4843176],
+ [0.027780701, 0.012074159, 0.256486585, 0.484320886],
+ [0.03125971, 0.012360132, 0.256483265, 0.4843176],
+ [0.017325834, 0.013412446, 0.145219298, 0.372875731],
+ [0, 0.024139944, 0, 0.349309271],
+ [0, 0.002273829, 0, 0.028405512],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.004933227, 0, 0.062756097],
+ [0, 0.022532088, 0, 0.333316709],
+ [0.070944542, 0, 0.573290147, 0.726225565],
+ [0.09178756, 0, 0.741271138, 0.939018428],
+ [0.09065018, 0, 0.741271138, 0.939018428],
+ [0.09178756, 0, 0.741271138, 0.939018428],
+ [0.070944542, 0, 0.573290147, 0.726225565],
+ [0, 0.026253328, 0, 0.333316709],
+ [0, 0.004439593, 0, 0.062756097],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.0048469, 0, 0.067883919],
+ [0, 0.025330515, 0, 0.325924232],
+ [0.073571436, 0, 0.590840941, 0.748458413],
+ [0.091407451, 0, 0.741271138, 0.939018428],
+ [0.091249384, 0, 0.741271138, 0.939018428],
+ [0.091407451, 0, 0.741271138, 0.939018428],
+ [0.073571436, 0, 0.590840941, 0.748458413],
+ [0, 0.022715108, 0, 0.325924232],
+ [0, 0.005238095, 0, 0.067883919],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.005979585, 0, 0.077701583],
+ [0, 0.021763019, 0.000269348, 0.312247616],
+ [0.076346504, 0, 0.620167705, 0.785608599],
+ [0.094391176, 0, 0.741271138, 0.939018428],
+ [0.090087269, 0, 0.741271138, 0.939018428],
+ [0.094391176, 0, 0.741271138, 0.939018428],
+ [0.076346504, 0, 0.620167705, 0.785608599],
+ [0, 0.023921144, 0.000269348, 0.312247616],
+ [0, 0.005614399, 0, 0.077701583],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000582922, 0, 0.009878053],
+ [0, 0.006755097, 0, 0.153168185],
+ [0, 0.014971171, 0, 0.276169809],
+ [0, 0.015052421, 0, 0.228687975],
+ [0, 0.002216224, 0, 0.048371355],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.006536034, 0, 0.091428613],
+ [0, 0.022790417, 0.004837376, 0.294226196],
+ [0.0839225, 0, 0.654096262, 0.828588402],
+ [0.096704099, 0, 0.741271138, 0.939018428],
+ [0.093987461, 0, 0.741271138, 0.939018428],
+ [0.096704099, 0, 0.741271138, 0.939018428],
+ [0.0839225, 0, 0.654096262, 0.828588402],
+ [0, 0.020704232, 0.004837376, 0.294226196],
+ [0, 0.006989422, 0, 0.091428613],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.006763469, 0, 0.132609277],
+ [0, 0.022020589, 0, 0.385169833],
+ [0.015883265, 0.002263921, 0.210229512, 0.290266824],
+ [0, 0.014352666, 0.018009176, 0.250416371],
+ [0, 0.020358683, 0, 0.367640583],
+ [0, 0.00325231, 0, 0.076875142],
+ [0, 0.000141054, 0, 0.002110758],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.008153652, 0, 0.107863818],
+ [0.000834092, 0.019741465, 0.018791725, 0.274674213],
+ [0.09122272, 0, 0.684831524, 0.867522525],
+ [0.094271775, 0, 0.741271138, 0.939018428],
+ [0.096786007, 0, 0.741271138, 0.939018428],
+ [0.094271775, 0, 0.741271138, 0.939018428],
+ [0.09122272, 0, 0.684831524, 0.867522525],
+ [0.000834092, 0.0206184, 0.018791725, 0.274674213],
+ [0, 0.00792394, 0, 0.107863818],
+ [0, 0, 0, 0],
+ [0, 0.000029337, 0, 0.000641707],
+ [0, 0.00492799, 0, 0.089849209],
+ [0.000199558, 0.019978149, 0.006139223, 0.366554121],
+ [0.023045664, 0.002077441, 0.317558822, 0.419610926],
+ [0.063378117, 0, 0.771130202, 0.910489347],
+ [0.055136536, 0, 0.6676603, 0.788320444],
+ [0.018203106, 0.00587207, 0.228278592, 0.394410189],
+ [0, 0.020644739, 0, 0.359392368],
+ [0, 0.010942227, 0, 0.193322119],
+ [0, 0.001073109, 0, 0.023230945],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.009084117, 0, 0.124416517],
+ [0.003535492, 0.018773723, 0.037703677, 0.251367978],
+ [0.093199516, 0, 0.70808753, 0.896982701],
+ [0.099983219, 0, 0.741271138, 0.939018428],
+ [0.098954304, 0, 0.741271138, 0.939018428],
+ [0.099983219, 0, 0.741271138, 0.939018428],
+ [0.093199516, 0, 0.70808753, 0.896982701],
+ [0.003535492, 0.017908475, 0.037703677, 0.251367978],
+ [0, 0.009332753, 0, 0.124416517],
+ [0, 0, 0, 0],
+ [0, 0.001164415, 0, 0.022458824],
+ [0, 0.022356775, 0, 0.356071984],
+ [0.011077554, 0.006959041, 0.156596466, 0.357174651],
+ [0.058581153, 0, 0.751601424, 0.887431425],
+ [0.06248951, 0, 0.795292497, 0.939018428],
+ [0.066543168, 0, 0.795292497, 0.939018428],
+ [0.06631968, 0, 0.788722478, 0.931261156],
+ [0.032099455, 0.001568557, 0.386656162, 0.486142614],
+ [0.002122175, 0.023179458, 0.03028719, 0.384322232],
+ [0, 0.013684395, 0, 0.301785909],
+ [0, 0.00160639, 0, 0.039640593],
+ [0, 0.000112165, 0, 0.00167618],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.010287621, 0, 0.137299651],
+ [0.005984103, 0.010206074, 0.054735241, 0.199472283],
+ [0.095171401, 0, 0.721110597, 0.913479814],
+ [0.10247441, 0, 0.741271138, 0.939018428],
+ [0.100152017, 0, 0.741271138, 0.939018428],
+ [0.10247441, 0, 0.741271138, 0.939018428],
+ [0.095171401, 0, 0.721110597, 0.913479814],
+ [0.005984103, 0.008543154, 0.054735241, 0.199472283],
+ [0, 0.010208376, 0, 0.137299651],
+ [0, 0, 0, 0],
+ [0, 0.004564787, 0, 0.094108402],
+ [0.001408976, 0.012969657, 0.029008759, 0.252869227],
+ [0.054711195, 0, 0.666895798, 0.78741766],
+ [0.062305676, 0, 0.79529243, 0.939018347],
+ [0.064494531, 0, 0.795292497, 0.939018428],
+ [0.06492512, 0, 0.795292497, 0.939018428],
+ [0.067208775, 0, 0.795292497, 0.939018428],
+ [0.062731332, 0, 0.78298023, 0.924481066],
+ [0.052804284, 0, 0.598367109, 0.706504497],
+ [0.011943196, 0.010083063, 0.139654472, 0.358228647],
+ [0, 0.020843277, 0, 0.392405691],
+ [0, 0.006844884, 0, 0.12825052],
+ [0, 0.000238346, 0, 0.00390472],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.0105859, 0, 0.142789924],
+ [0.006964167, 0.008601965, 0.062333688, 0.209825675],
+ [0.100861877, 0, 0.725659929, 0.919242889],
+ [0.101244063, 0, 0.741271138, 0.939018428],
+ [0.096843007, 0, 0.741271138, 0.939018428],
+ [0.101244063, 0, 0.741271138, 0.939018428],
+ [0.100861877, 0, 0.725659929, 0.919242889],
+ [0.006964167, 0.01035276, 0.062333688, 0.209825675],
+ [0, 0.010638935, 0, 0.142789924],
+ [0, 0, 0, 0],
+ [0, 0.003426592, 0, 0.069681837],
+ [0, 0.017068614, 0.002062151, 0.336426702],
+ [0.035130972, 0.003364834, 0.439650156, 0.569637372],
+ [0.064232869, 0, 0.775121443, 0.915201954],
+ [0.066441568, 0, 0.795292497, 0.939018428],
+ [0.05889763, 0, 0.795292497, 0.939018428],
+ [0.067680352, 0, 0.795292497, 0.939018428],
+ [0.070355255, 0, 0.795292497, 0.939018428],
+ [0.066056901, 0, 0.795292497, 0.939018428],
+ [0.064068414, 0, 0.711730515, 0.840355091],
+ [0.019699796, 0, 0.231759348, 0.273642964],
+ [0.001592472, 0.019938051, 0.01955833, 0.377900507],
+ [0, 0.007845895, 0, 0.184570881],
+ [0, 0.000673599, 0, 0.010238976],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.010674645, 0, 0.142892181],
+ [0.007079626, 0.01050088, 0.06243023, 0.209958248],
+ [0.099490512, 0, 0.725634777, 0.919210886],
+ [0.099830363, 0, 0.741271138, 0.939018428],
+ [0.101176918, 0, 0.741271138, 0.939018428],
+ [0.099830363, 0, 0.741271138, 0.939018428],
+ [0.099490512, 0, 0.725634777, 0.919210886],
+ [0.007079626, 0.008294874, 0.06243023, 0.209958248],
+ [0, 0.010609131, 0, 0.142892181],
+ [0, 0, 0, 0],
+ [0, 0.000303066, 0, 0.005969269],
+ [0, 0.010884481, 0, 0.194019417],
+ [0.000973627, 0.020176616, 0.012827125, 0.409709233],
+ [0.023692443, 0.002225306, 0.312020143, 0.410117377],
+ [0.06594854, 0, 0.77604882, 0.916297038],
+ [0.068850073, 0, 0.795292497, 0.939018428],
+ [0.066552096, 0, 0.795292497, 0.939018428],
+ [0.068689898, 0, 0.795292497, 0.939018428],
+ [0.072106684, 0, 0.795292497, 0.939018428],
+ [0.071792734, 0, 0.795292497, 0.939018428],
+ [0.071711682, 0, 0.776541524, 0.916878708],
+ [0.036680834, 0.001850768, 0.42110509, 0.524575325],
+ [0, 0.017790354, 0, 0.368942005],
+ [0, 0.00490558, 0, 0.083821033],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.005558514, 0, 0.074649648],
+ [0, 0.026276675, 0.000043317, 0.35511008],
+ [0.028933368, 0.013170377, 0.212256939, 0.440332],
+ [0.035228282, 0.01122073, 0.256486585, 0.484320886],
+ [0.034560749, 0.011506595, 0.256486585, 0.484320886],
+ [0.035228282, 0.013627772, 0.256486585, 0.484320886],
+ [0.028933368, 0.01414838, 0.212256939, 0.440332],
+ [0, 0.023556222, 0.000043317, 0.35511008],
+ [0, 0.005763477, 0, 0.074649648],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000556127, 0, 0.008151438],
+ [0, 0.007982791, 0, 0.151875174],
+ [0, 0.016233329, 0, 0.370569515],
+ [0.021747554, 0.006999331, 0.267827603, 0.41899043],
+ [0.0645761, 0, 0.736408297, 0.869492604],
+ [0.068497376, 0, 0.795292497, 0.939018428],
+ [0.071317024, 0, 0.795292497, 0.939018428],
+ [0.069800635, 0, 0.795292497, 0.939018428],
+ [0.070495869, 0, 0.795292497, 0.939018428],
+ [0.07350477, 0, 0.795292497, 0.939018428],
+ [0.054064129, 0, 0.604760553, 0.714053373],
+ [0, 0.019923182, 0.001019509, 0.327226306],
+ [0, 0.00501326, 0, 0.105481633],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000250679, 0, 0.002932722],
+ [0, 0.006760589, 0, 0.110899644],
+ [0, 0.023171628, 0, 0.293004304],
+ [0, 0.028118215, 0, 0.310627993],
+ [0, 0.022931815, 0, 0.310627993],
+ [0, 0.018022057, 0, 0.310627993],
+ [0, 0.020394266, 0, 0.293004304],
+ [0, 0.009427347, 0, 0.110899644],
+ [0, 0.00019093, 0, 0.002932722],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.00011277, 0, 0.002188138],
+ [0, 0.004490715, 0, 0.087438913],
+ [0, 0.02036625, 0, 0.389181323],
+ [0.008265529, 0.001365165, 0.115488896, 0.163658518],
+ [0.063006206, 0.000223477, 0.69948572, 0.828546993],
+ [0.072645957, 0, 0.795292497, 0.939018428],
+ [0.067466016, 0, 0.795292497, 0.939018428],
+ [0.071808234, 0, 0.795292497, 0.939018428],
+ [0.067324716, 0, 0.780133061, 0.921119347],
+ [0.014531214, 0.009814492, 0.162382767, 0.37766383],
+ [0, 0.02123376, 0, 0.372072967],
+ [0, 0.001184306, 0, 0.02627428],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000125558, 0, 0.00134432],
+ [0, 0.000444138, 0, 0.006539334],
+ [0, 0.000475849, 0, 0.007391583],
+ [0, 0.000560708, 0, 0.007391583],
+ [0, 0.000554948, 0, 0.007391583],
+ [0, 0.000461264, 0, 0.006539334],
+ [0, 0.000113702, 0, 0.00134432],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000116326, 0, 0.001777167],
+ [0, 0.005078354, 0, 0.086606888],
+ [0.00003096, 0.015621715, 0.000604079, 0.353712656],
+ [0.010443923, 0.01281544, 0.12533551, 0.364384587],
+ [0.05254638, 0, 0.599097476, 0.707366942],
+ [0.07340713, 0, 0.794299854, 0.937846392],
+ [0.066275224, 0, 0.760588005, 0.898042135],
+ [0.020233082, 0, 0.248978884, 0.293974436],
+ [0, 0.019446879, 0.003045299, 0.351357704],
+ [0, 0.002953102, 0, 0.069700005],
+ [0, 0.000067463, 0, 0.001117041],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.001755082, 0, 0.037643848],
+ [0, 0.014825208, 0, 0.321521512],
+ [0.003763013, 0.017905505, 0.047138934, 0.33735191],
+ [0.046212264, 0.000772858, 0.511426138, 0.621230563],
+ [0.036984338, 0.002492984, 0.410095682, 0.520961472],
+ [0, 0.018412011, 0, 0.415397435],
+ [0, 0.007693368, 0, 0.135237088],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000006829, 0, 0.000131956],
+ [0, 0.001508088, 0, 0.027400056],
+ [0, 0.011620294, 0, 0.220963484],
+ [0.001096672, 0.02019888, 0.014682837, 0.410874873],
+ [0.000898195, 0.021746755, 0.011743788, 0.407715641],
+ [0, 0.00746894, 0, 0.173031235],
+ [0, 0.000552309, 0, 0.008331299],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.000212587, 0, 0.001710458],
+ [0, 0.018315123, 0, 0.139288099],
+ [0.007542795, 0.025913409, 0.041127677, 0.330470075],
+ [0.084831813, 0.004498714, 0.431776321, 0.631574988],
+ [0.040587146, 0.009616295, 0.208244972, 0.356839185],
+ [0, 0.037344933, 0.002992881, 0.312876502],
+ [0, 0.036435717, 0, 0.396881714],
+ [0, 0.027043741, 0, 0.203269336],
+ [0, 0.00068365, 0, 0.008447941],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002859387, 0, 0.026163478],
+ [0, 0.041040596, 0, 0.368587034],
+ [0.044463089, 0.014906417, 0.246158732, 0.439072153],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.695503837, 0.935588372],
+ [0.093841107, 0, 0.556511147, 0.74861616],
+ [0.050353775, 0.013270052, 0.296703318, 0.46675856],
+ [0.009390965, 0.032123929, 0.048337597, 0.378902633],
+ [0, 0.01547736, 0, 0.149132623],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.014747351, 0, 0.122460041],
+ [0, 0.031212441, 0, 0.365636248],
+ [0.084835982, 0, 0.508082294, 0.683469823],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.041738961, 0.015503338, 0.209549977, 0.450908855],
+ [0, 0.038644092, 0, 0.272624819],
+ [0, 0, 0, 0],
+ [0, 0.001270793, 0, 0.014527404],
+ [0, 0.035920812, 0, 0.299852478],
+ [0.035095733, 0.014864399, 0.184307144, 0.335737657],
+ [0.105599999, 0, 0.680391014, 0.915258805],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105576996, 0, 0.604467967, 0.813127342],
+ [0.005846408, 0.029897862, 0.031835004, 0.322107137],
+ [0, 0.011218686, 0, 0.109959619],
+ [0, 0.000092746, 0, 0.00079736],
+ [0, 0.014024716, 0, 0.11653876],
+ [0.004836933, 0.03635221, 0.025835382, 0.38864264],
+ [0.094549747, 0.00174927, 0.554716497, 0.758885595],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.695674805, 0.935818324],
+ [0.055833854, 0, 0.307635729, 0.413830085],
+ [0, 0.041447277, 0, 0.346269903],
+ [0, 0.001261654, 0, 0.013687383],
+ [0, 0.00086125, 0, 0.008827311],
+ [0, 0.042060186, 0, 0.32436677],
+ [0.02934486, 0.019589737, 0.163420256, 0.372625306],
+ [0.10434895, 0, 0.675917807, 0.909241337],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.102252451, 0, 0.647679633, 0.871255522],
+ [0.016632766, 0.018274835, 0.090196368, 0.328019762],
+ [0, 0.026661949, 0, 0.251269326],
+ [0, 0.000085007, 0, 0.000660522],
+ [0, 0.007706615, 0, 0.070216982],
+ [0, 0.03518835, 0.002035472, 0.348229682],
+ [0.082770507, 0, 0.456160886, 0.61362547],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.086664601, 0.002630267, 0.535414895, 0.73419837],
+ [0.004665376, 0.046376571, 0.023084472, 0.41849968],
+ [0, 0.012280871, 0, 0.103045675],
+ [0, 0, 0, 0],
+ [0, 0.028329352, 0, 0.235995871],
+ [0.025313029, 0.020152807, 0.11812512, 0.305063553],
+ [0.105599999, 0, 0.691855245, 0.930680487],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.064181481, 0.007106231, 0.296990139, 0.486408743],
+ [0.000025796, 0.039775735, 0.00017091, 0.347431812],
+ [0, 0.002442872, 0, 0.01851953],
+ [0, 0, 0, 0],
+ [0, 0.040587082, 1.2e-7, 0.372128258],
+ [0.070834622, 0.010675597, 0.329778828, 0.536478516],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.698053718, 0.939018428],
+ [0.105599999, 0, 0.631752707, 0.849830739],
+ [0.010056818, 0.026673723, 0.049558769, 0.283317459],
+ [0, 0.018592432, 0, 0.170408344],
+ [0, 0.000143263, 0, 0.001696362],
+ [0, 0, 0, 0],
+ [0, 0.028490577, 0, 0.244683543],
+ [0.00705771, 0.017072083, 0.036989255, 0.234185069],
+ [0.06720078, 0.005058938, 0.327610852, 0.492860605],
+ [0.095394596, 0.000581079, 0.605799381, 0.818589968],
+ [0.105599999, 0, 0.679112838, 0.913539261],
+ [0.105599999, 0, 0.697184238, 0.937848809],
+ [0.068555961, 0.002199326, 0.383261678, 0.529125162],
+ [0, 0.038816351, 0, 0.381515636],
+ [0, 0.006720648, 0, 0.051454974],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0.002990371, 0, 0.027060861],
+ [0, 0.021164471, 0, 0.188607563],
+ [0.000168667, 0.040234116, 0.001476839, 0.348551028],
+ [0.010071355, 0.033263156, 0.047770992, 0.400148133],
+ [0.035770619, 0.016074294, 0.188953539, 0.366276847],
+ [0.086364852, 0, 0.443459423, 0.596539483],
+ [0.022110107, 0.024815227, 0.113843437, 0.359958373],
+ [0, 0.029228436, 0, 0.291802069],
+ [0, 0.00089496, 0, 0.008292256],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0]
+ ]
+ },
+ "afterReset": {
+ "activeCells": 0,
+ "samples": [
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0],
+ [0, 0, 0, 0]
+ ]
+ },
+ "penetrations": [0.11721948, 0.084338303, 0.177206854],
+ "steps": 3,
+ "lastSink": 0.177206854
+}
diff --git a/packages/core/__tests__/fluid-particles.spec.ts b/packages/core/__tests__/fluid-particles.spec.ts
new file mode 100644
index 0000000000..b1b44a8794
--- /dev/null
+++ b/packages/core/__tests__/fluid-particles.spec.ts
@@ -0,0 +1,270 @@
+import { describe, expect, it, vi } from "vitest";
+import { FluidParticles3D } from "../src/fluid-particles.js";
+import type { IRendererLike, RendererKind } from "../src/renderer.js";
+
+function renderer(names: string[], kind: RendererKind = "webgpu"): IRendererLike {
+ const canvas = new EventTarget() as HTMLCanvasElement;
+ return {
+ compileAsync: async () => undefined,
+ compute: (node) => names.push((node as { name: string }).name),
+ dispose: () => undefined,
+ domElement: canvas,
+ info: {},
+ kind,
+ raw: {},
+ readback: async () => new ArrayBuffer(0),
+ render: () => undefined,
+ renderOverlay: () => undefined,
+ setOutputNode: () => undefined,
+ setSize: () => undefined,
+ gpuFrameMs: () => undefined,
+ resolveGpuFrame: () => undefined,
+ setResolutionScale: () => undefined,
+ surface: () => ({
+ atFloor: false,
+ drawingBufferHeight: 1,
+ drawingBufferWidth: 1,
+ resolutionScale: 1,
+ sampleCount: 1,
+ scaleSource: "pinned" as const,
+ }),
+ };
+}
+
+describe("FluidParticles3D", () => {
+ it("fails closed for every invalid option", () => {
+ expect(() => new FluidParticles3D({ capacity: 0 })).toThrow("FluidParticles3D.capacity");
+ expect(() => new FluidParticles3D({ capacity: 70000 })).toThrow("FluidParticles3D.capacity");
+ expect(() => new FluidParticles3D({ capacity: 10.5 })).toThrow("FluidParticles3D.capacity");
+ expect(() => new FluidParticles3D({ capacity: 8, spacing: 0 })).toThrow(
+ "FluidParticles3D.spacing",
+ );
+ expect(() => new FluidParticles3D({ capacity: 8, iterations: 9 })).toThrow(
+ "FluidParticles3D.iterations",
+ );
+ expect(() => new FluidParticles3D({ capacity: 8, iterations: -1 })).toThrow(
+ "FluidParticles3D.iterations",
+ );
+ expect(() => new FluidParticles3D({ capacity: 8, viscosity: -1 })).toThrow(
+ "FluidParticles3D.viscosity",
+ );
+ expect(
+ () =>
+ new FluidParticles3D({
+ capacity: 8,
+ bounds: { min: [-1, 0, -1], max: [1, 0.1, 1] },
+ }),
+ ).toThrow("FluidParticles3D.bounds must be at least two spacings wide");
+ expect(() => new FluidParticles3D({ capacity: 8, maxColliders: 0 })).toThrow(
+ "FluidParticles3D.maxColliders",
+ );
+ });
+
+ it("defaults to a fixed cadence, fourteen warmup kernels and no landed readback", () => {
+ const water = new FluidParticles3D({ capacity: 100 });
+ expect(water.processCadence).toBe("fixed");
+ expect(water.warmupNodes).toHaveLength(14);
+ expect(water.used).toBe(0);
+ expect(water.stats).toBeUndefined();
+ expect(water.sample(0, 0).height).toBe(water.bounds.min[1]);
+ });
+
+ it("refuses a renderer without WebGPU compute, and a second renderer", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ expect(() => water.attachRenderer(renderer([], "webgl2"))).toThrow("WebGPU renderer");
+
+ const gpu = renderer([]);
+ water.attachRenderer(gpu);
+ water.attachRenderer(gpu);
+ expect(() => water.attachRenderer(renderer([]))).toThrow(
+ "FluidParticles3D is already attached to a renderer.",
+ );
+ });
+
+ it("fills at most capacity and reports the exact lattice count", () => {
+ const full = new FluidParticles3D({ capacity: 50, spacing: 0.22 });
+ expect(full.fill([0, 0, 0], [2, 2, 2])).toBe(50);
+ expect(full.fill([0, 0, 0], [2, 2, 2])).toBe(0);
+ expect(full.used).toBe(50);
+
+ const small = new FluidParticles3D({ capacity: 50, spacing: 0.22 });
+ expect(small.fill([0, 0, 0], [0.44, 0.44, 0.44])).toBe(27);
+ });
+
+ it("emits into a ring buffer and throttles the queue per step", () => {
+ const ring = new FluidParticles3D({ capacity: 4 });
+ expect(ring.emit([0, 0, 0])).toBe(true);
+ expect(ring.used).toBe(1);
+ for (let spawn = 0; spawn < 5; spawn += 1) expect(ring.emit([0, 0, 0])).toBe(true);
+ expect(ring.used).toBe(4);
+
+ const queued = new FluidParticles3D({ capacity: 8, maxSpawns: 2 });
+ expect(queued.emit([0, 0, 0])).toBe(true);
+ expect(queued.emit([0, 0, 0])).toBe(true);
+ expect(queued.emit([0, 0, 0])).toBe(false);
+ queued.process(renderer([]));
+ expect(queued.emit([0, 0, 0])).toBe(true);
+ });
+
+ it("dispatches nothing while empty, then the documented pass order", () => {
+ const names: string[] = [];
+ const water = new FluidParticles3D({ capacity: 8, iterations: 2 });
+ const gpu = renderer(names);
+ water.attachRenderer(gpu);
+
+ water.process();
+ expect(names).toEqual([]);
+ expect(water.steps).toBe(1);
+
+ water.fill([0, 0, 0], [0.44, 0.44, 0.44]);
+ water.process();
+ expect(names).toEqual([
+ "fluidParticles.inject",
+ "fluidParticles.predict",
+ "fluidParticles.grid.clear",
+ "fluidParticles.grid.build",
+ "fluidParticles.lambda",
+ "fluidParticles.delta",
+ "fluidParticles.apply",
+ "fluidParticles.grid.clear",
+ "fluidParticles.grid.build",
+ "fluidParticles.lambda",
+ "fluidParticles.delta",
+ "fluidParticles.apply",
+ "fluidParticles.grid.clear",
+ "fluidParticles.grid.build",
+ "fluidParticles.velocity",
+ "fluidParticles.stats.clear",
+ "fluidParticles.smooth",
+ "fluidParticles.confine",
+ "fluidParticles.stats.finalize",
+ "fluidParticles.volume",
+ "fluidParticles.columns",
+ ]);
+ expect(water.steps).toBe(2);
+ });
+
+ it("queues four drains and stirs, and validates colliders", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ for (let queue = 0; queue < 4; queue += 1) expect(water.drain([0, 0, 0], [1, 1, 1])).toBe(true);
+ expect(water.drain([0, 0, 0], [1, 1, 1])).toBe(false);
+ for (let queue = 0; queue < 4; queue += 1) expect(water.stir([0, 0, 0], 1, 0.5)).toBe(true);
+ expect(water.stir([0, 0, 0], 1, 0.5)).toBe(false);
+
+ const spheres = Array.from({ length: 9 }, () => ({
+ kind: "sphere" as const,
+ center: [0, 0, 0] as const,
+ radius: 1,
+ }));
+ expect(() => water.setColliders(spheres)).toThrow(
+ "FluidParticles3D.setColliders accepts at most 8.",
+ );
+ expect(() =>
+ water.setColliders([{ kind: "box", center: [0, 0.5, 0], halfExtents: [0.5, 0, 0.5] }]),
+ ).toThrow("FluidParticles3D.collider.halfExtents must be positive.");
+ expect(() =>
+ water.setColliders([
+ {
+ kind: "box",
+ center: [0, 0.5, 0],
+ halfExtents: [0.5, 0.5, 0.5],
+ rotation: [0, 0, 0, 0],
+ },
+ ]),
+ ).toThrow("FluidParticles3D collider rotation must be non-zero.");
+ expect(() =>
+ water.setColliders([
+ { kind: "sphere", center: [0, 0.5, 0], radius: 0.4 },
+ {
+ kind: "box",
+ center: [0, 0.5, 0],
+ halfExtents: [0.5, 0.5, 0.5],
+ rotation: [0, 0, Math.SQRT1_2, Math.SQRT1_2],
+ },
+ ]),
+ ).not.toThrow();
+ });
+
+ it("releases on detach and refuses every mutation and dispatch afterwards", () => {
+ const names: string[] = [];
+ const water = new FluidParticles3D({ capacity: 8 });
+ water.attachRenderer(renderer(names));
+ water.fill([0, 0, 0], [0.44, 0.44, 0.44]);
+
+ water.detach();
+ expect(water.released).toBe(true);
+ expect(() => water.fill([0, 0, 0], [1, 1, 1])).toThrow(
+ "FluidParticles3D cannot fill after release.",
+ );
+ expect(() => water.emit([0, 0, 0])).toThrow("FluidParticles3D cannot emit after release.");
+ expect(() => water.drain([0, 0, 0], [1, 1, 1])).toThrow(
+ "FluidParticles3D cannot drain after release.",
+ );
+ expect(() => water.stir([0, 0, 0], 1, 0.5)).toThrow(
+ "FluidParticles3D cannot stir after release.",
+ );
+ expect(() => water.setColliders([])).toThrow(
+ "FluidParticles3D cannot setColliders after release.",
+ );
+
+ water.process();
+ expect(names).toEqual([]);
+ expect(water.steps).toBe(0);
+ });
+
+ it("defaults to water-like damping, not a jelly", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ expect(water.viscosity).toBeLessThanOrEqual(0.02);
+ expect(water.cohesion).toBeLessThanOrEqual(0.05);
+ expect(water.vorticity).toBeGreaterThanOrEqual(0.01);
+ });
+
+ it("round-trips the live solver knobs and rejects negative ones", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ water.viscosity = 0.5;
+ water.cohesion = 0;
+ water.vorticity = 0.02;
+ water.gravity = 0;
+ expect(water.viscosity).toBe(0.5);
+ expect(water.cohesion).toBe(0);
+ expect(water.vorticity).toBe(0.02);
+ expect(water.gravity).toBe(0);
+
+ expect(() => {
+ water.viscosity = -1;
+ }).toThrow("FluidParticles3D.viscosity");
+ expect(() => {
+ water.cohesion = -1;
+ }).toThrow("FluidParticles3D.cohesion");
+ expect(() => {
+ water.vorticity = -1;
+ }).toThrow("FluidParticles3D.vorticity");
+ expect(() => {
+ water.gravity = -1;
+ }).toThrow("FluidParticles3D.gravity");
+ expect(water.viscosity).toBe(0.5);
+ });
+
+ it("frees every storage buffer on detach", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ water.attachRenderer(renderer([]));
+ const disposed = vi.spyOn(water.positions.value, "dispose");
+ const velocities = vi.spyOn(water.velocities.value, "dispose");
+ water.detach();
+ expect(disposed).toHaveBeenCalledOnce();
+ expect(velocities).toHaveBeenCalledOnce();
+ });
+
+ it("keeps the previous colliders when a later one is invalid, and rejects a NaN rotation", () => {
+ const water = new FluidParticles3D({ capacity: 8 });
+ water.setColliders([{ kind: "sphere", center: [0, 1, 0], radius: 0.3 }]);
+ expect(() =>
+ water.setColliders([
+ { kind: "sphere", center: [1, 1, 0], radius: 0.3 },
+ { kind: "box", center: [0, 0, 0], halfExtents: [1, 1, 1], rotation: [Number.NaN, 0, 0, 1] },
+ ]),
+ ).toThrow("FluidParticles3D.collider.rotation");
+ expect(water.stats).toBeUndefined();
+ expect(water.staleFrames).toBe(0);
+ });
+});
diff --git a/packages/core/__tests__/snow-field.spec.ts b/packages/core/__tests__/snow-field.spec.ts
new file mode 100644
index 0000000000..5f217f94e0
--- /dev/null
+++ b/packages/core/__tests__/snow-field.spec.ts
@@ -0,0 +1,596 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import { Vector3 } from "three";
+import { describe, expect, it } from "vitest";
+import {
+ type ISnowFootprint,
+ type ISnowFootprintSample,
+ SnowField,
+ snowDiscFootprint,
+} from "../src/snow-field.js";
+import { Heightfield } from "../src/world.js";
+
+/**
+ * The fixture was produced by running the original ICEFIELD study's own snow code — the same
+ * functions copied verbatim from lines 85-193 of the supplied document — through one deterministic
+ * scenario. Comparing against it is how the extraction proves it reproduced the source's
+ * footprint state and deposition instead of re-inventing them.
+ */
+interface ISourceSnapshot {
+ readonly activeCells: number;
+ readonly samples: readonly (readonly number[])[];
+}
+
+interface ISourceFixture {
+ readonly afterDepth: ISourceSnapshot;
+ readonly afterRecovery: ISourceSnapshot;
+ readonly afterReset: ISourceSnapshot;
+ readonly afterStamps: ISourceSnapshot;
+ readonly penetrations: readonly number[];
+ readonly points: readonly (readonly [number, number])[];
+ readonly scenario: {
+ readonly depth: number;
+ readonly depthOverride: number;
+ readonly recovery: {
+ readonly deposition: number;
+ readonly dt: number;
+ readonly steps: number;
+ readonly wind: number;
+ };
+ readonly resolution: number;
+ readonly size: number;
+ readonly stamps: readonly {
+ readonly angle: number;
+ readonly hardness: number;
+ readonly mass: number;
+ readonly x: number;
+ readonly z: number;
+ }[];
+ };
+ readonly source: string;
+ readonly steps: number;
+ readonly lastSink: number;
+}
+
+const fixture = JSON.parse(
+ readFileSync(path.join(import.meta.dirname, "fixtures", "snow-field-source.json"), "utf8"),
+) as ISourceFixture;
+
+const CHANNELS = ["indent", "bank", "compaction", "disturbance"] as const;
+/** The study's boot covered 0.074 m² and pushed 80 kg times gravity through it. */
+const SOURCE_BOOT_AREA = 0.074;
+const SOURCE_BOOT_MASS = 80;
+const GRAVITY = 9.81;
+
+function clamp(value: number, minimum: number, maximum: number): number {
+ return Math.max(minimum, Math.min(maximum, value));
+}
+
+function smoothstep(edge0: number, edge1: number, value: number): number {
+ const t = clamp((value - edge0) / (edge1 - edge0), 0, 1);
+ return t * t * (3 - 2 * t);
+}
+
+/** The study's terrain function, kept here so the fixture can rebuild the same ground. */
+function sourceTerrain(x: number, z: number): number {
+ const radius = Math.hypot(x, z);
+ const hills =
+ smoothstep(14, 42, radius) * (0.8 + 0.85 * Math.sin(x * 0.085 + 1) * Math.cos(z * 0.075));
+ return (
+ 0.2 * Math.sin(x * 0.16) * Math.cos(z * 0.12) +
+ 0.09 * Math.sin(x * 0.39 + z * 0.21) +
+ 0.07 * Math.cos(z * 0.53 - x * 0.24) +
+ hills
+ );
+}
+
+const ZERO_SAMPLE: ISnowFootprintSample = {
+ bank: 0,
+ coverage: 0,
+ disturbance: 0,
+ relief: 0,
+ shape: 0,
+};
+
+/**
+ * The study's rounded boot sole with its chevrons, groove and raised rim, expressed through the
+ * generalized footprint contract. Boot design belongs to a game; this copy exists only so the
+ * source fixture can be reproduced exactly.
+ */
+function sourceBootFootprint(): ISnowFootprint {
+ return {
+ extent: 0.48,
+ sample: (x, z) => {
+ const width = 0.13 + smoothstep(-0.22, 0.15, z) * 0.013;
+ const qx = Math.abs(x) - (width - 0.052);
+ const qz = Math.abs(z) - (0.255 - 0.052);
+ const sdf =
+ Math.hypot(Math.max(qx, 0), Math.max(qz, 0)) + Math.min(Math.max(qx, qz), 0) - 0.052;
+ if (sdf > 0.105) return ZERO_SAMPLE;
+ const tread = Math.max(0, Math.cos(z * 83 + Math.abs(x) * 30)) ** 8;
+ const groove = (1 - smoothstep(0.009, 0.023, Math.abs(x))) * 0.004;
+ const rim = sdf > 0 ? Math.exp(-(((sdf - 0.027) / 0.027) ** 2)) : 0;
+ return {
+ bank: rim * (0.78 + 0.22 * Math.sin(x * 94 + z * 63)),
+ coverage: 1 - smoothstep(-0.038, 0.008, sdf),
+ disturbance: rim * 0.5,
+ relief: 0.012 * tread + groove,
+ shape: 0.9 + 0.1 * smoothstep(-0.2, 0.15, z),
+ };
+ },
+ };
+}
+
+function fixtureTerrain(): Heightfield {
+ return Heightfield.fromSampler({
+ columns: fixture.scenario.resolution,
+ depth: fixture.scenario.size,
+ origin: { x: 0, z: 0 },
+ rows: fixture.scenario.resolution,
+ sampleHeight: sourceTerrain,
+ width: fixture.scenario.size,
+ });
+}
+
+function snapshot(snow: SnowField): ISourceSnapshot {
+ const samples = fixture.points.map(([x, z]) => {
+ const value = snow.sample(x, z);
+ return [value.indent, value.bank, value.compaction, value.disturbance];
+ });
+ return { activeCells: snow.activeCells, samples };
+}
+
+/** Worst per-channel difference over every fixture point, plus an exact active-cell comparison. */
+function worstDifference(actual: ISourceSnapshot, expected: ISourceSnapshot): number {
+ expect(actual.activeCells, "active cells").toBe(expected.activeCells);
+ let worst = 0;
+ for (let index = 0; index < expected.samples.length; index += 1) {
+ const actualSample = actual.samples[index] as readonly number[];
+ const expectedSample = expected.samples[index] as readonly number[];
+ for (let channel = 0; channel < 4; channel += 1) {
+ const difference = Math.abs(
+ (actualSample[channel] as number) - (expectedSample[channel] as number),
+ );
+ if (difference > worst) worst = difference;
+ if (difference > 1e-9)
+ throw new Error(
+ `${CHANNELS[channel]} at fixture point ${String(index)} (${String(
+ (fixture.points[index] as readonly number[])[0],
+ )}, ${String((fixture.points[index] as readonly number[])[1])}) differs by ${String(
+ difference,
+ )}.`,
+ );
+ }
+ }
+ return worst;
+}
+
+function replaySourceScenario(): SnowField {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: fixture.scenario.depth });
+ for (const stamp of fixture.scenario.stamps) {
+ snow.stamp({
+ area: SOURCE_BOOT_AREA,
+ footprint: sourceBootFootprint(),
+ hardness: stamp.hardness,
+ load: stamp.mass * GRAVITY,
+ rotation: stamp.angle,
+ x: stamp.x,
+ z: stamp.z,
+ });
+ }
+ return snow;
+}
+
+/** An axis-aligned box contact: a crate corner, a plank, a platform edge. */
+function boxFootprint(halfWidth: number, halfLength: number): ISnowFootprint {
+ const edge = 0.02;
+ return {
+ extent: Math.hypot(halfWidth, halfLength) + edge,
+ sample: (x, z) => {
+ const inside = Math.max(Math.abs(x) - halfWidth, Math.abs(z) - halfLength);
+ if (inside > edge) return ZERO_SAMPLE;
+ const coverage = 1 - smoothstep(-edge, edge, inside);
+ return {
+ bank: inside > 0 ? 1 - inside / edge : 0,
+ coverage,
+ disturbance: coverage,
+ relief: 0,
+ shape: 1,
+ };
+ },
+ };
+}
+
+describe("SnowField source regression", () => {
+ it("reproduces the source study's footprint state through the public field", () => {
+ const snow = replaySourceScenario();
+ expect(snow.steps).toBe(fixture.steps);
+ expect(worstDifference(snapshot(snow), fixture.afterStamps)).toBeLessThan(1e-9);
+ });
+
+ it("reaches the source's penetration for the same mass, area and hardness", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: fixture.scenario.depth });
+ const measured = fixture.scenario.stamps.map((stamp) =>
+ snow.stamp({
+ area: SOURCE_BOOT_AREA,
+ footprint: sourceBootFootprint(),
+ hardness: stamp.hardness,
+ load: stamp.mass * GRAVITY,
+ rotation: stamp.angle,
+ x: stamp.x,
+ z: stamp.z,
+ }),
+ );
+ for (let index = 0; index < measured.length; index += 1)
+ expect(measured[index]).toBeCloseTo(fixture.penetrations[index] as number, 9);
+ expect(snow.steps).toBe(fixture.steps);
+ expect(snow.lastSink).toBeCloseTo(fixture.lastSink, 9);
+ });
+
+ it("recovers, retunes depth and resets exactly as the source did", () => {
+ const snow = replaySourceScenario();
+ const { deposition, dt, steps, wind } = fixture.scenario.recovery;
+ for (let tick = 0; tick < steps; tick += 1) snow.recover(dt, deposition, wind);
+ expect(worstDifference(snapshot(snow), fixture.afterRecovery)).toBeLessThan(1e-9);
+
+ snow.setDepth(fixture.scenario.depthOverride);
+ expect(worstDifference(snapshot(snow), fixture.afterDepth)).toBeLessThan(1e-9);
+
+ snow.reset();
+ expect(worstDifference(snapshot(snow), fixture.afterReset)).toBeLessThan(1e-9);
+ expect(snow.steps).toBe(0);
+ expect(snow.lastSink).toBe(0);
+ expect(snow.activeCells).toBe(0);
+ });
+});
+
+describe("SnowField canonical surface", () => {
+ it("writes terrain plus depth plus bank minus indentation into the shared heightfield", () => {
+ const field = fixtureTerrain();
+ const pristine = fixtureTerrain();
+ const snow = new SnowField({ field, depth: 0.28 });
+ snow.stamp({
+ area: 0.05,
+ footprint: sourceBootFootprint(),
+ load: 900,
+ x: 0.4,
+ z: -0.2,
+ });
+ for (let index = 0; index < 200; index += 1) {
+ const x = -0.6 + index * 0.008;
+ const sample = snow.sample(x, 0.05);
+ expect(snow.heightAt(x, 0.05)).toBeCloseTo(
+ pristine.heightAt(x, 0.05) + 0.28 + sample.bank - sample.indent,
+ 5,
+ );
+ }
+ });
+
+ it("keeps rendered vertices and collider samples within a millimetre of canonical heights", () => {
+ const field = fixtureTerrain();
+ const snow = new SnowField({ field, depth: 0.28 });
+ const geometry = field.toGeometry();
+ const centre = (field.rows >> 1) * field.columns + (field.columns >> 1);
+ const before = geometry.getAttribute("position").getY(centre);
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.3), load: 2000, x: 0, z: 0 });
+ const region = snow.dirtyRegion;
+ expect(region).toBeDefined();
+ field.refreshGeometry(geometry, region);
+ const positions = geometry.getAttribute("position");
+ const normals = geometry.getAttribute("normal");
+ const collider = field.toColliderHeights();
+ expect(Math.abs(positions.getY(centre) - before)).toBeGreaterThan(0.02);
+
+ let worst = 0;
+ for (let row = 0; row < field.rows; row += 1) {
+ const worldZ = field.origin.z - field.depth / 2 + (row * field.depth) / (field.rows - 1);
+ for (let column = 0; column < field.columns; column += 1) {
+ const index = row * field.columns + column;
+ const worldX =
+ field.origin.x - field.width / 2 + (column * field.width) / (field.columns - 1);
+ const canonical = snow.heightAt(worldX, worldZ);
+ worst = Math.max(worst, Math.abs(positions.getY(index) - canonical));
+ worst = Math.max(
+ worst,
+ Math.abs((collider[column * field.rows + row] as number) - canonical),
+ );
+ worst = Math.max(worst, Math.abs(normals.getY(index) - snow.normalAt(worldX, worldZ).y));
+ }
+ }
+ expect(worst).toBeLessThan(0.001);
+ });
+
+ it("keeps a windowed geometry refresh's bounds around every vertex", () => {
+ // Flat, so the stamp's floor is below every height the bounds were first measured on.
+ const field = Heightfield.fromSampler({
+ columns: 65,
+ depth: 4,
+ origin: { x: 0, z: 0 },
+ rows: 65,
+ sampleHeight: () => 0,
+ width: 4,
+ });
+ const snow = new SnowField({ field, depth: 0.28 });
+ const geometry = field.toGeometry();
+ field.refreshGeometry(geometry);
+ snow.stamp({ area: 0.004, footprint: snowDiscFootprint(0.3), load: 5000, x: 0, z: 0 });
+ field.refreshGeometry(geometry, snow.dirtyRegion);
+ const box = geometry.boundingBox;
+ const sphere = geometry.boundingSphere;
+ if (box === null || sphere === null) throw new Error("refreshGeometry left no bounds");
+ const position = geometry.getAttribute("position");
+ const point = new Vector3();
+ for (let index = 0; index < position.count; index += 1) {
+ point.fromBufferAttribute(position, index);
+ expect(box.containsPoint(point)).toBe(true);
+ expect(sphere.containsPoint(point)).toBe(true);
+ }
+ });
+
+ it("tracks a monotonic version and the dirty window of the surface it changed", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ const start = snow.version;
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: -1, z: 2 });
+ expect(snow.version).toBe(start + 1);
+ const region = snow.dirtyRegion;
+ expect(region?.columns).toBeLessThan(snow.columns);
+ expect(region?.rows).toBeLessThan(snow.rows);
+ expect(region?.column).toBeGreaterThan(0);
+ expect(region?.row).toBeGreaterThan(0);
+ });
+
+ it("copies a channel window that matches sample at every cell, and rejects a window outside", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.3), load: 2000, x: 0, z: 0 });
+ const region = snow.dirtyRegion;
+ if (region === undefined) throw new Error("the stamp reported no window");
+ const indent = snow.copyChannel("indent", region);
+ const compaction = snow.copyChannel("compaction", region, new Float32Array(1));
+ let worst = 0;
+ for (let row = 0; row < region.rows; row += 1) {
+ for (let column = 0; column < region.columns; column += 1) {
+ const x = snow.minimumX + (region.column + column) * snow.cellWidth;
+ const z = snow.minimumZ + (region.row + row) * snow.cellDepth;
+ const sample = snow.sample(x, z);
+ const index = row * region.columns + column;
+ worst = Math.max(worst, Math.abs((indent[index] as number) - sample.indent));
+ worst = Math.max(worst, Math.abs((compaction[index] as number) - sample.compaction));
+ }
+ }
+ expect(worst).toBeLessThan(1e-6);
+ expect(Math.max(...indent)).toBeGreaterThan(0.02);
+ expect(() =>
+ snow.copyChannel("indent", { column: snow.columns - 2, columns: 4, row: 0, rows: 1 }),
+ ).toThrow(/inside the field/);
+ });
+
+ it("hands a renderer the union of every window written since it last looked", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ snow.takeDirtyRegion();
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: -1, z: 2 });
+ const first = snow.dirtyRegion;
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.15), load: 400, x: 1, z: -2 });
+ const second = snow.dirtyRegion;
+ const union = snow.takeDirtyRegion();
+ if (first === undefined || second === undefined || union === undefined)
+ throw new Error("each stamp must report a window");
+ for (const part of [first, second]) {
+ expect(union.column).toBeLessThanOrEqual(part.column);
+ expect(union.row).toBeLessThanOrEqual(part.row);
+ expect(union.column + union.columns).toBeGreaterThanOrEqual(part.column + part.columns);
+ expect(union.row + union.rows).toBeGreaterThanOrEqual(part.row + part.rows);
+ }
+ expect(snow.takeDirtyRegion()).toBeUndefined();
+ });
+});
+
+describe("SnowField contact profiles", () => {
+ it("prints a boot and a disc differently, and honours rotation", () => {
+ const boot = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ const disc = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ const load = 80 * GRAVITY;
+ boot.stamp({ area: SOURCE_BOOT_AREA, footprint: sourceBootFootprint(), load, x: 0, z: 0 });
+ disc.stamp({
+ area: SOURCE_BOOT_AREA,
+ footprint: snowDiscFootprint(0.153),
+ load,
+ x: 0,
+ z: 0,
+ });
+
+ // A disc is radially symmetric; the boot is long in z and narrow in x.
+ expect(disc.sample(0.13, 0).indent).toBeCloseTo(disc.sample(0, 0.13).indent, 5);
+ expect(boot.sample(0, 0.2).indent).toBeGreaterThan(boot.sample(0.2, 0).indent + 0.01);
+ expect(boot.sample(0, 0).indent).toBeGreaterThan(0.02);
+
+ const turned = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ turned.stamp({
+ area: SOURCE_BOOT_AREA,
+ footprint: sourceBootFootprint(),
+ load,
+ rotation: Math.PI / 2,
+ x: 0,
+ z: 0,
+ });
+ expect(turned.sample(0.2, 0).indent).toBeGreaterThan(turned.sample(0, 0.2).indent + 0.01);
+ expect(turned.sample(0.2, 0).indent).toBeCloseTo(boot.sample(0, 0.2).indent, 5);
+ });
+
+ it("responds to pressure rather than to a fixed boot constant", () => {
+ const depth = 0.3;
+ const load = 80 * GRAVITY;
+ const concentrated = new SnowField({ field: fixtureTerrain(), depth });
+ const spread = new SnowField({ field: fixtureTerrain(), depth });
+ const light = new SnowField({ field: fixtureTerrain(), depth });
+ concentrated.stamp({
+ area: 0.02,
+ footprint: snowDiscFootprint(0.08),
+ load,
+ x: 0,
+ z: 0,
+ });
+ spread.stamp({ area: 0.4, footprint: snowDiscFootprint(0.36), load, x: 0, z: 0 });
+ light.stamp({ area: 0.02, footprint: snowDiscFootprint(0.08), load: load / 8, x: 0, z: 0 });
+
+ expect(concentrated.sample(0, 0).indent).toBeGreaterThan(spread.sample(0, 0).indent + 0.05);
+ expect(concentrated.sample(0, 0).indent).toBeGreaterThan(light.sample(0, 0).indent + 0.02);
+ expect(spread.activeCells).toBeGreaterThan(concentrated.activeCells);
+ });
+
+ it("reaches the same indentation however the contact time is split", () => {
+ const depth = 0.3;
+ const load = 600;
+ const once = new SnowField({ field: fixtureTerrain(), depth });
+ const many = new SnowField({ field: fixtureTerrain(), depth });
+ once.stamp({
+ area: 0.03,
+ duration: 0.3,
+ footprint: snowDiscFootprint(0.12),
+ load,
+ x: 0,
+ z: 0,
+ });
+ for (let tick = 0; tick < 18; tick += 1)
+ many.stamp({
+ area: 0.03,
+ duration: 0.3 / 18,
+ footprint: snowDiscFootprint(0.12),
+ load,
+ x: 0,
+ z: 0,
+ });
+ expect(many.sample(0, 0).indent).toBeCloseTo(once.sample(0, 0).indent, 6);
+ expect(many.sample(0.1, 0.05).indent).toBeCloseTo(once.sample(0.1, 0.05).indent, 6);
+
+ // A resting contact that has already yielded does not keep sinking without limit.
+ many.stamp({
+ area: 0.03,
+ duration: 5,
+ footprint: snowDiscFootprint(0.12),
+ load,
+ x: 0,
+ z: 0,
+ });
+ expect(many.sample(0, 0).indent).toBeLessThan(depth * 0.88);
+ });
+
+ it("presses any bounded shape, not just a boot: a rotated box leaves a rectangular print", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ snow.stamp({
+ area: 0.6,
+ footprint: boxFootprint(0.5, 0.2),
+ load: 300,
+ x: 0,
+ z: 0,
+ });
+ expect(snow.sample(0.45, 0).indent).toBeGreaterThan(0.01);
+ expect(snow.sample(0, 0.15).indent).toBeGreaterThan(0.01);
+ expect(snow.sample(0.45, 0.3).indent).toBeLessThan(0.001);
+ expect(snow.sample(0.7, 0).indent).toBeLessThan(0.001);
+ });
+
+ it("stamps a rolling sphere as connected track instead of one repeated hole", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ const footprint = snowDiscFootprint(0.25);
+ for (let step = 0; step < 12; step += 1)
+ snow.stamp({ area: 0.2, footprint, load: 10 * GRAVITY, x: -1 + step * 0.15, z: 0.5 });
+ for (let step = 0; step < 12; step += 1)
+ expect(snow.sample(-1 + step * 0.15, 0.5).indent).toBeGreaterThan(0.01);
+ expect(snow.sample(-1, 1.5).indent).toBeLessThan(0.001);
+ });
+});
+
+describe("SnowField validation", () => {
+ it("rejects malformed contacts and dimensions before it mutates anything", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ const footprint = snowDiscFootprint(0.2);
+ const version = snow.version;
+ const contact = { area: 0.1, footprint, load: 100, x: 0, z: 0 };
+ expect(() => snow.stamp({ ...contact, area: 0 })).toThrow(/area/);
+ expect(() => snow.stamp({ ...contact, area: Number.NaN })).toThrow(/area/);
+ expect(() => snow.stamp({ ...contact, load: -1 })).toThrow(/load/);
+ expect(() => snow.stamp({ ...contact, x: Number.POSITIVE_INFINITY })).toThrow(/x/);
+ expect(() => snow.stamp({ ...contact, duration: -1 })).toThrow(/duration/);
+ expect(() =>
+ snow.stamp({ ...contact, footprint: { extent: 0, sample: () => ZERO_SAMPLE } }),
+ ).toThrow(/extent/);
+ expect(() =>
+ snow.stamp({
+ ...contact,
+ footprint: { extent: 1000, sample: () => ZERO_SAMPLE },
+ }),
+ ).toThrow(/cells/);
+ expect(() => snow.recover(-1, 0.1, 0.1)).toThrow(/recovery seconds/);
+ expect(() => snow.recover(1, -1, 0.1)).toThrow(/deposition/);
+ expect(() => snow.setDepth(-0.1)).toThrow(/depth/);
+ expect(() => new SnowField({ field: fixtureTerrain(), depth: Number.NaN })).toThrow(/depth/);
+ expect(snow.version).toBe(version);
+ });
+
+ it("keeps Heightfield's error contract for heightAt and normalAt, and returns zeros in sample", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ expect(() => snow.heightAt(99, 0)).toThrow(/outside its resident region/);
+ expect(() => snow.normalAt(0, -99)).toThrow(/outside its resident region/);
+ expect(snow.sample(99, 99)).toEqual({ bank: 0, compaction: 0, disturbance: 0, indent: 0 });
+ });
+
+ it("reports a stamp entirely outside the snowfield as a no-op", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ const version = snow.version;
+ expect(
+ snow.stamp({
+ area: 0.02,
+ footprint: snowDiscFootprint(0.2),
+ load: 800,
+ x: 40,
+ z: 40,
+ }),
+ ).toBe(0);
+ expect(snow.version).toBe(version);
+ expect(snow.steps).toBe(0);
+ });
+
+ it("clips a straddling contact at the boundary instead of wrapping or throwing", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ const edge = snow.minimumX + 0.1;
+ expect(
+ snow.stamp({
+ area: 0.02,
+ footprint: snowDiscFootprint(0.3),
+ load: 800,
+ x: edge,
+ z: 0,
+ }),
+ ).toBeGreaterThan(0);
+ expect(snow.sample(snow.minimumX + 0.01, 0).indent).toBeGreaterThan(0.01);
+ expect(() => snow.sample(snow.minimumX - 1, 0)).not.toThrow();
+ });
+
+ it("treats zero depth as bare ground with no indentation at all", () => {
+ const field = fixtureTerrain();
+ const pristine = fixtureTerrain();
+ const snow = new SnowField({ field, depth: 0 });
+ expect(
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.2), load: 900, x: 0, z: 0 }),
+ ).toBe(0);
+ expect(snow.sample(0, 0).indent).toBe(0);
+ expect(snow.heightAt(0.5, 0.5)).toBeCloseTo(pristine.heightAt(0.5, 0.5), 6);
+ });
+
+ it("buries indentation when depth shrinks and keeps tracks when it grows", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.3 });
+ snow.stamp({ area: 0.02, footprint: snowDiscFootprint(0.25), load: 1200, x: 0, z: 0 });
+ const deep = snow.sample(0, 0).indent;
+ snow.setDepth(0.06);
+ expect(snow.sample(0, 0).indent).toBeLessThan(deep);
+ expect(snow.sample(0, 0).indent).toBeLessThanOrEqual(0.06 * 0.88 + 1e-6);
+ snow.setDepth(0.4);
+ expect(snow.sample(0, 0).indent).toBeGreaterThan(0);
+ });
+
+ it("reports the bytes it retains", () => {
+ const snow = new SnowField({ field: fixtureTerrain(), depth: 0.28 });
+ const cells = snow.columns * snow.rows;
+ expect(snow.memoryBytes).toBe(cells * 5 * Float32Array.BYTES_PER_ELEMENT);
+ });
+});
diff --git a/packages/core/__tests__/three-shadow-override-cache.spec.ts b/packages/core/__tests__/three-shadow-override-cache.spec.ts
index 4727ce5631..6b1b932fdb 100644
--- a/packages/core/__tests__/three-shadow-override-cache.spec.ts
+++ b/packages/core/__tests__/three-shadow-override-cache.spec.ts
@@ -1,4 +1,4 @@
-import { BoxGeometry, Camera, Mesh, Scene } from "three";
+import { BoxGeometry, type BufferGeometry, Camera, Mesh, Scene } from "three";
// @ts-expect-error Three's private renderer module has no public declaration; this test must exercise it directly.
import RenderObjects from "three/src/renderers/common/RenderObjects.js";
import {
@@ -36,7 +36,12 @@ interface IShadowRendererStub {
readonly _cacheShadowNodes: WeakMap