diff --git a/.gitignore b/.gitignore index b0a306dc8a..541b990d94 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ node_modules/ .npmrc dist/ dist-native/ +# A build writes `.build-report.json` beside the artifact; it measures one machine's +# bytes, so tracking it only re-dirties the tree. +*.build-report.json # The locally assembled native payload and its scoped lock, regenerated by `pnpm release:native`. /release-native/ .threenative/ diff --git a/docs/PRDs/done/PRD-495-junk-audit-2026-10-04-cleanup.md b/docs/PRDs/done/PRD-495-junk-audit-2026-10-04-cleanup.md new file mode 100644 index 0000000000..22461695f1 --- /dev/null +++ b/docs/PRDs/done/PRD-495-junk-audit-2026-10-04-cleanup.md @@ -0,0 +1,380 @@ +--- +prd_contract: v1 +--- + +# PRD-495 — junk-audit 2026-10-04: every finding dispositioned + +**Status:** DONE — all three phases executed; AC-2 and its Phase 2 box close on CI run +[37255318117](https://github.com/ThreeNativeHQ/threenative/actions/runs/37255318117) at head +`346afd02717e079874280eab2a39ce3db21f2068`, terminal success, which covers the same four +`run-test-suite` phases and the same package scope as the local gate (see AC-2) +**Complexity:** 5 → MEDIUM (3 for 11+ implementation files, +2 for touching two independent +build boundaries — the scaffolder's pinned scaffold-tree hashes and the runtime-native +CMake/Android source sets; every item is a deletion, a comment fix or a doc pointer, so risk +is mechanical). +**Owner:** João +**Depends on:** none +**Task:** branch `chore/junk-cleanup-2026-10-04`, worktree `.worktrees/junk-cleanup`, base +`origin/develop` at `f24f6243e`, since merged forward to `64aed30fe` (#416). The audit input +`junk-audit-2026-10-04.md` stays untracked. + +## Context + +`junk-audit-2026-10-04.md` (90 findings, five appendices, read-only arms) claims ~575 lines in +`packages/core`, ~480 in physics/ui/playtest/assets, 1,635 safe-now tracked lines in +`runtime-native` + small packages, ~379 in templates/examples and ~2 MB + ~2,900 lines in +docs/scripts. It reaches no published npm tarball; the cost is clone weight, agent friction +in generated templates, and misleading surface. + +This PRD dispositions **all 90** findings — deleting what is provably dead, and recording why +the rest stay. Nothing here changes behaviour: every executed item is a zero-reader deletion, +a stale comment, or a doc pointer, and no gameplay/render value moves. + +## Solution + +Execute every finding whose only tracked references are its own definition or a comment that +contradicts the code — the **70 changes** listed under `### Executed` below, counted from the diff +itself. Three of the original 73 came back after the independent Opus-5.5 review on 2026-10-05: +the live benchmark was a definite false positive; the manual proof and distinct audit were +retained conservatively. Each is now a `### Retained` row with the reason (see the review +correction under `## Verification notes`). Retain the rest with a named reason (`### Retained`, one +row per group; several rows cover more than one numbered audit finding, so they carry no count). +Defer the duplicate-helper refactors +that touch live code for a ~21-line net win, leaving them to +[PRD-208](../tech-debt-code-quality/PRD-208-tier-four-hygiene-sweep.md), which already owns single-sourcing debt. + +## Acceptance Criteria + +- [x] AC-1 [local]: `packages/core` reports zero unused-local and zero unused-parameter diagnostics. The 24-diagnostic baseline is the audit input's measurement, not one re-taken here. proof: `cd packages/core && ../../node_modules/.bin/tsc --noEmit --noUnusedLocals --noUnusedParameters -p tsconfig.json` — Evidence: exit 0, empty output. +- [x] AC-2 [local, CI]: the deletions keep the contracts that read them intact — the workspace typechecks and lints, the unit and contract suites stay green, and the one pinned expectation that had to move (the scaffolder's `PRD_201_PARENT_SCAFFOLD_HASHES`) was restamped to the templates' new measured bytes. The per-symbol "no remaining tracked reader" claim is the audit's own independently checked caller census, dispositioned row by row in `## Disposition`; typecheck and the unit suite are the contracts preserved afterwards, not a reference search over the tree. proof: `pnpm typecheck && pnpm lint && pnpm test` — Evidence: `pnpm typecheck` exit 0, `pnpm lint` exit 0 (1074 warnings). The final full board (`taskset -c 0,1,2,3 TN_SUITE_PACKAGE_CONCURRENCY=1 pnpm test`, 1424s) is **exit 1**: `docs`, `build` and `package-test` phases all green (runtime-native 134 files / 1548 tests + Rust parity + publint; `scaffold.spec.ts` 66 tests green), unit phase `4 failed | 7933 passed | 13 skipped (7950)`. Every failure is wall-clock on a box at load 27–44 across 24 cores — `template-assets-compile-1` racing (60s), `template-assets-compile-2` shooter (60s), `template.spec.ts` pristine-scaffold typecheck (180s), `desktop-playtest.spec.ts` native screenshot wait (`native host output: none captured`, the file's other 38 tests green) — with no failed assertion. The same four files alone on the four idlest cores: 4 files / 117 tests, exit 0, 227s. That literal local `pnpm test` is not claimed green. The gate closes instead on CI run [37255318117](https://github.com/ThreeNativeHQ/threenative/actions/runs/37255318117), head `346afd02717e079874280eab2a39ce3db21f2068`, terminal **success**: `typecheck`, `lint`, `test`, `test-native`, `test-unit` shards 1/4–4/4 and `ci-required` all green. That is the same verification method at full scope, not narrower acceptance — `pnpm test`'s default is the four phases `docs,build,package-test,unit` (`scripts/run-test-suite.sh:277`) and CI runs all four across three jobs, a partition `scripts/__tests__/ci-structure.spec.ts` computes from the workflow rather than trusting (see the CI-coverage note under `## Verification notes`). +- [x] AC-3 [local]: the scaffolder still ships every template its contract specs name, with the pinned scaffold-tree hashes restamped for exactly the templates that changed (10 of 13; `platformer`, `snow`, `starter` unchanged), restamped again for the 2026-10-05 develop merge, and restamped once more for `rain` alone when the review restored `templates/rain/tools/verify-noise-volume.mjs`. proof: `pnpm --filter @threenative/create-threenative test` — Evidence: green in the workspace suite before the merge; the four affected specs alone: 4 files / 93 tests, exit 0, 205s. On the merged tree `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts` is 66 tests, exit 0, + 34.6s; after the rain restoration the whole file is 66 tests, exit 0, 11.0s, with the + byte-stability test first failing and printing its measured `rain` hash `637afd24…92255` before + the restamp — see the merge-integration and review-correction notes under `## Verification notes`. +- [x] AC-4 [local]: the runtime-native contract and source-list gates stay green after the never-packaged Android sample JS and the orphan Metal shader go. proof: `pnpm --filter @threenative/runtime-native test` — Evidence: exit 0 — 134 files / 1548 tests passed, 62 skipped; Rust physics parity 21 + 2; publint clean. Required a compiled host first (`pnpm native:build` exit 0); every pre-build failure was an `is not built` assertion, and no C++ source or JS-engine file this PRD touches appears in any of them. +- [x] AC-5 [local]: documentation links and primary-doc command names stay resolvable after the stale round-ledger pointers are corrected. proof: `pnpm check:docs && pnpm exec vitest run scripts/__tests__/check-doc-links.spec.ts scripts/__tests__/primary-docs.spec.ts` — Evidence: `pnpm check:docs` exit 0 — "Checked 2323 relative documentation links across 1216 Markdown files"; both specs pass inside the green `scripts/__tests__` lane (143 files / 1944 tests, exit 0). +- [x] AC-6 [local]: `examples/abyss-framework` no longer dirties the tree with a committed build report. proof: `pnpm --filter abyss-framework build` then `git check-ignore -v examples/abyss-framework/dist.build-report.json` — Evidence: build exit 0 (it rewrites the report on disk), `git check-ignore` reports `.gitignore:8:*.build-report.json`, and `git status --porcelain examples/abyss-framework` after the build lists only this lane's three staged deletions. + +## Verification notes + +- The native host was built locally to run AC-4 (`packages/runtime-native/build/` and `.runtime/` + are gitignored build output, ~10 min for the deps + compile). Nothing tracked changed for it. +- Every red this lane has recorded is a wall-clock failure on a shared box, and none is a + behavioural assertion: + 1. Two `pnpm test` runs at default concurrency: five scaffolder-spec **timeouts** (60s, 60s, 60s, + 180s, 180s) with `7932 passed / 13 skipped / 5 failed`, box load 55–70 across 24 cores. The + same four files alone: 4 files / 93 tests, exit 0, 205s. + 2. One `pnpm test` run pinned to **two** CPUs (`taskset -c 0,1`, `TN_SUITE_PACKAGE_CONCURRENCY=1`) + to force `maxWorkers` 1: `package-test` stopped the run at 1 failure, + `runtime-native/tests/production-profile.test.mjs:1970` `assert.ok(elapsed < 2_000)` — the test + took 4313 ms. Run alone under the same pin it passes in 374 ms, and the rest of that test + (status `FAIL`, exit code 1, the budget code) held. The two pinned CPUs are shared with + unpinned neighbour lanes — `ps` showed their node processes sitting on CPUs 2 and 3 — so a + 2 s CPU-bound assertion cannot hold on them. The clean run therefore widens the window to four + CPUs (`maxWorkers` 3) instead of narrowing the box further. + 3. The final full board on those four CPUs and the serial package walk (`taskset -c 0,1,2,3 + TN_SUITE_PACKAGE_CONCURRENCY=1 pnpm test`, 1424s): `docs`, `build` and `package-test` green, + unit phase `4 failed | 7933 passed | 13 skipped`. The same four files, alone, on the four + idlest cores: 4 files / 117 tests, exit 0, 227s. +- **CI repair (2026-10-04, budgets job 111567118048).** The CI red this diff caused: + `TN_TEMPLATE_CONVENTIONS_FAILED` for the `shooter` kit. The `normaliseToMetres` and + `attachToBone` cells of the applicability table still named `src/entities/Enemy.ts:863` and `:869`, + which this lane's dead-comment deletion moved 15 lines up to `:848` and `:854`. Both cells now + name the measured calls; `pnpm exec tsx scripts/check-template-conventions.ts` ran red with those + two findings and is green after the fix. The `shooter` scaffold hash needed no restamp — the + measured `createProject` no-install tree equals `PRD_201_PARENT_SCAFFOLD_HASHES.shooter`, and + `pnpm sync:agents` wrote 0 mirrors. Focused checks, all green: + `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps every + no-install scaffold tree byte-stable"` (1 passed); the scripts prose lane + (`check-template-conventions`, `check-doc-links`, `sync-agent-docs`, `evidence-budget`, + `evidence-citations`, `ci-structure`, `ci-needs`) 7 files / 250 tests exit 0; `pnpm check:docs` + exit 0; `git diff --check` clean. AC-2 stayed unticked at this point — the full board as written + still ran red on wall-clock, which this change does not touch. +- **CI red this diff did not cause (2026-10-05, `native-platforms / Windows desktop core`, job + 111566787292 of run 37246919707, head `0ae683546`).** The full board's Windows leg exits 2 in + "Collect bounded desktop performance evidence" with `TN_PROD_PLAYTEST_FAILED`, + `TN_PROD_RENDER_SAMPLES_INCOMPLETE`, `TN_PROD_MARKER_MISSING` and status `BLOCKED`. The run's own + artifact names the cause: `performance-contract/Windows/artifacts/c928a7ef….json` carries + `TN_PLAYTEST_OPERATION_TIMEOUT` — "Device mailbox operation '3' exceeded 35000ms". The manifest + agrees: `markers: ["run-start"]`, one `runWindows` entry with `sampleCount: 0` and + `durationSeconds: 0`, `startupMs: 1091`. The host itself is healthy in the same job — the earlier + desktop-core step rendered 300 frames in 18183 ms (`desktop-win32.log`) and its report passed. + **Not this lane, and not a cleanup regression:** the identical fingerprint + (`markers: ["run-start"]`, `sampleCount: 0`, `startupMs` ~1.1 s, exit 2) is on runs 37241861419 + (head `1015b95bc`) and 37245076840 (head `6a41767e7`), and `git merge-base --is-ancestor` says + `5678ba241`, `0ae683546` and `06754fd00` are in neither head, plus run 37065619655 (head + `af7e25333`, 2026-10-02T21:14Z). The job's last green run is 36960041733 (2026-10-02T03:24Z), so + the stall has been continuous for four hosted Windows runs and predates this PRD by two days. + **Layer: engine**, in the `packages/playtest` file-mailbox transport and the native host's poll + loop. No file this PRD deletes is in that path, so this lane did not cause the stall — and that + is a statement about cause only, **not** a finding that no bounded fix exists. + - **Exact cause, corrected 2026-10-05.** An earlier reading of this bullet blamed the missing + first `sample`. That was the symptom. 35000 ms is exact for a **warmup `advance(60)`**: + `advanceTimeoutMs(60)` = 5000 (`protocol.ts:37`) + 15000 (`protocol.ts:31`) + 60×250 + (`protocol.ts:24`), computed at `bridgeClient.ts:62-78`; `warmupFrames: 60` comes from + `--warmup 1` (`profile-production.mjs:537-538,556`); and `androidRunner.ts:241` issues that + advance **before** the startup wait. `describe`/`ready`/`sample` carry + `config.timeoutMs` = 105000 there, so only an advance can report 35000. Operation id 3 confirms + it: the green sibling artifact from the same job numbers `1 describe`, `2 ready`, then the + advance. The host does not answer because its frame pump is silent for 38.3 s during first-use + compile (`maxGapMs: 38332`, `startup.compileSettledMs observed 41907.94`), and the mailbox poll + rides rAF (`three/device.ts:139`). `startupReady.ts:116-125,172-186` deliberately swallows + `TN_PLAYTEST_OPERATION_TIMEOUT` there, so every other call on that path tolerates the stall; the + warmup advance is the one pre-observation call with no such tolerance. The bounded fix is + `packages/playtest`: issue `scenario.warmupFrames` after `waitForStartupReady`. It is a + native-lane change with its own proof and **is not dispatched from here**. + - **CI policy: this leg is not selected for this branch, so it is not this branch's merge gate.** + `.github/workflows/native-platforms.yml:1058` gates the macOS/Windows `desktop` job on + `needs.scope.outputs.native_tier == 'full'` (the iOS simulator leg on the same tier at `:2061`), + and an ordinary pull request into `develop` is classified `reduced` — `validationProfile` in + `scripts/ci-change-scope.mjs:610-618` returns `reduced` for a `develop` target with native work, + which native-platforms.yml:67-69 records as the PRD-380 phase 2 owner call. Verified for this + pull request rather than assumed: replaying the classifier with run 37251931881's own inputs + (`--target develop --event-name pull_request --base 64aed30fe --head 504626b48`) prints + `selection=full`, `native_tier=reduced`, and run 37251931881 shows + `native-platforms / ${{ matrix.platform }} desktop core` as **skipped** while its Linux legs ran + green. `scripts/__tests__/ci-efficiency.spec.ts:252-253,348` locks that condition, so it is + policy, not an accident of this run. The earlier self-imposed line here ("it must be green before + this branch merges") was this PRD adding a gate the repo's policy does not set for a develop pull + request, and it is withdrawn. The Windows red itself is real and stays recorded: the manual full + native-platforms audit that ran all four platforms is **red**, and nothing here turns it green. +- **Develop merge (2026-10-05, `64aed30fe` #416 backlight/dark defaults).** `origin/develop` was + merged in with a plain `git merge` (no rebase, no force) because the merge queue requires a + current base. #416 is one commit and 1,087 files; two of them also carried cleanup edits, so + exactly one conflict: `PRD_201_PARENT_SCAFFOLD_HASHES` in + `packages/create-threenative/__tests__/scaffold.spec.ts`. + - **Resolution, measured not chosen.** Neither side's hash block was kept. The merged tree was + measured through the installed generator — `pnpm exec vitest run + packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps every no-install scaffold tree + byte-stable"` — and its failing assertion printed all thirteen actual values; those are what + the constant now carries. Ten moved (the ten templates this sweep edited, since #416 also + touched them), `platformer`, `snow` and `starter` kept #416's values untouched by either side. + Re-run after the restamp: 1 passed; the whole file 66 tests, exit 0, 34.6s. + - **Auto-merge audited, not trusted.** The other overlapping file, + `templates/puzzle/src/scenes/Play.ts`, merged clean: `git diff 64aed30fe --` on it shows + exactly this lane's `const unwatch = seal.watch({` → `seal.watch({` and none of #416's + material-lighting wiring, and #416's diff against the base shows exactly its wiring and none + of ours. Both sides' behaviour survives. Whole-file deletions: all 14 of this lane's are + absent from the merged tree (`git diff --name-status --diff-filter=D 64aed30fe`), and every + file #416 added is present. + - **Focused proof on the merged tree** — `pnpm typecheck` exit 0; `pnpm lint` exit 0 (1,101 + warnings, 0 errors, after `biome format` on the restamped constant); `pnpm sync:agents + --check` exit 0, 22 mirrors; `pnpm exec tsx scripts/check-template-conventions.ts` green, so + the restamped shooter applicability cells still name live calls on the merged tree; + `pnpm check:docs` exit 0, 2,393 links across 1,230 files; the scripts lane + (`check-doc-links`, `primary-docs`, `evidence-budget`, `evidence-citations`, `sync-agent-docs`, + `ci-structure`, `ci-needs`, `search-path`) 8 files / 260 tests exit 0; `pnpm budgets` exit 0. + `pnpm budgets` first failed on a **stale build artefact**, not on merged source — + `CAPABILITY_BUILT_SYMBOL_MISSING: @threenative/playtest/capture#collectRegionalTone` — because + `packages/playtest/dist` predated #416's `collectRegionalTone`. `pnpm --filter + @threenative/playtest build` exit 0 (publint clean) and budgets is then exit 0. It also + reported native census drift; `pnpm census` exit 0 regenerated the ten cells, of which + `android/` 3,187 → 1,814 is this lane's deleted sample JS and the orphan Metal shader. + - **AC-2 stayed unticked here.** The merged tree had not run a full board, and none was rolled + from this lane: the box is shared and oversubscribed, and a fourth red wall-clock reading would + not be new evidence. AC-2 turned green later, on CI run 37255318117 at the new head rather than + on a local board. +- **Independent review (2026-10-05, `claude-opus-5-5 --effort high`) and its restorations.** The + review checked each removal for readers and required the benchmark restoration; the other two + restorations conservatively preserve useful manual proof and history: + the physics allocation bench (open PRD-191 still names it as its Phase 3 EDIT target and + verification step 2; its "NOT STARTED" status is stale because `91e46d34f` did that work and left + a record showing the bench run at exit 0), + `templates/rain/tools/verify-noise-volume.mjs` (an owner's manual storm-volume proof that needs + an external `tempest.html`), and `plans/technical-debt-audit-2026-08-20.md` (a distinct audit of + `d75f4644`, not a duplicate). All three are restored byte-identical to `origin/develop` + (`64aed30fe`). Focus checks on the restored tree: `pnpm exec vitest run + packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps every no-install scaffold tree + byte-stable"` — the assertion first failed and printed the measured `rain` hash + `637afd24…92255` (the restored verifier file reaches every rain scaffold), restamped, then 1 + passed; `NODE_OPTIONS=--expose-gc pnpm --filter @threenative/physics exec tsx + scripts/bench-allocations.ts` — exit 0, full JSON census, 156.3 s (it is a manual bench, not a CI + gate, so a restored bench is not a new CI obligation); `pnpm --filter create-threenative typecheck` + exit 0 (the restored bench sits outside every package tsconfig — `packages/physics/tsconfig.json` + includes `src/**/*.ts` only — so running it is its own proof, as PRD-191 does); + `pnpm exec tsx + scripts/check-template-conventions.ts` green; `pnpm check:docs` exit 0; `pnpm sync:agents --check` + exit 0; the prose/CI lane 8 files / 303 tests exit 0, including `ci-efficiency.spec.ts`, which + locks the `native_tier` condition above; `git diff --check` clean. The count of executed changes + drops 73 → 70. +- **What CI covers of AC-2, checked on this pull request rather than assumed.** `pnpm test` is four + phases — `docs,build,package-test,unit` (`scripts/run-test-suite.sh:277`) — and CI runs all four + across three jobs: `test` runs `docs,build,package-test` with `@threenative/runtime-native` + excluded (`ci.yml:196,200`), `test-native` runs that one excluded package + (`ci.yml:529`), and `test-unit` runs `unit` in four shards (`ci.yml:246-247`). + `scripts/__tests__/ci-structure.spec.ts:2617-2645` computes that partition from + the workflow rather than trusting it, and `:2652` holds the script's own default. `typecheck` and + `lint` are separate green jobs. So the phase and package scope of AC-2 is covered on hosted + runners, at full scope rather than a narrower substitute. + **Executed green 2026-10-05 at head `346afd02717e079874280eab2a39ce3db21f2068`:** + [run 37255318117](https://github.com/ThreeNativeHQ/threenative/actions/runs/37255318117), terminal + `success` — `typecheck` (job 111594404786), `lint` (111591273243), `test` (111591273307), + `test-native` (111594404758), `test-unit` 1/4–4/4 (111594404946, 111594404960, 111594404852, + 111594404832) and `ci-required` (111600105931), whose `selection` was `full`. Its + `native-platforms / ${{ matrix.platform }} desktop core` leg is **skipped** under the reduced + `native_tier` policy below, not waived here. AC-2 is ticked on that receipt, not on the + oversubscribed local box. +- **PR unit failure (2026-10-05, run 37251931881, job 111583180400).** Shard `3/4` + timed out at 60 s in `world-gpu-scene.spec.ts:913`, the GPU-main-pass regroup/refilter test. + The exact test alone passed locally: 1 passed / 69 skipped, 25.13 s (26.52 s total), using + `pnpm exec vitest run packages/core/__tests__/world-gpu-scene.spec.ts -t 'spends no regroup and no refilter, feeds the source buffer, and dresses every main key'`. + The core unused-local/parameter command in AC-1 was also rerun on the merged source: exit 0, + empty output. No assertion or timeout changed. Those focused results did not turn the failed + shard into a pass; run 37251931881 was superseded and cancelled by the new head, and the shard + is green on run 37255318117 (`test-unit` 3/4, job 111594404852). +- **Pacing decision (2026-10-04, this lane).** That is the third run to fail on wall-clock alone, so + the fourth was not attempted: at launch every core was ≥59% busy (mean 77%) with no quiet set of + four to move to, and another 24-minute run would only add foreign contention to the box it is + trying to measure. The isolated four-file re-run above is the minimal honest retry — it separates + "these specs are slow" from "these specs are wrong" without re-rolling the board a fourth time. + What would settle AC-2 is one full board on a box whose cores are not oversubscribed, not another + knob here. +- No tracked runner, config or test was changed to reach any of these results; the concurrency + knobs used (`TN_SUITE_PACKAGE_CONCURRENCY`, `taskset`) are execution-time inputs the suite already + reads. + +## Blocked on + +Nothing here blocks the sweep: every item below was **retained**, not deferred, so no box above +waits on it. They are listed because a retained row needs a named owner to un-retire it, not +because the safe cleanup is stuck. + +- Owner calls on six decide-first items — `packages/core/scripts/vsm-proof/` (466 lines), the + `runtime-native` native-glTF C++ island (622 lines), `conformance/scenes/shared/spectral-ocean.js` + (217 lines), the `castDistance` shim in `world-cells.ts` (PRD-458 ties it to an external + consumer), the four orphan docs, and the ~18.5 GB untracked disk. Each stays under the 2026-10-04 + working assumption recorded in `## Decisions` until the owner rules; retaining them is the safe + reading, and deleting them is a separate decision with its own proof burden. +- `examples/native-smoke`'s uncalled `parseHttpsUrl` is a game-layer defect, not junk; wiring it + changes example behaviour, so it is reported here, not fixed. Fixing it is its own change. +- The `native-platforms / Windows desktop core` leg is red on the manual full-platform audit with a + 35 s device-mailbox timeout on the **warmup `advance(60)`** that `androidRunner.ts:241` issues + before the tolerant startup wait — the missing first `sample` is the symptom, not the fault. It + predates this sweep by two days and no file this PRD deletes is in that path. It does not block + the sweep, and this branch's own CI does not select it: the leg needs `native_tier == 'full'` + (`native-platforms.yml:1058`) and a develop pull request is classified `reduced` + (`scripts/ci-change-scope.mjs:610-618`, verified for this pull request — see the Windows bullet + under `## Verification notes`). The broader full-platform audit is still red and is recorded as + such; the fix is a `packages/playtest` change with its own proof, not dispatched here. + +## Integration Ledger + +Integration: unchanged — no consumer path, CLI form, render stage, capability-manifest entry or +scenario changes. Deleted symbols have zero tracked readers; the one consumer-visible effect is +that generated projects stop shipping template files nothing imports. The scaffolder's +`PRD_201_PARENT_SCAFFOLD_HASHES` is the only pinned expectation that must move, and it moves to +the new measured bytes of the templates this PRD touches — three times: once for the sweep, once for +the 2026-10-05 merged-tree measurement after #416, and once more for `rain` alone when the review +restored `templates/rain/tools/verify-noise-volume.mjs`. One further pinned record moves with it: +`docs/verification/native-runtime-census-2026-08-16.md`, regenerated by `pnpm census` because the +native tree lost the Android sample JS and the orphan Metal shader. + +## Decisions + +- 2026-10-04 (**working assumption, pending owner reply — not an owner decision**): retain every + decide-first item and all untracked disk; keep tested interfaces even when only a spec reads them; + keep `parseHttpsUrl`'s validation and report the defect instead of adding a runtime change here. + João was never asked to choose this default. It is the conservative reading of the audit and each + retained item is reversible on its own, so the sweep proceeds on it instead of stalling; the items + it covers stay listed in `## Blocked on` until the owner rules on them. +- 2026-10-04 (this PRD): a published barrel re-export (`assets` `WRAP_REPEAT`, + `WRAP_MIRRORED_REPEAT`) is a public contract, not dead code, even with no in-repo reader. +- 2026-10-04 (this PRD): `stamp:template-loading` stays: the audit called it a duplicate entry + point, but the function it wraps is called from nothing else, so deleting it removes a capability. +- 2026-10-04 (this PRD): `core/__tests__/seed.spec.ts` stays: it is the only assertion that a + different seed diverges, which the 1,000-draw equality test cannot see. +- 2026-10-04 (this PRD): the two playtest compat facades (`src/assertions.ts`, + `src/evaluators/helpers.ts`) are still imported by 10 modules, so they are live re-export + surfaces, not dead files; collapsing them is a refactor with a ~35-line win, not junk removal. +- 2026-10-04 (review correction, parent): audit finding 29 (`examples/quarry/src/game.ts`, the + unreferenced `QuarryScene` class-expression name) is **not** executed. The first pass inlined it; + parent review reverted that, because `Constructor.name` is what scene diagnostics print and a class + name that names its scene is diagnostic value, while dropping one identifier cleans up nothing. + The finding moves to `### Retained`. +- 2026-10-05 (independent review correction, Opus 5.5 at `--effort high`): three deletions came + back, byte-identical to `origin/develop` — the physics allocation bench, the rain noise-volume + verifier and the 2026-08-20 plans audit. The benchmark was a definite false positive; the rain + verifier was an owner-decision item and the audit was distinct history. Preserve the latter two + conservatively under the owner's "fix otherwise" request. The remaining 70 deletions stand. + +## Disposition — every finding + +### Executed (70 changes, counted from the diff) + +| Area | Deleted | Count | +| --- | --- | --- | +| core (A) | `IBackendDataLike`, `IRenderObjectContext` (pipeline-census); `#presentsUnreadable` and its two writes (frame-budget); `#groundSpeedOf` (animation); `#random` and its two writes (game.ts); `members` (geometry-capture); `#renderer` with its write/clear, `attachRenderer`'s parameter now `_renderer` (gpu-scene-bvh); `colorValueOf` (projection-uniform); `BATCHED_PREVIOUS_MATRICES_PROPERTY` (render/batched-velocity); `WebGLRendererContract` (renderer); `ObserverConstructor` (ui-hit-regions); `Spans`' `forEach` id parameter → `_id` (playtest.ts) and `#attribute`'s `id` parameter (profiling/Spans); `evictGeometryMembers`' `workspace` parameter (projection-plan); `#sharedFor`/`#disableFar`/`#restoreFar` `cell` parameters (world-cells.ts); unused imports `IAssetLoader`, `zenithTransmittance`, `nodeObject`, `float`, `vec4`, `Box3`, `Matrix3`; an unused loop index over `regions.entries()` (world-gpu-scene.ts); 2 `eslint-disable-next-line no-console` in a Biome repo (`__tests__/world-tiles-cost.spec.ts`); the false "engine-server.mjs is gitignored" comment (mcp/blender.mjs) | 24 | +| physics / ui / playtest / assets (B) | `renderChainAssertionIsMeaningful` (playtest evaluators), `MIN_QUALITY` and `MAX_QUALITY` (assets audio-pcm), the `allowedXvfbRunMentions` entry naming the deleted analysis script (playtest `__tests__/display-advice.spec.ts`) | 4 | +| runtime-native + small (C) | `android/.../assets/scripts/{gltf-viewer,glb-parser}.js` (Gradle replaces the asset source set), `src/raytracing/shaders/raytracing.metal` (no build rule compiles it; `metal_rt.mm:323` embeds only `raytracingKernel`, and the `shadowRayKernel`/`cornellBoxKernel` this file also holds were never built), 3 module-only `export` keywords across `raw-unreal/{bulk-source-model,raw-mesh}.ts` | 4 | +| templates (D) | the shooter's `SCALE_EXPECTATIONS`/`SizeCheck`/`SizeAxis`/`DOOR_HEIGHT` scale-audit table and its false `tools/scale-audit.mjs` pointer, `swallowtail`, `hoardingGeometry`, `pointOnTrack`, `curbBlock`, `vanFootprint`, `halfWithMirrors`, `RainIntent`, the unused `PresetName`/`QualityName`/`WeatherKey` imports (`VISIBILITY_INTENT` stays: `rain/src/game.ts:7,60` still reads it), `LOS_INTERVAL_SECONDS`+`losStagger` and their false comments, `_repair`, `padAt`, `aimBasis`, `MUZZLE_HEIGHT`, `scratchAxisY`, `TRACK_LAYER`, `clearProgress`, the unused `unwatch` binding, the file `minimal/src/scenes/Boot.ts` | 19 | +| examples (D) | `abyss-framework/src/entities/{Crate,Player}.ts`, the committed `abyss-framework/dist.build-report.json` (+ `*.build-report.json` in `.gitignore`), `reparentHome` (native-cpu-load-test), `ROUTE_WARMUP_FRAMES`, `ROUTE_LENGTH_METRES`, `RouteMark` (quarry), `FIRE_SPRITE_TEXTURE` (vfx-gallery) | 9 | +| docs / scripts (E) | `docs/product/{ThreeNative_Lean_Product_Playbook_Report,ThreeNative_Product_Playbook}.pdf`, `analyze-prd-exp-002.mjs`, `analyze-prd-075-render-advisor.mjs`, `ICitationScan` (evidence-citations), `GATE_HEARTBEAT_INTERVAL_MS` and `writeGateStatus` (gate-status), `RoundDisposition` (round-ledger), `RealismEffectsExport`/`RealismEffectsPlatform` (realism-effects-coverage), 2 stale round-ledger pointers in `docs/README.md` | 10 | + +### Retained + +| Finding | Reason | +| --- | --- | +| A1 `packages/core/scripts/vsm-proof/` | owner call; it is the proof harness for a live feature and a publish gate asserts it stays unpublished | +| A3 `core/__tests__/seed.spec.ts` | not a duplicate after all: `random.spec.ts` proves a repeat seed matches, only this file proves a *different* seed diverges, which a seeded RNG ignoring its seed would pass | +| A9 `IMPOSTOR_SURFACE_PARALLAX` | a tested interface, not dead code | +| A24 `castDistance` shim | PRD-458 keeps it until the external `machinefall` consumer drops it | +| A25 19 export-only-for-tests symbols | each is read by a spec; un-exporting them is an interface change, not a deletion | +| B4 `WRAP_REPEAT`/`WRAP_MIRRORED_REPEAT` | published barrel re-exports of the `assets` package | +| B5 `playtest/src/assertions.ts`, B6 `evaluators/helpers.ts` | live compat facades, still imported by 10 modules | +| C1 `packages/*.tgz`, C2 native-glTF island, C4 `spectral-ocean.js` | owner decision (see `## Blocked on`) | +| D7 `parseHttpsUrl` | a real defect in an example: keep the validation, report the missing call | +| D29 `QuarryScene` class-expression name | restored on parent review: `Constructor.name` reaches scene diagnostics, so the name is diagnostic surface, not dead weight | +| E6–E9 four orphan docs | only the inbound link is provably missing; the content is a deliberate record | +| E16–E22 untracked disk (`packages/*.tgz`, `release-native/`, `artifacts/`, `.linchpin/`, `notify.log`, `site/`, `test-results/`) | this lane deletes no local disk; untracked and gitignored, no repo effect | +| E23 `stamp:template-loading` | not unreachable junk, and not deleted: `restampTemplateLoadingCopies` has no other caller in the tree, so this entry point is the only way to apply a canonical `loading.ts` change to the tracked templates — the exact role its sibling `stamp:template-render.ts` plays for shared render sources, and that sibling is itself a contract-tested input (`scripts/__tests__/ci-template-selection.spec.ts`). The audit's premise ("manual-only duplicate entry point") holds only if the function it wraps is reachable some other way; it is not | +| B `packages/physics/scripts/bench-allocations.ts` | **restored 2026-10-05** — the audit called it unused because it read PRD-191 as NOT STARTED (`docs/PRDs/performance/PRD-191-physics-feature-frames-reuse-crossing-storage.md:7`). That status is stale: commit `91e46d34f` did the PRD-191 work (296 added bench lines) and its verification record shows `NODE_OPTIONS=--expose-gc pnpm --filter @threenative/physics exec tsx scripts/bench-allocations.ts — exit 0`. The open PRD still names the bench as its Phase 3 EDIT target and verification step 2 (`PRD-191-*.md:20,83,96`). Deleting it would have removed the measurement PRD-191 still has to run | +| D `templates/rain/tools/verify-noise-volume.mjs` | **restored 2026-10-05** — an owner's manual proof that the storm noise volume matches byte for byte. It needs `tempest.html`, which exists only on the owner's disk, so nothing in the repo runs it; done `docs/PRDs/done/starter-kits/PRD-473-rain-starter-kit.md` and `docs/PRDs/production-readiness/RELEASE-READINESS-2026-09-23.md:23` cite it as history. Retained under "fix otherwise" rather than dropped: a useful proof that needs an external input is not junk | +| E `plans/technical-debt-audit-2026-08-20.md` | **restored 2026-10-05** — the audit called it a duplicate. It is a distinct audit of `d75f4644`, not a copy of `junk-audit-2026-10-04.md`, and its items are still tracked: P0-2 (`:57`, libuv handle storage) by `docs/PRDs/BLOCKED/requires-asan-libuv-source-build/PRD-177-*.md` and `PRD-184-*.md`, P0-4 (`:83`, failure-atomic startup and teardown) by `docs/PRDs/done/PRD-180-core-lifecycle-failure-atomicity.md`. A historical record with live follow-ups is not proven junk | + +### Deferred (4) + +`isRecord` ×3, `isObject` ×3, `now()` ×5 and `fail()` ×2 in `packages/core/src` are duplication, +not dead code: every copy has live callers, so collapsing them rewrites live modules for ~21 +net lines. PRD-208 owns that single-sourcing debt; `isRecord` ×6 in `scripts/` is already its item. + +## Execution Phases + +#### Phase 1: engine layer + +**Status:** DONE — both boxes green. +**Files:** `packages/core/{src,__tests__,mcp}`, `packages/playtest/{src,__tests__}`, `packages/assets/src`, `packages/raw-unreal/src`, `packages/runtime-native/{android,src}` (`packages/physics/scripts` dropped from this list on 2026-10-05: its only finding, the allocation bench, was restored) +**Implementation:** remove the compiler-proven unused declarations and their now-unused +arguments; delete the never-packaged Android sample JS and the orphan Metal shader; drop three +module-only `export` keywords; fix the one stale comment. +**Verification:** AC-1, AC-2, AC-4 — `tsc --noUnusedLocals --noUnusedParameters` on core, then +`pnpm --filter @threenative/runtime-native test` and the workspace gates. +- [x] `packages/core` unused-local and unused-parameter diagnostics reach 0. The 24-diagnostic baseline is the audit input's own measurement (`junk-audit-2026-10-04.md:7`), not re-measured here. proof: `cd packages/core && ../../node_modules/.bin/tsc --noEmit --noUnusedLocals --noUnusedParameters -p tsconfig.json` — Evidence: exit 0, no output at all. +- [x] runtime-native source-list and contract gates stay green without the Android sample JS and the orphan shader. proof: `pnpm --filter @threenative/runtime-native test` — Evidence: exit 0 — 134 files / 1548 tests passed, 62 skipped; Rust physics parity 21 + 2 passed; publint clean. Reaching it needed the compiled host the failures name: `pnpm native:build` exit 0, then `cmake --build build/tn-linux --target threenative-canvas2d-dirty-test threenative-crash-handler-policy-test threenative-timestamp-query-test threenative-rg11b10-renderable-test`, plus the on-demand `tn-linux-quickjs` arm (`cmake --preset tn-linux -B build/tn-linux-quickjs -DMYSTRAL_USE_QUICKJS=ON -DMYSTRAL_USE_V8=OFF`) and its two test targets. Before those builds every one of the failures was an `is not built` assertion on a missing executable; none touched C++ sources or the JS engine, and none referenced a file this PRD deletes. + +#### Phase 2: game layer (templates and examples) + +**Status:** DONE — all three boxes green; the workspace gate verified on CI at head `346afd02`. +**Files:** `packages/create-threenative/templates/{shooter,sailing,racing,rain,action-rpg,rts,tower-defense,runner,puzzle,minimal}`, `examples/{abyss-framework,quarry,native-cpu-load-test,vfx-gallery}` +**Implementation:** delete each zero-reader helper with its doc comment, drop the false +`LOS_INTERVAL_SECONDS`/`losStagger` comments, delete `minimal/src/scenes/Boot.ts`, remove +`abyss-framework/dist.build-report.json` and gitignore it, and restamp +`PRD_201_PARENT_SCAFFOLD_HASHES` for exactly the templates whose bytes moved (10 of 13; +`platformer`, `snow` and `starter` keep their values). +**Verification:** AC-2, AC-3, AC-6 — the scaffolder contract specs, the workspace gates, and the +example build. +- [x] The scaffolder's template contract specs pass with the hashes restamped for the 10 changed templates. proof: `pnpm --filter @threenative/create-threenative test` — Evidence: green inside the workspace suite; the four scaffolder specs the restamp touches (`scaffold`, `template-assets-compile-1`, `template-assets-compile-2`, `template`) re-run on their own: 4 files / 93 tests, exit 0, 205s. +- [x] The workspace typecheck, lint and unit suite stay green over the deleted helpers. proof: `pnpm typecheck && pnpm lint && pnpm test`, or the same four phases on hosted runners — Evidence: `pnpm typecheck` exit 0; `pnpm lint` exit 0 (1074 warnings). The final `pnpm test` (`taskset -c 0,1,2,3 TN_SUITE_PACKAGE_CONCURRENCY=1`, 1424s) is **exit 1**: `docs`, `build` and `package-test` green — `package-test` including runtime-native 134 files / 1548 tests + Rust parity 21 + 2 + publint and the scaffolder's own `scaffold.spec.ts` at 66 tests — and the unit phase `4 failed | 7933 passed | 13 skipped (7950)`. All four are `Test timed out` on the three heaviest specs in the repo plus one native screenshot wait: `template-assets-compile-1` racing (60s), `template-assets-compile-2` shooter (60s), `template.spec.ts` pristine-scaffold typecheck (180s), and `desktop-playtest.spec.ts` "desktop screenshot outlives the unpicked-request wait" (`TN_PLAYTEST_NATIVE_SCREENSHOT_UNAVAILABLE … native host output: none captured`, its other 38 tests green). Not one behavioural assertion. This box is shared with other agents' worktrees and ran at load 27–44 on 24 cores; the same four files alone on the four idlest cores are green — 4 files / 117 tests, exit 0, 227s. Left unticked at the time because the gate as written ran red; see the pacing decision under `## Verification notes` for why it was not rolled a fourth time. It closes on CI run 37255318117 at head `346afd02` — `typecheck`, `lint`, `test`, `test-native` and all four `test-unit` shards green — which runs the same four phases over the same package scope, as AC-2 records. +- [x] `examples/abyss-framework` no longer dirties the tree on build. proof: `pnpm --filter abyss-framework build` then `git check-ignore -v examples/abyss-framework/dist.build-report.json` — Evidence: build exit 0 and rewrites the report; `git check-ignore` → `.gitignore:8:*.build-report.json`; `git status --porcelain examples/abyss-framework` after the build lists only this lane's three staged deletions. + +#### Phase 3: repo surface (docs and scripts) + +**Status:** DONE — deleted and re-pointed; both boxes green. +**Files:** `docs/product/*.pdf`, `docs/README.md`, `scripts/{analyze-prd-exp-002,analyze-prd-075-render-advisor}.mjs`, `scripts/{gate-status,evidence-citations,round-ledger,realism-effects-coverage}.ts`, `packages/playtest/__tests__/display-advice.spec.ts` (`plans/technical-debt-audit-2026-08-20.md` dropped from this list on 2026-10-05: the review proved it a distinct audit, not a duplicate) +**Implementation:** delete the unreferenced PDFs and the two unreachable +analysis scripts; drop the five dead exports; correct the two stale round-ledger pointers. +`scripts/stamp-template-loading.ts` and its `package.json` entry point are **kept** — see the E23 +row under `### Retained` and the 2026-10-04 decision: the wrapped function has no other caller, so +removing the entry point removes the capability rather than a duplicate of it. +**Verification:** AC-2, AC-5 — the docs lane plus the script specs. +- [x] Doc links and primary-doc command names stay resolvable after the pointer fix and the deletions. proof: `pnpm check:docs && pnpm exec vitest run scripts/__tests__/check-doc-links.spec.ts scripts/__tests__/primary-docs.spec.ts` — Evidence: `pnpm check:docs` exit 0, "Checked 2323 relative documentation links across 1216 Markdown files"; the two named specs pass inside the `scripts/__tests__` run below. +- [x] The scripts lane stays green after the two analysis scripts and five dead exports go. proof: `pnpm exec vitest run scripts/__tests__` — Evidence: 143 files, 1944 tests, exit 0 (142 files green in the lane run + `fluid-collision-view.spec.ts`, which failed there only because it was run concurrently with the suite's own `build` phase and `@threenative/core/dist` was mid-rewrite; re-run alone, exit 0, 1 test). diff --git a/docs/README.md b/docs/README.md index 3a7f33678a..0bf57baa17 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ limitations are recorded. evidence. - [CONFLICTS](strategy/CONFLICTS.md) records decisions where strategy and the binding document disagree. -- [Latest round ledger](verification/round-10-2026-08-16.md) gives the current self-improvement +- [Latest round ledger](verification/round-14-2026-09-04.md) gives the current self-improvement state. The [Studio hosting series](PRDs/studio-hosting/README.md) describes the proposed container, session broker, and production path — for the private Studio repository, not this one. - [Engine-load comparison](verification/runtime-perf-state.md#prd-117-browser-comparison-2026-08-14) @@ -77,7 +77,7 @@ recorded in builder sessions, and the defect shapes reviewers keep re-finding. A round ledger records one self-improvement round's inputs, decisions, evidence, and resulting state. `pnpm round:next` resumes from the latest ledger, and `pnpm round:deletions` reports -persistent unused-export evidence. The [newest ledger is round 12](verification/round-12-close-2026-08-22.md); +persistent unused-export evidence. The [newest ledger is round 14](verification/round-14-2026-09-04.md); [the earlier rounds](verification/) sit beside it. ## Benchmark diff --git a/docs/product/ThreeNative_Lean_Product_Playbook_Report.pdf b/docs/product/ThreeNative_Lean_Product_Playbook_Report.pdf deleted file mode 100644 index 7098c64c0f..0000000000 Binary files a/docs/product/ThreeNative_Lean_Product_Playbook_Report.pdf and /dev/null differ diff --git a/docs/product/ThreeNative_Product_Playbook.pdf b/docs/product/ThreeNative_Product_Playbook.pdf deleted file mode 100644 index 6abb0bd9bb..0000000000 Binary files a/docs/product/ThreeNative_Product_Playbook.pdf and /dev/null differ diff --git a/docs/verification/PRD-289-conventions-2026-08-31.md b/docs/verification/PRD-289-conventions-2026-08-31.md index d41c57ad69..6dfd38639b 100644 --- a/docs/verification/PRD-289-conventions-2026-08-31.md +++ b/docs/verification/PRD-289-conventions-2026-08-31.md @@ -23,7 +23,7 @@ table, reads each template's `AGENTS.md`, and checks the TypeScript AST for the | 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` | +| 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:848` | `src/entities/Enemy.ts:854` | `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/native-runtime-census-2026-08-16.md b/docs/verification/native-runtime-census-2026-08-16.md index cfe8540850..386995ac66 100644 --- a/docs/verification/native-runtime-census-2026-08-16.md +++ b/docs/verification/native-runtime-census-2026-08-16.md @@ -15,27 +15,27 @@ considered, and a KEEP/DELETE verdict. | Counted area | Lines | Owner | Live proof or caller | Alternative considered | Verdict | | --- | ---: | --- | --- | --- | --- | -| `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. | +| `src/` | 57,863 | 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,517 | 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/` | 58,508 | 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,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. | +| `scripts/` | 30,901 | 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/` | 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. | +| `include/` | 6,118 | 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/` | 1,814 | 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,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. | +| Root `CMakeLists.txt` | 2,802 | 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. | -| `package.json` | 106 | PRD-048, PRD-050, PRD-054, PRD-116 | `native:build`, `native:physics:parity`, and `native:verify:desktop` scripts | Hide opt-in native commands in per-game manifests | **KEEP** — package-level command contract. | +| `package.json` | 107 | PRD-048, PRD-050, PRD-054, PRD-116 | `native:build`, `native:physics:parity`, and `native:verify:desktop` scripts | Hide opt-in native commands in per-game manifests | **KEEP** — package-level command contract. | | `vitest.config.ts` | 10 | PRD-048, PRD-050 | Runtime-native Vitest command and parity producer | Drop native package test collection | **KEEP** — declares the native package test boundary. | | `tools/` | 145 | PRD-077 | `conformance/desktop-touch.mjs` → `threenative-uinput-touch`, built by the `CMakeLists.txt` target of the same name | Write the injector in Node, or take an npm addon, or shell out to `python3` | **KEEP, and it cannot be smaller.** Creating a `uinput` device is a sequence of ioctls and Node exposes none, so the alternatives are a new native harness dependency rebuilt per Node version, or a Python toolchain this repository does not otherwise have. This owns only the ioctls and the device's lifetime — every event is encoded in JavaScript where a test can assert two `ABS_MT_SLOT` groups precede one `SYN_REPORT`. Linux-only by construction. | | `shim-manifest.json` | 61 | PRD-215 | `scripts/check-native-shims.ts` from `pnpm budgets`; native host installs in `src/runtime.cpp` and `src/webgpu/bindings.cpp` | Keep the native-global contract as prose only in `AGENTS.md` | **KEEP** — machine-readable shim evidence is the enforced web/native boundary. | | `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. | +| `build-matrix.json` | 238 | 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** | **180,365** | | `pnpm budgets` current measurement | | **No area rejected.** | +| **Total** | **179,864** | | `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/examples/abyss-framework/dist.build-report.json b/examples/abyss-framework/dist.build-report.json deleted file mode 100644 index 34c3adacd7..0000000000 --- a/examples/abyss-framework/dist.build-report.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "artifact": { - "kind": "directory", - "name": "dist", - "sha256": "00610e60c69c4bb14aab51c193fd747d3ba2f87d5583aef8e5261f6e67815dfe" - }, - "measured": { - "artifactBytes": 12577621, - "packagedAssetBytes": 786109 - }, - "manifestSha256": "e96b9804e18d941afd1774ff628452ab4db9c199a7230ee00cba306cf58ad036", - "performanceBudget": null, - "profile": null, - "schemaVersion": 1, - "target": "web" -} diff --git a/examples/abyss-framework/src/entities/Crate.ts b/examples/abyss-framework/src/entities/Crate.ts deleted file mode 100644 index aec33a4c9b..0000000000 --- a/examples/abyss-framework/src/entities/Crate.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { ICtx } from "@threenative/core"; -import { CollisionShape3D, type IPhysicsContext, RigidBody3D } from "@threenative/physics"; -import { BoxGeometry, Mesh, MeshNormalMaterial } from "three"; - -export class Crate { - readonly mesh: Mesh; - readonly body: RigidBody3D; - - constructor( - ctx: ICtx<{ playerX: number; score: number }, IPhysicsContext>, - x: number, - y: number, - z = 0, - ) { - this.mesh = new Mesh(new BoxGeometry(1, 1, 1), new MeshNormalMaterial()); - this.mesh.position.set(x, y, z); - ctx.add(this.mesh); - this.body = new RigidBody3D({ - mass: 8, - object: this.mesh, - physics: ctx.physics, - shape: CollisionShape3D.fromMesh(this.mesh), - }); - } - - dispose(): void { - this.body.dispose(); - this.mesh.removeFromParent(); - } -} diff --git a/examples/abyss-framework/src/entities/Player.ts b/examples/abyss-framework/src/entities/Player.ts deleted file mode 100644 index 1158e262ec..0000000000 --- a/examples/abyss-framework/src/entities/Player.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ICtx } from "@threenative/core"; -import { CharacterBody3D, CollisionShape3D, type IPhysicsContext } from "@threenative/physics"; -import { BoxGeometry, Mesh, MeshNormalMaterial } from "three"; - -type GameCtx = ICtx<{ playerX: number; score: number }, IPhysicsContext>; - -export class Player { - readonly mesh: Mesh; - readonly body: CharacterBody3D; - - constructor(ctx: GameCtx) { - this.mesh = new Mesh(new BoxGeometry(0.6, 1, 0.6), new MeshNormalMaterial()); - this.mesh.position.set(-2, 0.5, 0); - ctx.add(this.mesh); - this.body = new CharacterBody3D({ - autostep: { maxHeight: 0.4, minWidth: 0.2 }, - object: this.mesh, - physics: ctx.physics, - shape: CollisionShape3D.capsule(0.2, 0.3), - }); - } - - update(ctx: GameCtx, dt: number): void { - const move = ctx.input.vector("move"); - this.body.move({ x: move.x * dt * 2, y: 0, z: move.y * dt * 2 }); - } - - debug(): Record { - return { grounded: this.body.grounded, position: this.mesh.position.toArray() }; - } - - dispose(): void { - this.body.dispose(); - this.mesh.removeFromParent(); - } -} diff --git a/examples/native-cpu-load-test/src/main.ts b/examples/native-cpu-load-test/src/main.ts index 51c8a56120..2dc86d1b69 100644 --- a/examples/native-cpu-load-test/src/main.ts +++ b/examples/native-cpu-load-test/src/main.ts @@ -699,7 +699,6 @@ async function run(): Promise { const removed = meshes[2] as Mesh; const reparented = meshes[3] as Mesh; const removedParent = removed.parent; - const reparentHome = reparented.parent; const relocated = new Group(); relocated.position.set(0, 500, 0); scene.add(relocated); diff --git a/examples/quarry/src/quarry/route.ts b/examples/quarry/src/quarry/route.ts index 2685c3e3ba..2c0af77944 100644 --- a/examples/quarry/src/quarry/route.ts +++ b/examples/quarry/src/quarry/route.ts @@ -10,8 +10,6 @@ import { floorHeight } from "./terrain.js"; export const EYE_HEIGHT = 1.7; /** Frames the route takes end to end at the game's fixed 1/60 step: thirty seconds of walking. */ export const ROUTE_FRAMES = 1800; -/** Discarded before anything is measured: first-frame compiles and uploads are not the walk. */ -export const ROUTE_WARMUP_FRAMES = 90; /** How close the walk's last frame stands to the rock. */ export const CONTACT_DISTANCE = 0.4; @@ -31,8 +29,6 @@ export const ROUTE_MARKS = [ { frame: 1740, label: "contact" }, ] as const; -export type RouteMark = (typeof ROUTE_MARKS)[number]["label"]; - /** * Ground-plane control points, walked in order. The final point is placed against the rock rather * than at an authored z, because the face is generated and an authored number would be measuring a @@ -98,5 +94,3 @@ export function routePose(frameIndex: number): IRoutePose { return { position, target: [position[0], position[1], position[2] - 1] }; return { position, target }; } - -export const ROUTE_LENGTH_METRES = path.totalLength; diff --git a/examples/vfx-gallery/src/render/archivePresets.ts b/examples/vfx-gallery/src/render/archivePresets.ts index 79eec50d8e..330fc387de 100644 --- a/examples/vfx-gallery/src/render/archivePresets.ts +++ b/examples/vfx-gallery/src/render/archivePresets.ts @@ -56,7 +56,6 @@ export type ArchiveLayer = { type PresetFactory = (position: ArchiveVec3) => ArchiveLayer[]; -const FIRE_SPRITE_TEXTURE = "fire"; const SMOKE_SPRITE_TEXTURE = "smoke"; const GLOW_SPRITE_TEXTURE = "glow"; diff --git a/packages/assets/src/passes/audio-pcm.ts b/packages/assets/src/passes/audio-pcm.ts index 2620f79b99..10f8540560 100644 --- a/packages/assets/src/passes/audio-pcm.ts +++ b/packages/assets/src/passes/audio-pcm.ts @@ -234,10 +234,6 @@ function sampleReaderFor(format: number, bits: number): SampleReader | undefined return undefined; } -/** Vorbis VBR quality, the same -1 to 10 scale the reference encoder takes. */ -export const MIN_QUALITY = -1; -export const MAX_QUALITY = 10; - /** * Encodes PCM to Ogg Vorbis with a serial number derived from the asset's own path. * diff --git a/packages/core/__tests__/world-tiles-cost.spec.ts b/packages/core/__tests__/world-tiles-cost.spec.ts index c24415b6b2..b12105be6a 100644 --- a/packages/core/__tests__/world-tiles-cost.spec.ts +++ b/packages/core/__tests__/world-tiles-cost.spec.ts @@ -398,7 +398,6 @@ describe("TerrainTiles settled-frame cost", () => { attributeReads += attributes.mock.calls.length - before; } attributes.mockRestore(); - // eslint-disable-next-line no-console console.log( `TerrainTiles 6 s walk: ${perCall.toFixed(3)} ms per follow()+process(), ` + `${(attributeReads / frames).toFixed(0)} getAttribute a frame, ` + @@ -430,7 +429,6 @@ describe("TerrainTiles settled-frame cost", () => { tiles.process(); } const perCall = (performance.now() - started) / 100; - // eslint-disable-next-line no-console console.log( `TerrainTiles settled 289-tile ring: ${perCall.toFixed(3)} ms per follow()+process()`, ); diff --git a/packages/core/mcp/blender.mjs b/packages/core/mcp/blender.mjs index 6928af6f01..39ba9fac74 100644 --- a/packages/core/mcp/blender.mjs +++ b/packages/core/mcp/blender.mjs @@ -8,10 +8,9 @@ import { MCP_PACKAGES } from "./servers.mjs"; // Core carries the built server beside this shim, so a project that installed `@threenative/core` // has working Blender tools with no network and no extra install. // -// `blender-server.mjs` is generated by `scripts/bundle-blender-mcp.mjs` **and committed**, unlike -// its `engine-server.mjs` neighbour, which is gitignored. The difference matters: the npx fallback -// below cannot work for this server, because `threenative-blender-mcp` has never been published — -// a missing bundle becomes +// `blender-server.mjs` is generated by `scripts/bundle-blender-mcp.mjs` **and committed**, as is +// its `engine-server.mjs` neighbour. It must stay that way: the npx fallback below cannot work for +// this server, because `threenative-blender-mcp` has never been published — a missing bundle becomes // // threenative-blender exited before completing MCP requests (1): npm error code E404 // diff --git a/packages/core/src/animation.ts b/packages/core/src/animation.ts index 23df72f8e5..a8009a4b58 100644 --- a/packages/core/src/animation.ts +++ b/packages/core/src/animation.ts @@ -404,10 +404,6 @@ export class AnimationPlayer { return uniformYawScale(owner.matrixWorld); } - #groundSpeedOf(name: string): number { - return this.#measureOf(name).groundSpeed; - } - /** * What one unit of a root track is worth in world metres. * diff --git a/packages/core/src/atmosphere/index.ts b/packages/core/src/atmosphere/index.ts index 7fcab9104f..698f7079f9 100644 --- a/packages/core/src/atmosphere/index.ts +++ b/packages/core/src/atmosphere/index.ts @@ -15,7 +15,6 @@ import { resolveAtmosphereParameters, solarPosition, updateAtmosphereParameters, - zenithTransmittance, } from "./params.js"; /** The structural contract consumed by the compute registry from PRD-242. */ diff --git a/packages/core/src/frame-budget.ts b/packages/core/src/frame-budget.ts index 85d2f18d0e..626e7f5969 100644 --- a/packages/core/src/frame-budget.ts +++ b/packages/core/src/frame-budget.ts @@ -588,7 +588,6 @@ export class FrameBudget { #readPresentCount: (() => number | undefined) | undefined; #lastPresentCount: number | undefined; #presentsInWindow = 0; - #presentsUnreadable = false; #firstFrameStart: number | undefined; constructor(options: IFrameBudgetOptions = {}) { @@ -1084,11 +1083,9 @@ export class FrameBudget { if (count === undefined || !Number.isFinite(count)) { // One unreadable sample is not a broken counter: the reading resumes on the next frame, and // the window still says what it counted. - this.#presentsUnreadable = true; this.#lastPresentCount = undefined; return; } - this.#presentsUnreadable = false; const previous = this.#lastPresentCount; this.#lastPresentCount = count; if (previous === undefined || count < previous) return; // first sample, or a restarted counter diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts index c19d79f714..39b14521b4 100644 --- a/packages/core/src/game.ts +++ b/packages/core/src/game.ts @@ -6,7 +6,7 @@ import { Scene as ThreeScene, Vector2, } from "three"; -import { type IAssetLoader, type IAssetLoaderOptions, createAssetLoader } from "./assets.js"; +import { type IAssetLoaderOptions, createAssetLoader } from "./assets.js"; import { CanvasLayer } from "./canvas-layer.js"; import { updateClusteredMeshes } from "./clustered-mesh.js"; import { ComputeDrivenRegistry, isComputeDriven } from "./compute-driven.js"; @@ -688,7 +688,6 @@ class GameImpl, TPhysics> #cleanup: Array<() => void> = []; #computeDriven = new ComputeDrivenRegistry(); #entities: Registry | undefined; - #random: IRandom | undefined; #picker: ScenePicker | undefined; #pointerEvents: PointerEvents3D | undefined; #scheduler: Scheduler | undefined; @@ -1364,7 +1363,6 @@ class GameImpl, TPhysics> }; this.#ctx = ctx; this.#entities = entities; - this.#random = random; this.#scheduler = scheduler; this.#afterPhysicsPhase = afterPhysicsPhase; const devToolsHost = @@ -2212,7 +2210,6 @@ class GameImpl, TPhysics> this.#ctx = undefined; this.#hasDepthCoupledOutput = false; this.#loop = undefined; - this.#random = undefined; this.#pointerEvents?.dispose(); this.#pointerEvents = undefined; this.#picker?.dispose(); diff --git a/packages/core/src/geometry-capture.ts b/packages/core/src/geometry-capture.ts index fd50518b6f..f8a7c45841 100644 --- a/packages/core/src/geometry-capture.ts +++ b/packages/core/src/geometry-capture.ts @@ -706,7 +706,6 @@ function meshDraftsOf(armed: IArmedFrame): IMeshDraft[] { // A batch is one draw however many sources it folded. Charging a whole draw to each member is // how a merged batch reads as more expensive than the objects it replaced. const owner = displayName(record.object); - const members = ownership.sources.length; for (const source of ownership.sources) { const geometry = (source as IRenderedLike).geometry; const own = geometryTriangles(geometry); diff --git a/packages/core/src/gpu-scene-bvh.ts b/packages/core/src/gpu-scene-bvh.ts index d215d8e5d8..5bf6819fbc 100644 --- a/packages/core/src/gpu-scene-bvh.ts +++ b/packages/core/src/gpu-scene-bvh.ts @@ -370,7 +370,6 @@ export class GPUSceneBVH extends Group implements IComputeDriven { #packedGeometry: BufferGeometry | undefined; #positions = 0; #released = false; - #renderer: IRendererLike | undefined; #scene: Object3D; #triangleCount = 0; @@ -414,9 +413,8 @@ export class GPUSceneBVH extends Group implements IComputeDriven { return this.#positions; } - attachRenderer(renderer: IRendererLike): void { + attachRenderer(_renderer: IRendererLike): void { if (this.#released) throw new Error("GPUSceneBVH cannot be attached after release."); - this.#renderer = renderer; } process(_renderer: IRendererLike): void { @@ -459,7 +457,6 @@ export class GPUSceneBVH extends Group implements IComputeDriven { this.#backings.normals = null; this.#packedGeometry?.dispose(); this.#packedGeometry = undefined; - this.#renderer = undefined; this.#released = true; } diff --git a/packages/core/src/pipeline-census.ts b/packages/core/src/pipeline-census.ts index 2c65dfab47..670121f2ab 100644 --- a/packages/core/src/pipeline-census.ts +++ b/packages/core/src/pipeline-census.ts @@ -180,17 +180,6 @@ interface IInstalledDevice { readonly restore: readonly (() => void)[]; } -interface IBackendDataLike { - readonly pipeline?: unknown; - readonly error?: unknown; -} - -interface IRenderObjectContext { - readonly pass?: unknown; - readonly object?: unknown; - readonly material?: unknown; -} - interface IActiveRenderObject { readonly clippingContext: unknown; readonly object: unknown; diff --git a/packages/core/src/playtest.ts b/packages/core/src/playtest.ts index 3238cb1174..a0432099f4 100644 --- a/packages/core/src/playtest.ts +++ b/packages/core/src/playtest.ts @@ -584,7 +584,7 @@ function drainContacts, TPhysics>( for (const value of objectGraphValues(registered)) if (!idsByEntity.has(value)) idsByEntity.set(value, id); }); - ctx.entities.forEach((id, registered) => { + ctx.entities.forEach((_id, registered) => { for (const source of entitySources(registered)) { for (const event of source.drainContacts()) { const entity = idsByEntity.get(event.body); diff --git a/packages/core/src/profiling/Spans.ts b/packages/core/src/profiling/Spans.ts index 8e6c70be5c..06f3a4df25 100644 --- a/packages/core/src/profiling/Spans.ts +++ b/packages/core/src/profiling/Spans.ts @@ -24,8 +24,6 @@ * to put around a per-draw call — and the names exist only in the report. */ -import type { Object3D } from "three"; - /** Marker printed once per report window when spans are installed. */ export const SPANS_MARKER = "TN_FRAME_SPANS"; @@ -263,7 +261,7 @@ export class SpanRecorder { this.#depth = depth; const ms = now - (this.#stackStart[depth] ?? now); this.#own[id] = (this.#own[id] ?? 0) + ms; - this.#attribute(id, ms); + this.#attribute(ms); } /** @@ -274,10 +272,10 @@ export class SpanRecorder { add(id: SpanId, ms: number): void { this.#own[id] = (this.#own[id] ?? 0) + ms; this.#entries[id] = (this.#entries[id] ?? 0) + 1; - this.#attribute(id, ms); + this.#attribute(ms); } - #attribute(id: SpanId, ms: number): void { + #attribute(ms: number): void { if (this.#depth === 0) { this.#topLevelMs += ms; return; diff --git a/packages/core/src/projection-plan.ts b/packages/core/src/projection-plan.ts index 2caa6a9528..2fa5b6f2df 100644 --- a/packages/core/src/projection-plan.ts +++ b/packages/core/src/projection-plan.ts @@ -920,11 +920,7 @@ function addToMaterialGroup( * still listed in it, so the below-floor sweep hands them to the exact lane exactly once. What * this removes them from is the batch they can no longer honestly join. */ -function evictGeometryMembers( - workspace: IProjectionScanWorkspace, - group: IProjectionMaterialGroup, - geometry: BufferGeometry, -): void { +function evictGeometryMembers(group: IProjectionMaterialGroup, geometry: BufferGeometry): void { let writeIndex = 0; for (let readIndex = 0; readIndex < group.memberCount; readIndex += 1) { const member = group.members[readIndex] as Mesh; @@ -955,7 +951,7 @@ function watchMaterialGroupGeometries(workspace: IProjectionScanWorkspace): void workspace.streamedGeometries.add(geometry); group.geometries.delete(geometry); group.revision += 1; - evictGeometryMembers(workspace, group, geometry); + evictGeometryMembers(group, geometry); } } } diff --git a/packages/core/src/projection-uniform.ts b/packages/core/src/projection-uniform.ts index 66b9aa3ab0..73488face5 100644 --- a/packages/core/src/projection-uniform.ts +++ b/packages/core/src/projection-uniform.ts @@ -75,11 +75,6 @@ const records = new WeakMap(); const opaqueIds = new WeakMap(); let nextOpaqueId = 1; -function colorValueOf(value: unknown): Color | undefined { - if (value === null || value === undefined) return undefined; - return (value as { isColor?: boolean }).isColor === true ? (value as Color) : undefined; -} - function isTextureValue(value: unknown): boolean { return value !== null && (value as { isTexture?: boolean }).isTexture === true; } diff --git a/packages/core/src/render/batched-velocity.ts b/packages/core/src/render/batched-velocity.ts index aaa2f7ba94..377bafd4b4 100644 --- a/packages/core/src/render/batched-velocity.ts +++ b/packages/core/src/render/batched-velocity.ts @@ -1,7 +1,5 @@ import { BatchedMesh, DataTexture, type Matrix4, type Texture } from "three"; -/** The property consumed by the patched Three.js `Batch` TSL accessor. */ -export const BATCHED_PREVIOUS_MATRICES_PROPERTY = "_previousMatricesTexture"; const THREE_NATIVE_BATCHED_VELOCITY_PATCH = "threeNativeBatchedVelocityPatch"; interface IBatchedMeshVelocityState { diff --git a/packages/core/src/render/virtual-shadow.ts b/packages/core/src/render/virtual-shadow.ts index 2a6a585df1..a7eea611d8 100644 --- a/packages/core/src/render/virtual-shadow.ts +++ b/packages/core/src/render/virtual-shadow.ts @@ -28,7 +28,6 @@ import { max, min, mix, - nodeObject, positionWorld, property, reference, diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 0b4135b19c..61e6af058d 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -1,4 +1,4 @@ -import type { BufferGeometry, Camera, Object3D, WebGLRenderer } from "three"; +import type { BufferGeometry, Camera, Object3D } from "three"; import { type PassNode, RenderPipeline } from "three/webgpu"; import type { IFrameSurfaceState } from "./frame-budget.js"; import { @@ -1185,5 +1185,3 @@ export async function createRenderer(options: IRendererOptions = {}): Promise void; } -type ObserverConstructor = new (callback: () => void) => IObserverLike; - /** Five decimals of a normalized rect is a tenth of a pixel on a 10k display. */ function quantize(value: number): number { return Math.round(value * 1e5) / 1e5; diff --git a/packages/core/src/world-cells.ts b/packages/core/src/world-cells.ts index 4d4808c9a7..b45b5a2bf6 100644 --- a/packages/core/src/world-cells.ts +++ b/packages/core/src/world-cells.ts @@ -5502,7 +5502,7 @@ export class WorldCells extends Group implements IComputeDriven { // skipping an entry that has its main block meant the retry never asked for its cluster, so // those records reached the main pass and no shadow map. if (entry.segment < 0) { - const shared = this.#sharedFor(job.asset.id, entry, cell); + const shared = this.#sharedFor(job.asset.id, entry); const segment = shared === undefined ? undefined @@ -5550,7 +5550,7 @@ export class WorldCells extends Group implements IComputeDriven { // This run's near representation is attached, so the far mesh stops drawing its original roots in // the same synchronous block: a cell is never drawn by both and never by neither. A no-op until // the asset's atlas lands; see `#syncFarSegments` for the build-time half and `#restoreFar`. - this.#disableFar(job.cell, job.run); + this.#disableFar(job.run); return true; } @@ -5952,7 +5952,7 @@ export class WorldCells extends Group implements IComputeDriven { } /** The main pass's one mesh for an asset part at a level, covering every resident cell. */ - #sharedFor(assetId: string, entry: ICellBatch, cell: IResidentCell): SharedBatch | undefined { + #sharedFor(assetId: string, entry: ICellBatch): SharedBatch | undefined { return this.#batchFor(assetId, entry, this.#keyOf(entry), "main", this.#receiveShadow); } @@ -7949,7 +7949,7 @@ export class WorldCells extends Group implements IComputeDriven { } /** The near ring has attached this run's full representation; the far mesh stops drawing it. */ - #disableFar(cell: IResidentCell, run: IWorldRun): void { + #disableFar(run: IWorldRun): void { const aggregate = this.#farSegments.get(run); if (aggregate === undefined) return; const segment = aggregate.segments.get(run); @@ -7959,7 +7959,7 @@ export class WorldCells extends Group implements IComputeDriven { } /** This run is leaving the ring; the far mesh draws it again before the near batches are cleared. */ - #restoreFar(cell: IResidentCell, run: IWorldRun): void { + #restoreFar(run: IWorldRun): void { const aggregate = this.#farSegments.get(run); if (aggregate === undefined) return; const segment = aggregate.segments.get(run); @@ -8596,7 +8596,7 @@ export class WorldCells extends Group implements IComputeDriven { // Every run this cell held goes back to the far mesh BEFORE the near batches below are cleared: // the far mesh owns the ORIGINAL roots and this is the same synchronous block, so no frame draws // the cell twice or not at all. Skipped at teardown, where the far aggregates are drained whole. - if (!this.#released) for (const run of cell.cell.runs) this.#restoreFar(cell, run); + if (!this.#released) for (const run of cell.cell.runs) this.#restoreFar(run); // Work queued for a cell that has left is dropped, not resumed: a later frame would build // batches into a graph that no longer holds the cell, and the residency slot is already gone. this.#jobs = this.#jobs.filter((job) => { diff --git a/packages/core/src/world-gpu-scene.ts b/packages/core/src/world-gpu-scene.ts index 9cbbc9a6b2..109cff6757 100644 --- a/packages/core/src/world-gpu-scene.ts +++ b/packages/core/src/world-gpu-scene.ts @@ -15,7 +15,6 @@ import { struct, uniform, vec3, - vec4, } from "three/tsl"; import { IndirectStorageBufferAttribute, @@ -435,7 +434,7 @@ function select(input: IKernelInput, shadow: IShadowLevel | undefined): IKernelR 0, ); const matrix = new Float32Array(capacity * LOCAL_WORDS); - for (const [index, region] of regions.entries()) { + for (const region of regions) { args[region.argsIndex * DRAW_ARGS_WORDS + 4] = region.start; } for (let index = 0; index < input.count; index += 1) { diff --git a/packages/core/src/world-tiles.ts b/packages/core/src/world-tiles.ts index 8a078e2252..2a6ac1e906 100644 --- a/packages/core/src/world-tiles.ts +++ b/packages/core/src/world-tiles.ts @@ -1,14 +1,4 @@ -import { - type Box3, - BufferAttribute, - BufferGeometry, - LOD, - type Matrix3, - type Matrix4, - Mesh, - Object3D, - Vector3, -} from "three"; +import { BufferAttribute, BufferGeometry, LOD, type Matrix4, Mesh, Object3D, Vector3 } from "three"; import type { InterleavedBufferAttribute } from "three"; import type { IAssetLoader } from "./assets.js"; import type { IComputeDriven } from "./compute-driven.js"; diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index df62e230e9..0401caa66d 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -172,25 +172,33 @@ const BUG_REPORT_SKILL_PATHS = [ // docs/verification/prd339-exposure-proof/completion-consumer-8bf16f4.json. // Current develop c18a42b integration: all13 actual generated trees were byte-compared // against reviewed 8bf trees; only the copied Three compute-only Storage3DTexture patch changed. +// Recomputed 2026-10-04 for PRD-495's junk sweep: ten templates lost zero-reader declarations +// (several helpers in racing and shooter, one dead file in minimal), so their generated trees +// moved. platformer, snow and starter were untouched and keep their values. Every restamped value +// is the measured no-install tree, taken from the failing assertion this constant exists to raise +// and then re-run green. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { - // Recomputed 2026-10-03 on the merge of develop (PRD-339 exposure/fog, PRD-423 frame - // consolidation) into the PRD-345 backlight/defaults branch: the merged tree carries both - // sides' engine, manifest and template bytes, so all thirteen generated trees were - // re-measured through createProject on that merged tree. Values below are the merged-tree - // measurement, not either side's. - "action-rpg": "85dede7f62295e7f286944727cc64de40dc759d9324cb38d02c6b03bfcf9eeea", - minimal: "168ed30d0c543c5973c4f642968ed1672cc85e10b94fdb687f851f70a0cdce08", + // Recomputed 2026-10-05 on the merge of develop (PRD-345 backlight/dark defaults, #416) into + // the PRD-495 junk-cleanup branch: the merged tree carries both sides' template bytes — ten + // templates lost zero-reader declarations and all thirteen gained the bounded + // lighting/environment sources — so all thirteen generated trees were re-measured through + // createProject on that merged tree. Values below are the merged-tree measurement, not either + // side's. + // rain re-measured after Opus review restored templates/rain/tools/verify-noise-volume.mjs, + // the owner's manual storm-volume proof that the sweep had removed as junk. + "action-rpg": "4958ce9c3fc2a809262f05294481020a958fac17b70d454f648b59f09351f95a", + minimal: "572bf9ffd8b18a8b1e04186c4dfdec17216b77d31bf6d9ddc1a26ceaac3333fe", platformer: "6de157b3c2cfa0034d48f8326b9f1a90fe90ebce3bf265ac91bae379482d017b", - puzzle: "54c2a109b3c8d0575a06718b3e8f2c1c3bb75a8d968d7afb3215ac998e11b79c", - racing: "7b31cdc89f985f7431e1ef294dba9c6010fdebef6f250d767f86f6f6fcdb4d21", - rain: "9ba4b5d3bd72d2803db42568d1126c7dd96173b51acaa8c484d6d2139859f5fd", - rts: "a2d9006abe0b33c04b272c8ba2120d780e88782d1bdcee0c4d40635886a7adc3", - runner: "2d7dd25c0d763b84dd08f591d8bf124fb169ccba17e4b0b0bf967ac37d9a2258", - sailing: "7b87e05b98e3136cae944eab416442108dc1edf1f4ebe759e8ce3e24b58aece9", - shooter: "adecce922b383deb3c44f66cd518a2b433acee0ef23f3e2b11900eb05c674902", + puzzle: "1a83b3b4ec8de36970c589cdb50783620af09a8569f7690fb1beb2f4a7a2c2a6", + racing: "7f1ca6cd0655349331216dc9b8b08bc04406209bfc075b01af4029fedf9c6caa", + rain: "637afd242e4d7bc6ded1b102b1bd80bfbf289ed32878cdb1b20902e303292255", + rts: "c9b22f3b32846caf493699c365ffed4bcd139d418b7e4ce99dd9b67c42a325d8", + runner: "550b626387489ebf70a6a33aad2c437c52d7bdefef04b3a97c1a81413fa678f4", + sailing: "4cae0186fa08e10f6e732d79711ef42347ebd83e2b83448f656b1e6c63a8cdba", + shooter: "127fcdc64a2b48f7259c811391e07524c4e27b189f994c8a157268bd27f0d200", snow: "791580f3b7314d4983a2cbf57b87b83be2e4740fa63e204327826120f1a0cf12", starter: "5ab4d1828be78d82e7b83fbb2e07afa998372314ffb46accbaffd3dc4ad8cfdd", - "tower-defense": "6e93920048bd67a0b9cfeec9d8d6ec8c71dd17d0af9e5ff40cb67e7c60aa9714", + "tower-defense": "f441c30e062185be72b7d97f63ff63c6eaf66b72a8689acdfea91e5b9907f8a2", }; const GENERATED_SCAFFOLD_METADATA = diff --git a/packages/create-threenative/templates/action-rpg/src/progress.ts b/packages/create-threenative/templates/action-rpg/src/progress.ts index 5363c11c80..5def0f2fa4 100644 --- a/packages/create-threenative/templates/action-rpg/src/progress.ts +++ b/packages/create-threenative/templates/action-rpg/src/progress.ts @@ -29,7 +29,3 @@ export function loadProgress>(initial: T): T { export function saveProgress>(store: IReadableStore): void { storage()?.setItem(SAVE_KEY, JSON.stringify(store.getState())); } - -export function clearProgress(): void { - storage()?.removeItem(SAVE_KEY); -} diff --git a/packages/create-threenative/templates/minimal/src/scenes/Boot.ts b/packages/create-threenative/templates/minimal/src/scenes/Boot.ts deleted file mode 100644 index 178781c037..0000000000 --- a/packages/create-threenative/templates/minimal/src/scenes/Boot.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Scene } from "@threenative/core"; -import type { IPhysicsContext } from "@threenative/physics"; -import type { GameState } from "../state.js"; - -export class Boot extends Scene {} diff --git a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts index 31cc026349..0bdf0ef95d 100644 --- a/packages/create-threenative/templates/puzzle/src/scenes/Play.ts +++ b/packages/create-threenative/templates/puzzle/src/scenes/Play.ts @@ -344,7 +344,7 @@ export class Play extends Scene { /** V pressed before the opening drop finished: honoured as soon as it has. */ let replayRequested = false; - const unwatch = seal.watch({ + seal.watch({ isCrate: (body) => crateBodies.has(body), onContact: (by) => { // The seal is switched off for the duration of the replay, so anything that reaches it diff --git a/packages/create-threenative/templates/racing/src/render/palette.ts b/packages/create-threenative/templates/racing/src/render/palette.ts index 9419dfa761..6191d3a3ab 100644 --- a/packages/create-threenative/templates/racing/src/render/palette.ts +++ b/packages/create-threenative/templates/racing/src/render/palette.ts @@ -7,7 +7,7 @@ // // The two `sky*` roles are the loading screen's backdrop and progress track (`loading.ts` reads // them by name); `horizon` is the photographed sky's own haze, which is what distance fades into. -import { BoxGeometry, Group, Mesh, MeshStandardMaterial } from "three"; +import { MeshStandardMaterial } from "three"; export const palette = { /** Loading screen backdrop. */ @@ -34,12 +34,3 @@ export function toon(color: number, roughness = 0.72): MeshStandardMaterial { materials.set(key, material); return material; } - -export function curbBlock(width: number, height: number, depth: number, color: number): Group { - const group = new Group(); - const mesh = new Mesh(new BoxGeometry(width, height, depth), toon(color, 0.55)); - mesh.castShadow = true; - mesh.receiveShadow = true; - group.add(mesh); - return group; -} diff --git a/packages/create-threenative/templates/racing/src/render/shapes.ts b/packages/create-threenative/templates/racing/src/render/shapes.ts index 5bac43d10c..7ed736899a 100644 --- a/packages/create-threenative/templates/racing/src/render/shapes.ts +++ b/packages/create-threenative/templates/racing/src/render/shapes.ts @@ -488,31 +488,6 @@ export function treeGeometry(): { canopy: BufferGeometry; trunk: BufferGeometry return { canopy: merged(tiers), trunk: merged([stem]) }; } -/** A sponsor hoarding: two posts and a rail in one piece, the board in another. */ -export function hoardingGeometry(width = 5): { - board: BufferGeometry; - frame: BufferGeometry; -} { - const frame: Mesh[] = []; - for (const side of [-1, 1]) { - const post = new Mesh(new CylinderGeometry(0.06, 0.06, 1.3, 5)); - post.position.set(0, 0.65, (side * width) / 2.6); - frame.push(post); - } - const rail = new Mesh(new BoxGeometry(0.12, 0.08, width)); - rail.position.y = 1.32; - frame.push(rail); - // Upright, not raked. A tilt looked like a fallen board once the row was rotated to face the - // road, because the tilt is applied in the board's own frame and turns into a lean. - // - // The panel reaches the **tarmac**. It used to float 580 mm above it, which read as a fence on - // legs and was worse than that: the board is the barrier's collider, so the gap underneath was a - // gap a car drove straight through and out of the circuit. - const panel = new Mesh(new BoxGeometry(0.08, 1.3, width)); - panel.position.y = 0.65; - return { board: merged([panel]), frame: merged(frame) }; -} - /** A grandstand: a raked deck on legs with a canopy, and a crowd placed into its own batch. */ export function grandstand( structure: Material, diff --git a/packages/create-threenative/templates/racing/src/track/circuit.ts b/packages/create-threenative/templates/racing/src/track/circuit.ts index 9aafc06bc7..e3bf84ad20 100644 --- a/packages/create-threenative/templates/racing/src/track/circuit.ts +++ b/packages/create-threenative/templates/racing/src/track/circuit.ts @@ -466,12 +466,3 @@ export const GRID_DISTANCE = { export function lineDistance(fraction: number): number { return fraction * CIRCUIT.totalLength; } - -/** - * A point on the tarmac: `offset` metres right of the centreline at `distance`, on the banked - * surface rather than on a flat plane through it, so anything parked on the track sits on it. - */ -export function pointOnTrack(distance: number, offset: number, target: Vector3): Vector3 { - const sample = CIRCUIT.at(distance, CIRCUIT.createSample()); - return target.lerpVectors(sample.left, sample.rightEdge, (offset + HALF) / TRACK_WIDTH); -} diff --git a/packages/create-threenative/templates/rain/src/game.ts b/packages/create-threenative/templates/rain/src/game.ts index 4e060e4db6..1fab540fd6 100644 --- a/packages/create-threenative/templates/rain/src/game.ts +++ b/packages/create-threenative/templates/rain/src/game.ts @@ -4,14 +4,7 @@ import { UI_READY_INTENT } from "@threenative/core/ui-layer"; import config from "../threenative.config.js"; import { type IStormAudio, STORM_AUDIO_ENTITY } from "./audio/storm.js"; import { Coast } from "./scenes/Boot.js"; -import { - type GameState, - type PresetName, - type QualityName, - VISIBILITY_INTENT, - type WeatherKey, - intentPatch, -} from "./state.js"; +import { type GameState, VISIBILITY_INTENT, intentPatch } from "./state.js"; const game = defineGame({ input: { @@ -48,32 +41,6 @@ const game = defineGame({ export default game; -/** - * The intents the UI sends, and the payload each one carries. The UI slice owns nothing else: - * every control here is a named intent and a validated payload, and nothing else crosses. - */ -export type RainIntent = - | { readonly name: "closeHelp" } - | { readonly name: "help" } - | { readonly name: "hideUi" } - | { readonly name: "pause" } - | { readonly name: "resetCamera"; readonly payload?: never } - | { readonly name: "resume" } - | { readonly name: "setAudioEnabled"; readonly payload: boolean } - | { readonly name: "setAutoLightning"; readonly payload: boolean } - | { readonly name: "setCinematic"; readonly payload: boolean } - | { readonly name: "setDroplets"; readonly payload: boolean } - | { readonly name: "setFrozen"; readonly payload: boolean } - | { readonly name: "setMuted"; readonly payload: boolean } - | { readonly name: "setPreset"; readonly payload: PresetName } - | { readonly name: "setQuality"; readonly payload: QualityName } - | { readonly name: "setSafe"; readonly payload: boolean } - | { readonly name: "setWeather"; readonly payload: Partial> } - | { readonly name: "showUi" } - | { readonly name: "step"; readonly payload: number } - | { readonly name: "strike"; readonly payload?: never } - | { readonly name: typeof VISIBILITY_INTENT; readonly payload: boolean }; - game.ui.onIntent((intent, payload) => { // The framework's own "my tree has rendered" signal, and the one control that is not a player's: // it arrives on the same door but carries no weather, so it is answered before the validator diff --git a/packages/create-threenative/templates/rts/src/sim/ai.ts b/packages/create-threenative/templates/rts/src/sim/ai.ts index e88e87370c..3e90771672 100644 --- a/packages/create-threenative/templates/rts/src/sim/ai.ts +++ b/packages/create-threenative/templates/rts/src/sim/ai.ts @@ -82,7 +82,6 @@ const _nodes: IResourceNode[] = []; const _gasSites: IResourceNode[] = []; const _bunkers: IEntity[] = []; const _rangers: IEntity[] = []; -const _repair: IEntity[] = []; const _built: IEntity[] = []; const _candidates: IEntity[] = []; const _expanded: IEntity[] = []; diff --git a/packages/create-threenative/templates/rts/src/sim/terrain.ts b/packages/create-threenative/templates/rts/src/sim/terrain.ts index 87b9e1f31e..a74b6753cf 100644 --- a/packages/create-threenative/templates/rts/src/sim/terrain.ts +++ b/packages/create-threenative/templates/rts/src/sim/terrain.ts @@ -122,10 +122,6 @@ export function terrainNoise(x: number, z: number): number { return a * (1 - fz) + b * fz; } -export function padAt(x: number, z: number): IStart | undefined { - return STARTS.find((s) => Math.abs(x - s.x) < 24 && Math.abs(z - s.z) < 23); -} - export function onBridge(x: number, z: number, margin = 0): boolean { return Math.abs(z + 20) <= 3.35 - margin && x >= -33.5 && x <= 9.5; } diff --git a/packages/create-threenative/templates/runner/src/physics.ts b/packages/create-threenative/templates/runner/src/physics.ts index c46ff3a9bf..11a4e561ff 100644 --- a/packages/create-threenative/templates/runner/src/physics.ts +++ b/packages/create-threenative/templates/runner/src/physics.ts @@ -7,7 +7,6 @@ import type { IPhysicsContext } from "@threenative/physics"; * scans for obstacles. Obstacles are fixed bodies that collide with nothing and are only ever * *detected*, which is why they cost a layer and no solver work. */ -export const TRACK_LAYER = 1; export const OBSTACLE_LAYER = 2; export type RunnerPhysics = IPhysicsContext; diff --git a/packages/create-threenative/templates/sailing/src/render/props.ts b/packages/create-threenative/templates/sailing/src/render/props.ts index 75c4e894d7..380e7d01bd 100644 --- a/packages/create-threenative/templates/sailing/src/render/props.ts +++ b/packages/create-threenative/templates/sailing/src/render/props.ts @@ -61,51 +61,6 @@ function belliedSail(width: number, height: number, belly: number, taper = 1): B return geometry; } -/** - * A swallow-tailed pennant, the tail cut back to the middle of the hoist instead of squared off. - * - * The notch is the whole difference: a rectangular flag reads as a card, and at the chase camera's - * distance the shape is all there is. Cutting the last fifth of the fly into two points turns the - * same four columns of canvas into the shape a player has seen on every ship ever drawn. - */ -function swallowtail(width: number, height: number, belly: number): BufferGeometry { - const positions: number[] = []; - // The notch: past 0.8 of the length the fly narrows to a point, so the tail is two points. - const fly = (u: number, v: number): number => (u < 0.8 ? v : v * (1 - (u - 0.8) * 5)); - const at = (u: number, v: number): [number, number, number] => [ - u * width, - (v - 0.5) * height, - Math.sin(Math.PI * u) * Math.sin(Math.PI * v) * belly, - ]; - for (let row = 0; row < 3; row += 1) { - for (let column = 0; column < 4; column += 1) { - const u0 = column / 4; - const u1 = (column + 1) / 4; - const v0 = row / 3; - const v1 = (row + 1) / 3; - const corners = [ - at(u0, fly(u0, v0)), - at(u1, fly(u1, v0)), - at(u1, fly(u1, v1)), - at(u0, fly(u0, v1)), - ]; - for (const triangle of [ - [0, 1, 2], - [0, 2, 3], - ] as const) { - for (const index of triangle) { - const corner = corners[index] as [number, number, number]; - positions.push(...corner); - } - } - } - } - const geometry = new BufferGeometry(); - geometry.setAttribute("position", new BufferAttribute(new Float32Array(positions), 3)); - geometry.computeVertexNormals(); - return geometry; -} - /** * One sail, authored flat, ready for `SoftBody3D` to put the wind in it. * diff --git a/packages/create-threenative/templates/shooter/src/entities/Enemy.ts b/packages/create-threenative/templates/shooter/src/entities/Enemy.ts index de6d308d8e..ced45ab602 100644 --- a/packages/create-threenative/templates/shooter/src/entities/Enemy.ts +++ b/packages/create-threenative/templates/shooter/src/entities/Enemy.ts @@ -223,18 +223,6 @@ const ROUTE_START = ROUTE[0] ?? new Vector3(); * anyone can act on. Peaks are per frame and reset by `beginSquadFrame`, so what comes out is * the worst single frame each stage has ever cost across all five soldiers together. */ -/** - * Seconds between line-of-sight raycasts for one soldier. - * - * The cheap half of `#canSee` — range and view cone — still runs every frame, so a soldier turning - * away or stepping out of range loses sight instantly. Only the raycast is rationed, because it is - * a `raycastAll` against every solid in the town and five soldiers doing that at 60 Hz measured at - * 15.9 ms of a 16.3 ms frame: the entire mid-round hitch, in one call. - * - * A tenth of a second of staleness on "can he see me" is invisible next to `REACTION_SECONDS`, - * which already holds fire for far longer than this after a soldier first spots the player. - */ -const LOS_INTERVAL_SECONDS = 0; /** * Shortest gap between two grid searches for one soldier when only the goal has drifted. * @@ -250,9 +238,6 @@ const LOS_INTERVAL_SECONDS = 0; * per-frame cost. */ const GOAL_REPLAN_SECONDS = 0.15; -/** Spreads the squad's raycasts across frames, so five soldiers never all test on the same one. */ -const losStagger = 0; - /** Blocked-cell bitmaps per collider set, so the squad pays for the nav grid once. */ const NAV_GRIDS = new WeakMap>(); diff --git a/packages/create-threenative/templates/shooter/src/render/camera.ts b/packages/create-threenative/templates/shooter/src/render/camera.ts index 767db7cf6d..b04249b7e0 100644 --- a/packages/create-threenative/templates/shooter/src/render/camera.ts +++ b/packages/create-threenative/templates/shooter/src/render/camera.ts @@ -10,7 +10,7 @@ // puts the crosshair somewhere the body is not. The rule this file exists to // state is the opposite one: compose the camera from the body's *solved* pose, // every frame, with no interpolation of its own. -import { MathUtils, type PerspectiveCamera, type Vector3Like } from "three"; +import { MathUtils, type PerspectiveCamera } from "three"; /** The numbers a first-person camera is built from, in one table. */ export const FIRST_PERSON = { @@ -80,14 +80,3 @@ export function firstPersonFov(current: number, aiming: boolean, dt: number): nu dt, ); } - -/** Where a shot leaves from: the camera basis, never the body transform. */ -export function aimBasis(camera: PerspectiveCamera): { - origin: Vector3Like; - direction: Vector3Like; -} { - camera.updateMatrixWorld(true); - const origin = camera.position; - const direction = camera.getWorldDirection(camera.position.clone()); - return { origin, direction: direction.normalize() }; -} diff --git a/packages/create-threenative/templates/shooter/src/render/gunfx.ts b/packages/create-threenative/templates/shooter/src/render/gunfx.ts index af93b7ca8a..6fc3926a87 100644 --- a/packages/create-threenative/templates/shooter/src/render/gunfx.ts +++ b/packages/create-threenative/templates/shooter/src/render/gunfx.ts @@ -240,7 +240,6 @@ const scratchBasisT2 = new Vector3(); const scratchDir = new Vector3(); const scratchAxisX = new Vector3(); const scratchAxisZ = new Vector3(); -const scratchAxisY = new Vector3(); const scratchMatrix = new Matrix4(); const scratchColour = new Color(); diff --git a/packages/create-threenative/templates/shooter/src/render/scale.ts b/packages/create-threenative/templates/shooter/src/render/scale.ts index e33f54b6d4..0e28587b98 100644 --- a/packages/create-threenative/templates/shooter/src/render/scale.ts +++ b/packages/create-threenative/templates/shooter/src/render/scale.ts @@ -1,9 +1,8 @@ // The one place a real-world size is declared. // // One metre is one metre. Everything that builds or measures a physical object imports -// from here: the game so it renders at the right size, and `tools/scale-audit.mjs` so a -// mismatch fails a command instead of hiding in a screenshot. A model that arrives in -// centimetres is normalised on load — never accommodated by tuning a literal beside it. +// from here, so the game renders at the right size. A model that arrives in centimetres is +// normalised on load — never accommodated by tuning a literal beside it. // // This file is why the 2.68 m soldier, the 1.19 m AK and the 1.43 m viewmodel cannot // come back: there is no second place to write a size. @@ -63,93 +62,3 @@ export const scale = { /** Open drum dimensions used by the range shell and its arc colliders. */ drum: { radius: 3.7, height: 1.55 }, } as const; - -// Measurement and normalisation live in the engine now: `normaliseToMetres` from -// the core package does crown-bone height and longest-axis scaling. This file keeps -// only this game's real-world metre table and the audit rules that check it. - -/** Which measured dimension a check reads. */ -export type SizeAxis = "height" | "width" | "depth" | "longest"; - -/** - * One rule the audit enforces. - * - * `band` pins a size within a fraction either way; `max` only forbids oversize, for - * things that are legitimately allowed to vary downward (a head plate is a small - * silhouette, but no silhouette may be taller than a person); `match` pins one subject to - * another *measured* subject rather than to a number. - * - * `match` exists because the first run of this audit passed the enemy hitbox — 1.8 m sits - * comfortably inside the human band — while the soldier it is supposed to wrap measured - * 2.678 m. An absolute band cannot see that: only the relationship can. - */ -export type SizeCheck = { - /** `Object3D.name` the audit looks for, or a synthetic key the audit supplies. */ - readonly subject: string; - readonly axis: SizeAxis; - /** The declared size. Ignored for `match`, which reads `reference` instead. */ - readonly metres: number; - /** Fractional tolerance for `band` and `match`; ignored for `max`. */ - readonly tolerance?: number; - readonly kind: "band" | "max" | "match"; - /** For `match`: the subject whose measured size this one must equal. */ - readonly reference?: string; - /** Every subject must be found. Set only for things that may legitimately be absent. */ - readonly optional?: boolean; - readonly note?: string; -}; - -/** A Mediterranean street door reads two metres tall plus a lintel margin. */ -const DOOR_HEIGHT = 2.3; - -export const SCALE_EXPECTATIONS: readonly SizeCheck[] = [ - { - subject: "enemy", - axis: "height", - metres: scale.humanHeight, - tolerance: 0.06, - kind: "band", - note: "bone-accurate head-top, not a bind-pose box", - }, - { - subject: "enemy-weapon", - axis: "longest", - metres: scale.rifleLength, - tolerance: 0.12, - kind: "band", - }, - { - subject: "player-viewmodel", - axis: "longest", - metres: scale.rifleLength, - tolerance: 0.15, - kind: "band", - note: "a viewmodel may be read slightly large, but not a different weapon", - }, - { - subject: "player", - axis: "height", - metres: scale.humanHeight, - tolerance: 0.06, - kind: "band", - }, - { - // The hitbox is what the player actually shoots at, so it is checked against the body - // it wraps, not against the human band — a hitbox that is right in the abstract and - // wrong about this model is the bug that made headshots impossible. - subject: "enemy-hitbox", - axis: "height", - metres: scale.humanHeight, - reference: "enemy", - tolerance: 0.06, - kind: "match", - note: "must equal the rendered enemy height", - }, - { subject: "target-plate", axis: "height", metres: scale.silhouette.height, kind: "max" }, - { subject: "target-plate", axis: "width", metres: scale.silhouette.width * 1.5, kind: "max" }, - // Town props: a man-size crate, an oil drum, and a door that a person opens. - { subject: "crate", axis: "height", metres: 0.78, tolerance: 0.1, kind: "band" }, - { subject: "barrel", axis: "height", metres: 0.92, tolerance: 0.12, kind: "band" }, - { subject: "door", axis: "height", metres: DOOR_HEIGHT, tolerance: 0.08, kind: "band" }, - { subject: "muzzle-flash", axis: "longest", metres: scale.muzzleFlash, kind: "max" }, -]; diff --git a/packages/create-threenative/templates/shooter/src/render/vehicles.ts b/packages/create-threenative/templates/shooter/src/render/vehicles.ts index acd645c882..b3cefb98ee 100644 --- a/packages/create-threenative/templates/shooter/src/render/vehicles.ts +++ b/packages/create-threenative/templates/shooter/src/render/vehicles.ts @@ -252,8 +252,6 @@ const VAN = { track: 0.8, archRadius: 0.48, archCentre: 0.58, - /** Half-width including the wing mirrors — outside the collider on purpose. */ - halfWithMirrors: 1.14, } as const; function vanParts(batch: PartBatch, m: VehicleMaterials): void { @@ -419,21 +417,6 @@ export function addVan( }; } -/** Footprint a placement table can be audited against without building anything. */ -export function vanFootprint(at: VanPlacement): { - readonly minX: number; - readonly maxX: number; - readonly minZ: number; - readonly maxZ: number; -} { - const cos = Math.abs(Math.cos(at.yaw)); - const sin = Math.abs(Math.sin(at.yaw)); - const halfX = (VAN.bumper - VAN.tail) / 2; - const spanX = halfX * cos + VAN.halfWithMirrors * sin; - const spanZ = halfX * sin + VAN.halfWithMirrors * cos; - return { minX: at.x - spanX, maxX: at.x + spanX, minZ: at.z - spanZ, maxZ: at.z + spanZ }; -} - // --------------------------------------------------------------------------- // The fishing boat // --------------------------------------------------------------------------- diff --git a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts index 54cf5c7b3f..01a1766c7f 100644 --- a/packages/create-threenative/templates/tower-defense/src/render/shapes.ts +++ b/packages/create-threenative/templates/tower-defense/src/render/shapes.ts @@ -186,9 +186,6 @@ export function pads(places: readonly (readonly [number, number])[]): Group { return group; } -/** How high a tower's muzzle stands, for beams and shells to leave from. */ -export const MUZZLE_HEIGHT = 1.28; - export interface ITowerModel { readonly group: Group; /** Turns to face a target; its local +z is the way it shoots. */ diff --git a/packages/playtest/__tests__/display-advice.spec.ts b/packages/playtest/__tests__/display-advice.spec.ts index 74ac22ecbc..f3ec386c6c 100644 --- a/packages/playtest/__tests__/display-advice.spec.ts +++ b/packages/playtest/__tests__/display-advice.spec.ts @@ -14,7 +14,7 @@ const scannedTrees = ["packages/playtest/src", "packages/runtime-native/src"]; // Records that quote the old advice to analyse it sit outside the scanned trees by design. // An allowlist entry inside a scanned tree would silently exempt it, so the disjointness is // asserted below rather than trusted. -const allowedXvfbRunMentions = ["docs/", "scripts/analyze-prd-075-render-advisor.mjs"]; +const allowedXvfbRunMentions = ["docs/"]; // A mention is fine when its sentence carries the warning; an instruction to run it is not. // The warning wraps, so read the sentence around the mention rather than the line. diff --git a/packages/playtest/src/evaluators/render-chain.ts b/packages/playtest/src/evaluators/render-chain.ts index 115176eb22..18d27b9e2a 100644 --- a/packages/playtest/src/evaluators/render-chain.ts +++ b/packages/playtest/src/evaluators/render-chain.ts @@ -196,20 +196,6 @@ function selectExpectation(ctx: IEvaluationContext, assertion: IPlaytestRenderCh }; } -export function renderChainAssertionIsMeaningful(assertion: IPlaytestRenderChainAssertion): boolean { - return assertion.tier !== undefined - || assertion.stages !== undefined - || assertion.contributions !== undefined - || assertion.velocity !== undefined - || Object.values(assertion.perAdapter ?? {}).some( - (branch) => branch !== undefined - && (branch.tier !== undefined - || branch.stages !== undefined - || branch.contributions !== undefined - || branch.velocity !== undefined), - ); -} - function emitStageCheck( ctx: IEvaluationContext, provenance: ISelectedExpectation["provenance"], diff --git a/packages/raw-unreal/src/bulk-source-model.ts b/packages/raw-unreal/src/bulk-source-model.ts index 29db181e98..50f5c8c1d9 100644 --- a/packages/raw-unreal/src/bulk-source-model.ts +++ b/packages/raw-unreal/src/bulk-source-model.ts @@ -149,7 +149,7 @@ function expandUe4Instances( /** Builds geometry from a UE4 MeshDescription: every triangle's corners come from the file's * own triangle container, and its section from the polygon group its polygon belongs to. */ -export function buildGeometryFromUe4MeshDescription( +function buildGeometryFromUe4MeshDescription( description: IUe4MeshDescription, options: GeometryOptions, ): IGeometryBuild { diff --git a/packages/raw-unreal/src/raw-mesh.ts b/packages/raw-unreal/src/raw-mesh.ts index 6284165e23..e1b54f89e6 100644 --- a/packages/raw-unreal/src/raw-mesh.ts +++ b/packages/raw-unreal/src/raw-mesh.ts @@ -1,8 +1,8 @@ import { UAssetError } from "./errors.js"; /** FRawMesh serialization version; both known values differ only by one trailing array. */ -export const RAW_MESH_VER_INITIAL = 0; -export const RAW_MESH_VER_REMOVE_ZERO_TRIANGLE_SECTIONS = 1; +const RAW_MESH_VER_INITIAL = 0; +const RAW_MESH_VER_REMOVE_ZERO_TRIANGLE_SECTIONS = 1; const MAX_MESH_TEXTURE_COORDS = 8; const MAX_ARRAY_ELEMENTS = 200_000_000; diff --git a/packages/runtime-native/android/app/src/main/assets/scripts/glb-parser.js b/packages/runtime-native/android/app/src/main/assets/scripts/glb-parser.js deleted file mode 100644 index 4b1901315d..0000000000 --- a/packages/runtime-native/android/app/src/main/assets/scripts/glb-parser.js +++ /dev/null @@ -1,294 +0,0 @@ -/** - * Simple GLB Parser for QuickJS - * - * Parses GLB (binary GLTF) files and returns mesh data, materials, and images. - * Works with basic GLB files - no Draco compression, no extensions. - */ - -/** - * Parse a GLB ArrayBuffer and return parsed data - * @param {ArrayBuffer} buffer - The GLB file as ArrayBuffer - * @returns {Object} Parsed GLTF data with meshes, materials, images, etc. - */ -function parseGLB(buffer) { - const dataView = new DataView(buffer); - let offset = 0; - - // GLB Header (12 bytes) - const magic = dataView.getUint32(offset, true); offset += 4; - const version = dataView.getUint32(offset, true); offset += 4; - const length = dataView.getUint32(offset, true); offset += 4; - - if (magic !== 0x46546C67) { // 'glTF' in little-endian - throw new Error('Invalid GLB magic number'); - } - if (version !== 2) { - throw new Error(`Only GLTF 2.0 is supported, got version ${version}`); - } - - // Parse chunks - let jsonChunk = null; - let binChunk = null; - - while (offset < length) { - const chunkLength = dataView.getUint32(offset, true); offset += 4; - const chunkType = dataView.getUint32(offset, true); offset += 4; - - if (chunkType === 0x4E4F534A) { // 'JSON' - const jsonBytes = new Uint8Array(buffer, offset, chunkLength); - const jsonString = new TextDecoder('utf-8').decode(jsonBytes); - jsonChunk = JSON.parse(jsonString); - } else if (chunkType === 0x004E4942) { // 'BIN\0' - binChunk = buffer.slice(offset, offset + chunkLength); - } - - offset += chunkLength; - } - - if (!jsonChunk) { - throw new Error('No JSON chunk found in GLB'); - } - - return processGLTF(jsonChunk, binChunk); -} - -/** - * Process the GLTF JSON and extract usable data - */ -function processGLTF(gltf, binBuffer) { - const result = { - meshes: [], - materials: [], - textures: [], - images: [], - samplers: [], - scenes: [], - nodes: [], - }; - - // Helper to get accessor data - function getAccessorData(accessorIndex, asUint32 = false) { - const accessor = gltf.accessors[accessorIndex]; - const bufferView = gltf.bufferViews[accessor.bufferView]; - - const byteOffset = (bufferView.byteOffset || 0) + (accessor.byteOffset || 0); - const count = accessor.count; - const componentType = accessor.componentType; - const type = accessor.type; - - const componentCounts = { - 'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16 - }; - const components = componentCounts[type] || 1; - const totalComponents = count * components; - - // Create appropriate typed array based on component type - let data; - if (componentType === 5126) { // FLOAT - data = new Float32Array(binBuffer, byteOffset, totalComponents); - } else if (componentType === 5123) { // UNSIGNED_SHORT - data = new Uint16Array(binBuffer, byteOffset, totalComponents); - if (asUint32) { - // Convert to Uint32 for indices if needed - const uint32Data = new Uint32Array(totalComponents); - for (let i = 0; i < totalComponents; i++) uint32Data[i] = data[i]; - return uint32Data; - } - } else if (componentType === 5125) { // UNSIGNED_INT - data = new Uint32Array(binBuffer, byteOffset, totalComponents); - } else if (componentType === 5121) { // UNSIGNED_BYTE - data = new Uint8Array(binBuffer, byteOffset, totalComponents); - if (asUint32) { - const uint32Data = new Uint32Array(totalComponents); - for (let i = 0; i < totalComponents; i++) uint32Data[i] = data[i]; - return uint32Data; - } - } else { - console.log(`Unknown component type: ${componentType}`); - data = new Float32Array(binBuffer, byteOffset, totalComponents); - } - - // Return a copy to avoid alignment issues - if (data instanceof Float32Array) { - return new Float32Array(data); - }if (data instanceof Uint32Array) { - return new Uint32Array(data); - }if (data instanceof Uint16Array) { - return new Uint16Array(data); - } - return data; - } - - // Process materials - if (gltf.materials) { - for (let i = 0; i < gltf.materials.length; i++) { - const mat = gltf.materials[i]; - const pbr = mat.pbrMetallicRoughness || {}; - - result.materials.push({ - name: mat.name || `Material_${i}`, - baseColorFactor: pbr.baseColorFactor || [1, 1, 1, 1], - metallicFactor: pbr.metallicFactor !== undefined ? pbr.metallicFactor : 1, - roughnessFactor: pbr.roughnessFactor !== undefined ? pbr.roughnessFactor : 1, - baseColorTextureIndex: pbr.baseColorTexture?.index ?? -1, - metallicRoughnessTextureIndex: pbr.metallicRoughnessTexture?.index ?? -1, - normalTextureIndex: mat.normalTexture?.index ?? -1, - emissiveTextureIndex: mat.emissiveTexture?.index ?? -1, - occlusionTextureIndex: mat.occlusionTexture?.index ?? -1, - emissiveFactor: mat.emissiveFactor || [0, 0, 0], - alphaMode: mat.alphaMode || 'OPAQUE', - alphaCutoff: mat.alphaCutoff || 0.5, - doubleSided: mat.doubleSided || false, - }); - } - } - - // Process images - if (gltf.images) { - for (let i = 0; i < gltf.images.length; i++) { - const img = gltf.images[i]; - let imageData = null; - const mimeType = img.mimeType || 'image/png'; - - if (img.bufferView !== undefined) { - const bv = gltf.bufferViews[img.bufferView]; - const byteOffset = bv.byteOffset || 0; - const byteLength = bv.byteLength; - imageData = new Uint8Array(binBuffer, byteOffset, byteLength); - // Make a copy - imageData = new Uint8Array(imageData); - } - - result.images.push({ - name: img.name || `Image_${i}`, - uri: img.uri || '', - mimeType: mimeType, - data: imageData, - }); - } - } - - // Process samplers - if (gltf.samplers) { - for (let i = 0; i < gltf.samplers.length; i++) { - const sampler = gltf.samplers[i]; - result.samplers.push({ - magFilter: sampler.magFilter || 9729, // LINEAR - minFilter: sampler.minFilter || 9729, // LINEAR - wrapS: sampler.wrapS || 10497, // REPEAT - wrapT: sampler.wrapT || 10497, // REPEAT - }); - } - } - - // Process textures (maps texture index to image index) - if (gltf.textures) { - for (let i = 0; i < gltf.textures.length; i++) { - const tex = gltf.textures[i]; - result.textures.push({ - name: tex.name || `Texture_${i}`, - imageIndex: tex.source !== undefined ? tex.source : -1, - samplerIndex: tex.sampler !== undefined ? tex.sampler : -1, - }); - } - } - - // Process meshes - if (gltf.meshes) { - for (let mi = 0; mi < gltf.meshes.length; mi++) { - const mesh = gltf.meshes[mi]; - const meshData = { - name: mesh.name || `Mesh_${mi}`, - primitives: [], - }; - - for (let pi = 0; pi < mesh.primitives.length; pi++) { - const prim = mesh.primitives[pi]; - const primData = { - materialIndex: prim.material !== undefined ? prim.material : -1, - positions: null, - normals: null, - texcoords: null, - indices: null, - vertexCount: 0, - indexCount: 0, - }; - - // Get attributes - if (prim.attributes.POSITION !== undefined) { - primData.positions = getAccessorData(prim.attributes.POSITION); - primData.vertexCount = primData.positions.length / 3; - } - if (prim.attributes.NORMAL !== undefined) { - primData.normals = getAccessorData(prim.attributes.NORMAL); - } - if (prim.attributes.TEXCOORD_0 !== undefined) { - primData.texcoords = getAccessorData(prim.attributes.TEXCOORD_0); - } - - // Get indices - if (prim.indices !== undefined) { - primData.indices = getAccessorData(prim.indices, true); - primData.indexCount = primData.indices.length; - } - - meshData.primitives.push(primData); - } - - result.meshes.push(meshData); - } - } - - // Process nodes - if (gltf.nodes) { - for (let ni = 0; ni < gltf.nodes.length; ni++) { - const node = gltf.nodes[ni]; - result.nodes.push({ - name: node.name || `Node_${ni}`, - meshIndex: node.mesh !== undefined ? node.mesh : -1, - children: node.children || [], - translation: node.translation || [0, 0, 0], - rotation: node.rotation || [0, 0, 0, 1], - scale: node.scale || [1, 1, 1], - matrix: node.matrix || null, - }); - } - } - - // Process scenes - if (gltf.scenes) { - for (let si = 0; si < gltf.scenes.length; si++) { - const scene = gltf.scenes[si]; - result.scenes.push({ - name: scene.name || `Scene_${si}`, - nodes: scene.nodes || [], - }); - } - } - - result.defaultScene = gltf.scene !== undefined ? gltf.scene : 0; - - return result; -} - -/** - * Load a GLB file from URL and parse it - * @param {string} url - URL to the GLB file - * @returns {Promise} Parsed GLTF data - */ -async function loadGLB(url) { - console.log(`loadGLB: Fetching ${url}`); - const response = await fetch(url); - if (!response.ok) { - throw new Error(`Failed to fetch GLB: ${response.status}`); - } - const buffer = await response.arrayBuffer(); - console.log(`loadGLB: Fetched ${buffer.byteLength} bytes`); - return parseGLB(buffer); -} - -// Export for use -globalThis.parseGLB = parseGLB; -globalThis.loadGLB = loadGLB; - -console.log('GLB Parser loaded'); diff --git a/packages/runtime-native/android/app/src/main/assets/scripts/gltf-viewer.js b/packages/runtime-native/android/app/src/main/assets/scripts/gltf-viewer.js deleted file mode 100644 index f0b33b54c0..0000000000 --- a/packages/runtime-native/android/app/src/main/assets/scripts/gltf-viewer.js +++ /dev/null @@ -1,1077 +0,0 @@ -/** - * iOS GLTF Viewer with Touch Rotation - * - * Loads the DamagedHelmet model and allows touch-based rotation. - * Uses the same PBR rendering approach as the desktop gltf-render example. - */ - -console.log("iOS GLTF Viewer starting..."); - -// Skybox shader - renders the environment cube as background -// Uses vertex buffer instead of array indexing (wgpu-native/naga limitation) -const skyboxShaderCode = /* wgsl */` - struct SceneUniforms { - viewMatrix: mat4x4f, - projectionMatrix: mat4x4f, - cameraPosition: vec3f, - environmentIntensity: f32, - } - - @group(0) @binding(0) var scene: SceneUniforms; - @group(0) @binding(1) var envMap: texture_cube; - @group(0) @binding(2) var envSampler: sampler; - - struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) uvw: vec3f, - } - - @vertex - fn vs_main(@location(0) inPos: vec3f) -> VertexOutput { - var output: VertexOutput; - output.uvw = inPos; - var view = scene.viewMatrix; - view[3] = vec4f(0.0, 0.0, 0.0, 1.0); - let clipPos = scene.projectionMatrix * view * vec4f(inPos, 1.0); - output.position = clipPos.xyww; - return output; - } - - @fragment - fn fs_main(input: VertexOutput) -> @location(0) vec4f { - var color = textureSample(envMap, envSampler, normalize(input.uvw)).rgb; - color *= scene.environmentIntensity; - color = color / (color + vec3f(1.0)); - color = pow(color, vec3f(1.0 / 2.2)); - return vec4f(color, 1.0); - } -`; - -// Skybox cube vertex data (36 vertices for 6 faces) -const skyboxVertices = new Float32Array([ - -1, 1, -1, -1, -1, -1, 1, -1, -1, - 1, -1, -1, 1, 1, -1, -1, 1, -1, - -1, -1, 1, -1, -1, -1, -1, 1, -1, - -1, 1, -1, -1, 1, 1, -1, -1, 1, - 1, -1, -1, 1, -1, 1, 1, 1, 1, - 1, 1, 1, 1, 1, -1, 1, -1, -1, - -1, -1, 1, -1, 1, 1, 1, 1, 1, - 1, 1, 1, 1, -1, 1, -1, -1, 1, - -1, 1, -1, 1, 1, -1, 1, 1, 1, - 1, 1, 1, -1, 1, 1, -1, 1, -1, - -1, -1, -1, -1, -1, 1, 1, -1, -1, - 1, -1, -1, -1, -1, 1, 1, -1, 1 -]); - -// PBR Shader with IBL -const pbrShaderCode = /* wgsl */` - const PI = 3.14159265359; - - struct SceneUniforms { - viewMatrix: mat4x4f, - projectionMatrix: mat4x4f, - cameraPosition: vec3f, - environmentIntensity: f32, - } - - struct ModelUniforms { - modelMatrix: mat4x4f, - normalMatrix: mat4x4f, - } - - struct MaterialUniforms { - baseColor: vec3f, - metallic: f32, - roughness: f32, - emissiveFactor: f32, - textureFlags: u32, - _pad: f32, - } - - @group(0) @binding(0) var scene: SceneUniforms; - @group(0) @binding(1) var envMap: texture_cube; - @group(0) @binding(2) var envSampler: sampler; - - @group(1) @binding(0) var model: ModelUniforms; - - @group(2) @binding(0) var material: MaterialUniforms; - @group(2) @binding(1) var texSampler: sampler; - @group(2) @binding(2) var baseColorTex: texture_2d; - @group(2) @binding(3) var normalTex: texture_2d; - @group(2) @binding(4) var metallicRoughnessTex: texture_2d; - @group(2) @binding(5) var emissiveTex: texture_2d; - @group(2) @binding(6) var occlusionTex: texture_2d; - - struct VertexInput { - @location(0) position: vec3f, - @location(1) normal: vec3f, - @location(2) uv: vec2f, - } - - struct VertexOutput { - @builtin(position) position: vec4f, - @location(0) worldPosition: vec3f, - @location(1) worldNormal: vec3f, - @location(2) uv: vec2f, - } - - @vertex - fn vs_main(input: VertexInput) -> VertexOutput { - var output: VertexOutput; - let worldPos = model.modelMatrix * vec4f(input.position, 1.0); - output.worldPosition = worldPos.xyz; - output.position = scene.projectionMatrix * scene.viewMatrix * worldPos; - output.worldNormal = (model.normalMatrix * vec4f(input.normal, 0.0)).xyz; - output.uv = input.uv; - return output; - } - - fn distributionGGX(N: vec3f, H: vec3f, roughness: f32) -> f32 { - let a = roughness * roughness; - let a2 = a * a; - let NdotH = max(dot(N, H), 0.0); - let NdotH2 = NdotH * NdotH; - let num = a2; - let denom = NdotH2 * (a2 - 1.0) + 1.0; - return num / (PI * denom * denom); - } - - fn geometrySchlickGGX(NdotV: f32, roughness: f32) -> f32 { - let r = roughness + 1.0; - let k = (r * r) / 8.0; - return NdotV / (NdotV * (1.0 - k) + k); - } - - fn geometrySmith(N: vec3f, V: vec3f, L: vec3f, roughness: f32) -> f32 { - let NdotV = max(dot(N, V), 0.0); - let NdotL = max(dot(N, L), 0.0); - return geometrySchlickGGX(NdotV, roughness) * geometrySchlickGGX(NdotL, roughness); - } - - fn fresnelSchlick(cosTheta: f32, F0: vec3f) -> vec3f { - return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0); - } - - const HAS_BASE_COLOR_TEX = 1u; - const HAS_NORMAL_TEX = 2u; - const HAS_METALLIC_ROUGHNESS_TEX = 4u; - const HAS_EMISSIVE_TEX = 8u; - const HAS_OCCLUSION_TEX = 16u; - - @fragment - fn fs_main(input: VertexOutput) -> @location(0) vec4f { - var N = normalize(input.worldNormal); - let V = normalize(scene.cameraPosition - input.worldPosition); - let uv = input.uv; - - var baseColor = material.baseColor; - if ((material.textureFlags & HAS_BASE_COLOR_TEX) != 0u) { - let texColor = textureSample(baseColorTex, texSampler, uv); - baseColor = baseColor * texColor.rgb; - } - - if ((material.textureFlags & HAS_NORMAL_TEX) != 0u) { - let mapNormal = textureSample(normalTex, texSampler, uv).xyz * 2.0 - 1.0; - let Q1 = dpdx(input.worldPosition); - let Q2 = dpdy(input.worldPosition); - let st1 = dpdx(uv); - let st2 = dpdy(uv); - let T = normalize(Q1 * st2.y - Q2 * st1.y); - let B = -normalize(cross(N, T)); - let TBN = mat3x3f(T, B, N); - N = normalize(TBN * mapNormal); - } - - var metallic = material.metallic; - var roughness = material.roughness; - if ((material.textureFlags & HAS_METALLIC_ROUGHNESS_TEX) != 0u) { - let mrSample = textureSample(metallicRoughnessTex, texSampler, uv); - roughness = mrSample.g * material.roughness; - metallic = mrSample.b * material.metallic; - } - roughness = max(roughness, 0.04); - - var emissive = vec3f(0.0); - if ((material.textureFlags & HAS_EMISSIVE_TEX) != 0u) { - emissive = textureSample(emissiveTex, texSampler, uv).rgb * material.emissiveFactor; - } - - var occlusion = 1.0; - if ((material.textureFlags & HAS_OCCLUSION_TEX) != 0u) { - occlusion = textureSample(occlusionTex, texSampler, uv).r; - } - - var F0 = vec3f(0.04); - F0 = mix(F0, baseColor, metallic); - - let R = reflect(-V, N); - let NdotV = max(dot(N, V), 0.0); - - let numLevels = f32(textureNumLevels(envMap)); - let lod = roughness * (numLevels - 1.0); - let iblSpecular = textureSampleLevel(envMap, envSampler, R, lod).rgb; - - let diffuseLOD = numLevels - 2.0; - let skyColor = textureSampleLevel(envMap, envSampler, vec3f(0.0, 1.0, 0.0), diffuseLOD).rgb; - let groundColor = textureSampleLevel(envMap, envSampler, vec3f(0.0, -1.0, 0.0), diffuseLOD).rgb; - let hemiMix = N.y * 0.5 + 0.5; - let iblDiffuse = mix(groundColor, skyColor, hemiMix); - - let F_IBL = fresnelSchlick(NdotV, F0); - let kS_IBL = F_IBL; - var kD_IBL = vec3f(1.0) - kS_IBL; - kD_IBL *= 1.0 - metallic; - - let ambientIBL = (kD_IBL * baseColor * iblDiffuse + iblSpecular * F_IBL) * scene.environmentIntensity * occlusion; - - // Direct sunlight - var Lo = vec3f(0.0); - let sunDir = normalize(vec3f(1.0, 1.0, 0.5)); - let sunColor = vec3f(1.0, 0.98, 0.95); - let sunIntensity = 5.0; - - let L = sunDir; - let H = normalize(V + L); - let radiance = sunColor * sunIntensity; - - let NDF = distributionGGX(N, H, roughness); - let G = geometrySmith(N, V, L, roughness); - let F = fresnelSchlick(max(dot(H, V), 0.0), F0); - - let numerator = NDF * G * F; - let NdotL = max(dot(N, L), 0.0); - let denominator = 4.0 * NdotV * NdotL + 0.0001; - let specular = numerator / denominator; - - let kS = F; - var kD = vec3f(1.0) - kS; - kD *= 1.0 - metallic; - - Lo = (kD * baseColor / PI + specular) * radiance * NdotL; - - var color = ambientIBL + Lo + emissive; - color = color / (color + vec3f(1.0)); - color = pow(color, vec3f(1.0 / 2.2)); - - return vec4f(color, 1.0); - } -`; - -// Global state for touch rotation -let rotationX = 0; -let rotationY = 0; -let lastTouchX = 0; -let lastTouchY = 0; -let isDragging = false; - -// Handle touch/mouse events -function setupTouchHandlers() { - const canvasEl = document.getElementById('canvas'); - if (!canvasEl) { - console.log("Canvas not found, touch handlers not set up"); - return; - } - - // Touch events - canvasEl.addEventListener('touchstart', (e) => { - e.preventDefault(); - if (e.touches.length > 0) { - isDragging = true; - lastTouchX = e.touches[0].clientX; - lastTouchY = e.touches[0].clientY; - } - }); - - canvasEl.addEventListener('touchmove', (e) => { - e.preventDefault(); - if (isDragging && e.touches.length > 0) { - const deltaX = e.touches[0].clientX - lastTouchX; - const deltaY = e.touches[0].clientY - lastTouchY; - rotationY += deltaX * 0.01; - rotationX += deltaY * 0.01; - // Clamp vertical rotation - rotationX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotationX)); - lastTouchX = e.touches[0].clientX; - lastTouchY = e.touches[0].clientY; - } - }); - - canvasEl.addEventListener('touchend', () => { - isDragging = false; - }); - - // Mouse events (for simulator) - canvasEl.addEventListener('mousedown', (e) => { - isDragging = true; - lastTouchX = e.clientX; - lastTouchY = e.clientY; - }); - - canvasEl.addEventListener('mousemove', (e) => { - if (isDragging) { - const deltaX = e.clientX - lastTouchX; - const deltaY = e.clientY - lastTouchY; - rotationY += deltaX * 0.01; - rotationX += deltaY * 0.01; - rotationX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, rotationX)); - lastTouchX = e.clientX; - lastTouchY = e.clientY; - } - }); - - canvasEl.addEventListener('mouseup', () => { - isDragging = false; - }); - - console.log("Touch handlers set up"); -} - -// HDR Loader -function parseHDR(buffer) { - const data = new Uint8Array(buffer); - let pos = 0; - - while (pos < data.length) { - let line = ''; - while (pos < data.length && data[pos] !== 0x0a) { - line += String.fromCharCode(data[pos]); - pos++; - } - pos++; - if (line.endsWith('\r')) line = line.slice(0, -1); - if (line.length === 0) break; - } - - let width = 0; - let height = 0; - let resLine = ''; - while (pos < data.length && data[pos] !== 0x0a) { - resLine += String.fromCharCode(data[pos]); - pos++; - } - pos++; - if (resLine.endsWith('\r')) resLine = resLine.slice(0, -1); - - const parts = resLine.trim().split(/\s+/); - for (let i = 0; i < parts.length; i++) { - if (parts[i] === '-Y' || parts[i] === '+Y') height = Number.parseInt(parts[i+1]); - if (parts[i] === '+X' || parts[i] === '-X') width = Number.parseInt(parts[i+1]); - } - - if (width === 0 || height === 0) { - throw new Error(`HDR: Invalid resolution: "${resLine}"`); - } - - const floatData = new Float32Array(width * height * 4); - const scanlineBuffer = new Uint8Array(width * 4); - let ptr = pos; - - for (let y = 0; y < height; y++) { - const idxStart = y * width * 4; - - if (ptr + 4 <= data.length && data[ptr] === 2 && data[ptr+1] === 2 && - data[ptr+2] === ((width >> 8) & 0xFF) && data[ptr+3] === (width & 0xFF)) { - ptr += 4; - - for (let ch = 0; ch < 4; ch++) { - let extracted = 0; - while (extracted < width) { - const val = data[ptr++]; - if (val > 128) { - const count = val - 128; - const runVal = data[ptr++]; - for (let i = 0; i < count; i++) { - scanlineBuffer[extracted + ch * width] = runVal; - extracted++; - } - } else { - const count = val; - for (let i = 0; i < count; i++) { - scanlineBuffer[extracted + ch * width] = data[ptr++]; - extracted++; - } - } - } - } - - for (let x = 0; x < width; x++) { - const r = scanlineBuffer[x]; - const g = scanlineBuffer[x + width]; - const b = scanlineBuffer[x + 2 * width]; - const e = scanlineBuffer[x + 3 * width]; - - const outIdx = idxStart + x * 4; - if (e === 0) { - floatData[outIdx] = 0; floatData[outIdx+1] = 0; floatData[outIdx+2] = 0; floatData[outIdx+3] = 1; - } else { - const f = 2.0 ** (e - 128 - 8); - floatData[outIdx] = r * f; - floatData[outIdx+1] = g * f; - floatData[outIdx+2] = b * f; - floatData[outIdx+3] = 1; - } - } - } else { - for (let x = 0; x < width; x++) { - const r = data[ptr++]; - const g = data[ptr++]; - const b = data[ptr++]; - const e = data[ptr++]; - const outIdx = idxStart + x * 4; - - if (e === 0) { - floatData[outIdx] = 0; floatData[outIdx+1] = 0; floatData[outIdx+2] = 0; floatData[outIdx+3] = 1; - } else { - const f = 2.0 ** (e - 128 - 8); - floatData[outIdx] = r * f; - floatData[outIdx+1] = g * f; - floatData[outIdx+2] = b * f; - floatData[outIdx+3] = 1; - } - } - } - } - - return { width, height, data: floatData }; -} - -function packToHalf(data) { - const out = new Uint16Array(data.length); - const floatView = new Float32Array(1); - const int32View = new Int32Array(floatView.buffer); - const halfMax = 0x7bff; - - for (let i = 0; i < data.length; i++) { - const val = data[i]; - if (Number.isNaN(val)) { out[i] = 0; continue; } - - floatView[0] = val; - const x = int32View[0]; - const bits = (x >> 16) & 0x8000; - let m = (x >> 12) & 0x07ff; - const e = (x >> 23) & 0xff; - - if (e < 103) { out[i] = bits; continue; } - if (e > 142) { out[i] = bits | halfMax; continue; } - if (e < 113) { m |= 0x0800; out[i] = bits | ((m >> (114 - e)) + ((m >> (113 - e)) & 1)); continue; } - out[i] = bits | ((e - 112) << 10) | ((m >> 1) + (m & 1)); - } - return out; -} - -async function loadEnvironmentMap(device, url, resolution = 512) { - console.log(`Loading HDR environment map: ${url}`); - - const response = await fetch(url); - const buffer = await response.arrayBuffer(); - const hdr = parseHDR(buffer); - const width = hdr.width; - const height = hdr.height; - const equirectData = hdr.data; - - console.log(`Loaded HDR: ${width}x${height}`); - - function getDirection(face, u, v) { - const uc = 2.0 * u - 1.0; - const vc = 2.0 * v - 1.0; - switch (face) { - case 0: return [1, -vc, -uc]; - case 1: return [-1, -vc, uc]; - case 2: return [uc, 1, vc]; - case 3: return [uc, -1, -vc]; - case 4: return [uc, -vc, 1]; - case 5: return [-uc, -vc, -1]; - } - return [0, 0, 0]; - } - - function normalize(v) { - const len = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); - return len > 0 ? [v[0]/len, v[1]/len, v[2]/len] : [0, 0, 0]; - } - - function dirToEquirect(dir) { - const d = normalize(dir); - const phi = Math.atan2(d[2], d[0]); - const theta = Math.acos(Math.max(-1, Math.min(1, d[1]))); - const u = (phi + Math.PI) / (2 * Math.PI); - const v = theta / Math.PI; - return [u, v]; - } - - function sampleEquirect(u, v) { - const fx = u * width - 0.5; - const fy = v * height - 0.5; - const x0 = Math.floor(fx); - const y0 = Math.floor(fy); - const x1 = (x0 + 1) % width; - const y1 = Math.min(y0 + 1, height - 1); - const wx = fx - x0; - const wy = fy - y0; - - const getPixel = (x, y) => { - const px = ((x % width) + width) % width; - const py = Math.max(0, Math.min(height - 1, y)); - const idx = (py * width + px) * 4; - return [equirectData[idx], equirectData[idx+1], equirectData[idx+2], equirectData[idx+3]]; - }; - - const p00 = getPixel(x0, y0); - const p10 = getPixel(x1, y0); - const p01 = getPixel(x0, y1); - const p11 = getPixel(x1, y1); - - const r = p00[0]*(1-wx)*(1-wy) + p10[0]*wx*(1-wy) + p01[0]*(1-wx)*wy + p11[0]*wx*wy; - const g = p00[1]*(1-wx)*(1-wy) + p10[1]*wx*(1-wy) + p01[1]*(1-wx)*wy + p11[1]*wx*wy; - const b = p00[2]*(1-wx)*(1-wy) + p10[2]*wx*(1-wy) + p01[2]*(1-wx)*wy + p11[2]*wx*wy; - const a = p00[3]*(1-wx)*(1-wy) + p10[3]*wx*(1-wy) + p01[3]*(1-wx)*wy + p11[3]*wx*wy; - - return [r, g, b, a]; - } - - const mipLevels = Math.floor(Math.log2(resolution)) + 1; - const cubeTexture = device.createTexture({ - label: 'Environment Cubemap HDR', - size: [resolution, resolution, 6], - format: 'rgba16float', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, - mipLevelCount: mipLevels, - }); - - const basePixels = []; - - for (let face = 0; face < 6; face++) { - const faceData = new Float32Array(resolution * resolution * 4); - - for (let y = 0; y < resolution; y++) { - for (let x = 0; x < resolution; x++) { - const u = (x + 0.5) / resolution; - const v = (y + 0.5) / resolution; - const dir = getDirection(face, u, v); - const [eu, ev] = dirToEquirect(dir); - const [r, g, b, a] = sampleEquirect(eu, ev); - - const idx = (y * resolution + x) * 4; - faceData[idx] = r; - faceData[idx + 1] = g; - faceData[idx + 2] = b; - faceData[idx + 3] = a; - } - } - - basePixels.push(faceData); - - const halfData = packToHalf(faceData); - device.queue.writeTexture( - { texture: cubeTexture, origin: [0, 0, face], mipLevel: 0 }, - halfData, - { bytesPerRow: resolution * 8, rowsPerImage: resolution }, - [resolution, resolution, 1] - ); - } - - // Generate mipmaps - let currentRes = resolution; - let currentPixels = basePixels; - - for (let level = 1; level < mipLevels; level++) { - const nextRes = Math.floor(currentRes / 2); - if (nextRes < 1) break; - - const nextPixels = []; - - for (let face = 0; face < 6; face++) { - const src = currentPixels[face]; - const dst = new Float32Array(nextRes * nextRes * 4); - - for (let y = 0; y < nextRes; y++) { - for (let x = 0; x < nextRes; x++) { - const srcX = x * 2; - const srcY = y * 2; - let r = 0; - let g = 0; - let b = 0; - let a = 0; - - for (let dy = 0; dy < 2; dy++) { - for (let dx = 0; dx < 2; dx++) { - const sIdx = ((srcY + dy) * currentRes + (srcX + dx)) * 4; - r += src[sIdx]; g += src[sIdx + 1]; b += src[sIdx + 2]; a += src[sIdx + 3]; - } - } - - const idx = (y * nextRes + x) * 4; - dst[idx] = r / 4; - dst[idx + 1] = g / 4; - dst[idx + 2] = b / 4; - dst[idx + 3] = a / 4; - } - } - - nextPixels.push(dst); - - const halfData = packToHalf(dst); - device.queue.writeTexture( - { texture: cubeTexture, origin: [0, 0, face], mipLevel: level }, - halfData, - { bytesPerRow: nextRes * 8, rowsPerImage: nextRes }, - [nextRes, nextRes, 1] - ); - } - - currentRes = nextRes; - currentPixels = nextPixels; - } - - const view = device.createTextureView(cubeTexture, { - dimension: 'cube', - mipLevelCount: mipLevels, - arrayLayerCount: 6, - }); - const sampler = device.createSampler({ - magFilter: 'linear', - minFilter: 'linear', - mipmapFilter: 'linear', - }); - - console.log(`Created environment cubemap: ${resolution}x${resolution} with ${mipLevels} mip levels`); - - return { texture: cubeTexture, view, sampler }; -} - -// Matrix utilities -function identity() { - return new Float32Array([ - 1, 0, 0, 0, - 0, 1, 0, 0, - 0, 0, 1, 0, - 0, 0, 0, 1, - ]); -} - -function multiply(a, b) { - const result = new Float32Array(16); - for (let i = 0; i < 4; i++) { - for (let j = 0; j < 4; j++) { - let sum = 0; - for (let k = 0; k < 4; k++) { - sum += a[i * 4 + k] * b[k * 4 + j]; - } - result[i * 4 + j] = sum; - } - } - return result; -} - -function rotateX(angle) { - const c = Math.cos(angle); - const s = Math.sin(angle); - return new Float32Array([ - 1, 0, 0, 0, - 0, c, -s, 0, - 0, s, c, 0, - 0, 0, 0, 1, - ]); -} - -function rotateY(angle) { - const c = Math.cos(angle); - const s = Math.sin(angle); - return new Float32Array([ - c, 0, s, 0, - 0, 1, 0, 0, - -s, 0, c, 0, - 0, 0, 0, 1, - ]); -} - -function perspective(fov, aspect, near, far) { - const f = 1.0 / Math.tan(fov / 2); - const nf = 1 / (near - far); - return new Float32Array([ - f / aspect, 0, 0, 0, - 0, f, 0, 0, - 0, 0, (far + near) * nf, -1, - 0, 0, 2 * far * near * nf, 0, - ]); -} - -function lookAt(eye, target, up) { - const zAxis = normalize3(subtract3(eye, target)); - const xAxis = normalize3(cross3(up, zAxis)); - const yAxis = cross3(zAxis, xAxis); - - return new Float32Array([ - xAxis[0], yAxis[0], zAxis[0], 0, - xAxis[1], yAxis[1], zAxis[1], 0, - xAxis[2], yAxis[2], zAxis[2], 0, - -dot3(xAxis, eye), -dot3(yAxis, eye), -dot3(zAxis, eye), 1, - ]); -} - -function subtract3(a, b) { return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; } -function cross3(a, b) { return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; } -function dot3(a, b) { return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; } -function normalize3(v) { - const len = Math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); - return len > 0 ? [v[0] / len, v[1] / len, v[2] / len] : [0, 0, 0]; -} - -// Main initialization -async function init() { - setupTouchHandlers(); - - const adapter = await navigator.gpu.requestAdapter(); - if (!adapter) throw new Error("No WebGPU adapter"); - - const device = await adapter.requestDevice(); - console.log("WebGPU device acquired"); - - const canvas = document.getElementById('canvas'); - const ctx = canvas.getContext('webgpu'); - const format = navigator.gpu.getPreferredCanvasFormat(); - ctx.configure({ device, format }); - - // Load GLTF - path will be set by the native app - const helmetPath = globalThis.__GLTF_PATH__ || "DamagedHelmet.glb"; - console.log(`Loading GLTF: ${helmetPath}`); - - const gltf = await loadGLTF(helmetPath); - console.log(`Loaded GLTF: ${gltf.meshes.length} meshes`); - - // Load environment map - path will be set by the native app - const envMapPath = globalThis.__ENV_MAP_PATH__ || "sunny_rose_garden_2k.hdr"; - const envCube = await loadEnvironmentMap(device, envMapPath, 256); - - const mesh = gltf.meshes[0]; - const primitive = mesh.primitives[0]; - const mat = gltf.materials[primitive.materialIndex]; - - console.log(`Mesh: ${mesh.name} with ${primitive.vertexCount} vertices`); - - // Create typed array views from ArrayBuffer data - const positions = new Float32Array(primitive.positions); - const normals = primitive.normals ? new Float32Array(primitive.normals) : null; - const texcoords = primitive.texcoords ? new Float32Array(primitive.texcoords) : null; - - // Create vertex buffer - const vertexCount = primitive.vertexCount; - const vertexData = new Float32Array(vertexCount * 8); - - for (let i = 0; i < vertexCount; i++) { - vertexData[i * 8 + 0] = positions[i * 3 + 0]; - vertexData[i * 8 + 1] = positions[i * 3 + 1]; - vertexData[i * 8 + 2] = positions[i * 3 + 2]; - if (normals) { - vertexData[i * 8 + 3] = normals[i * 3 + 0]; - vertexData[i * 8 + 4] = normals[i * 3 + 1]; - vertexData[i * 8 + 5] = normals[i * 3 + 2]; - } - if (texcoords) { - vertexData[i * 8 + 6] = texcoords[i * 2 + 0]; - vertexData[i * 8 + 7] = texcoords[i * 2 + 1]; - } - } - - const vertexBuffer = device.createBuffer({ - size: vertexData.byteLength, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, - }); - device.queue.writeBuffer(vertexBuffer, 0, vertexData); - - const indexBuffer = device.createBuffer({ - size: primitive.indices.byteLength, - usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST, - }); - device.queue.writeBuffer(indexBuffer, 0, primitive.indices); - - // Create textures from GLTF images - async function createTextureFromImage(imageIndex) { - if (imageIndex === undefined || imageIndex < 0 || imageIndex >= gltf.images.length) { - console.log(`Image index invalid or out of range: ${imageIndex}`); - return null; - } - - const img = gltf.images[imageIndex]; - if (!img) { - console.log(`Image ${imageIndex} is null/undefined`); - return null; - } - - console.log(`Image ${imageIndex}: name=${img.name || ""} uri=${img.uri || ""} mimeType=${img.mimeType || ""} hasData=${img.data ? `yes (${img.data.byteLength} bytes)` : "no"}`); - - if (!img.data) { - console.log(`Image ${imageIndex} has no embedded data, creating white texture`); - return createWhiteTexture(); - } - - const blob = new Blob([img.data], { type: img.mimeType || 'image/png' }); - const bitmap = await createImageBitmap(blob); - - const texture = device.createTexture({ - size: [bitmap.width, bitmap.height], - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, - }); - - device.queue.copyExternalImageToTexture( - { source: bitmap }, - { texture }, - [bitmap.width, bitmap.height] - ); - - return texture; - } - - function createWhiteTexture() { - const texture = device.createTexture({ - size: [1, 1], - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, - }); - device.queue.writeTexture({ texture }, new Uint8Array([255, 255, 255, 255]), { bytesPerRow: 4 }, [1, 1]); - return texture; - } - - function createFlatNormalTexture() { - const texture = device.createTexture({ - size: [1, 1], - format: 'rgba8unorm', - usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST, - }); - device.queue.writeTexture({ texture }, new Uint8Array([128, 128, 255, 255]), { bytesPerRow: 4 }, [1, 1]); - return texture; - } - - const baseColorTex = await createTextureFromImage(mat.baseColorTextureIndex) || createWhiteTexture(); - const normalTex = await createTextureFromImage(mat.normalTextureIndex) || createFlatNormalTexture(); - const metallicRoughnessTex = await createTextureFromImage(mat.metallicRoughnessTextureIndex) || createWhiteTexture(); - const emissiveTex = await createTextureFromImage(mat.emissiveTextureIndex) || createWhiteTexture(); - const occlusionTex = await createTextureFromImage(mat.occlusionTextureIndex) || createWhiteTexture(); - - const textureSampler = device.createSampler({ - magFilter: 'linear', - minFilter: 'linear', - mipmapFilter: 'linear', - addressModeU: 'repeat', - addressModeV: 'repeat', - }); - - const depthTexture = device.createTexture({ - size: [canvas.width, canvas.height], - format: 'depth24plus', - usage: GPUTextureUsage.RENDER_ATTACHMENT, - }); - - // Create bind group layouts and pipelines - // SceneUniforms: viewMatrix(64) + projMatrix(64) + cameraPos(12) + envIntensity(4) + padding(4) = 148 - // Aligned to 16 bytes = 160 bytes to be safe - const sceneUniformBuffer = device.createBuffer({ - size: 160, - usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, - }); - - const sceneBindGroupLayout = device.createBindGroupLayout({ - entries: [ - { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, - { binding: 1, visibility: GPUShaderStage.FRAGMENT, texture: { viewDimension: 'cube' } }, - { binding: 2, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }, - ], - }); - - const sceneBindGroup = device.createBindGroup({ - layout: sceneBindGroupLayout, - entries: [ - { binding: 0, resource: { buffer: sceneUniformBuffer } }, - { binding: 1, resource: envCube.view }, - { binding: 2, resource: envCube.sampler }, - ], - }); - - // Skybox vertex buffer - const skyboxVertexBuffer = device.createBuffer({ - size: skyboxVertices.byteLength, - usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, - }); - device.queue.writeBuffer(skyboxVertexBuffer, 0, skyboxVertices); - - // Skybox pipeline - const skyboxShaderModule = device.createShaderModule({ code: skyboxShaderCode }); - const skyboxPipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [sceneBindGroupLayout] }); - const skyboxPipeline = device.createRenderPipeline({ - layout: skyboxPipelineLayout, - vertex: { - module: skyboxShaderModule, - entryPoint: 'vs_main', - buffers: [{ - arrayStride: 12, // 3 floats per vertex - attributes: [ - { shaderLocation: 0, offset: 0, format: 'float32x3' }, - ], - }], - }, - fragment: { module: skyboxShaderModule, entryPoint: 'fs_main', targets: [{ format }] }, - primitive: { topology: 'triangle-list', cullMode: 'none' }, - depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'less-equal' }, - }); - - // PBR pipeline - const modelUniformBuffer = device.createBuffer({ size: 128, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - const materialUniformBuffer = device.createBuffer({ size: 32, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST }); - - const modelBindGroupLayout = device.createBindGroupLayout({ - entries: [{ binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } }], - }); - - const materialBindGroupLayout = device.createBindGroupLayout({ - entries: [ - { binding: 0, visibility: GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } }, - { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } }, - { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, - { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, - { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, - { binding: 5, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, - { binding: 6, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } }, - ], - }); - - const modelBindGroup = device.createBindGroup({ - layout: modelBindGroupLayout, - entries: [{ binding: 0, resource: { buffer: modelUniformBuffer } }], - }); - - const materialBindGroup = device.createBindGroup({ - layout: materialBindGroupLayout, - entries: [ - { binding: 0, resource: { buffer: materialUniformBuffer } }, - { binding: 1, resource: textureSampler }, - { binding: 2, resource: device.createTextureView(baseColorTex) }, - { binding: 3, resource: device.createTextureView(normalTex) }, - { binding: 4, resource: device.createTextureView(metallicRoughnessTex) }, - { binding: 5, resource: device.createTextureView(emissiveTex) }, - { binding: 6, resource: device.createTextureView(occlusionTex) }, - ], - }); - - const pbrPipelineLayout = device.createPipelineLayout({ - bindGroupLayouts: [sceneBindGroupLayout, modelBindGroupLayout, materialBindGroupLayout], - }); - - const pbrShaderModule = device.createShaderModule({ code: pbrShaderCode }); - const pbrPipeline = device.createRenderPipeline({ - layout: pbrPipelineLayout, - vertex: { - module: pbrShaderModule, - entryPoint: 'vs_main', - buffers: [{ - arrayStride: 32, - attributes: [ - { shaderLocation: 0, offset: 0, format: 'float32x3' }, - { shaderLocation: 1, offset: 12, format: 'float32x3' }, - { shaderLocation: 2, offset: 24, format: 'float32x2' }, - ], - }], - }, - fragment: { module: pbrShaderModule, entryPoint: 'fs_main', targets: [{ format }] }, - primitive: { topology: 'triangle-list', cullMode: 'back' }, - depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' }, - }); - - // Material uniforms - let textureFlags = 0; - if (mat.baseColorTextureIndex >= 0) textureFlags |= 1; - if (mat.normalTextureIndex >= 0) textureFlags |= 2; - if (mat.metallicRoughnessTextureIndex >= 0) textureFlags |= 4; - if (mat.emissiveTextureIndex >= 0) textureFlags |= 8; - if (mat.occlusionTextureIndex >= 0) textureFlags |= 16; - - const materialData = new Float32Array([ - mat.baseColorFactor[0], mat.baseColorFactor[1], mat.baseColorFactor[2], - mat.metallicFactor, mat.roughnessFactor, 1.0, 0, 0 - ]); - const materialDataView = new DataView(materialData.buffer); - materialDataView.setUint32(24, textureFlags, true); - device.queue.writeBuffer(materialUniformBuffer, 0, materialData); - - const cameraFOV = 60 * Math.PI / 180; - const environmentIntensity = 0.4; - const cameraDistance = 2.5; - - function frame() { - // Camera position based on touch rotation - const cameraX = Math.sin(rotationY) * Math.cos(rotationX) * cameraDistance; - const cameraY = Math.sin(rotationX) * cameraDistance; - const cameraZ = Math.cos(rotationY) * Math.cos(rotationX) * cameraDistance; - const cameraPos = [cameraX, cameraY, cameraZ]; - - const viewMatrix = lookAt(cameraPos, [0, 0, 0], [0, 1, 0]); - const aspect = canvas.width / canvas.height; - const projMatrix = perspective(cameraFOV, aspect, 0.1, 1000); - - // Scene uniforms: viewMatrix(64) + projMatrix(64) + cameraPos(12) + envIntensity(4) = 144 bytes - // Using 40 floats (160 bytes) for alignment - const sceneData = new Float32Array(40); - sceneData.set(viewMatrix, 0); - sceneData.set(projMatrix, 16); - sceneData[32] = cameraPos[0]; - sceneData[33] = cameraPos[1]; - sceneData[34] = cameraPos[2]; - sceneData[35] = environmentIntensity; - device.queue.writeBuffer(sceneUniformBuffer, 0, sceneData); - - // Model matrix with base rotation - const modelMatrix = multiply(rotateY(Math.PI), identity()); - const normalMatrix = identity(); - const modelData = new Float32Array(32); - modelData.set(modelMatrix, 0); - modelData.set(normalMatrix, 16); - device.queue.writeBuffer(modelUniformBuffer, 0, modelData); - - // Render - const commandEncoder = device.createCommandEncoder(); - const textureView = ctx.getCurrentTexture().createView(); - const depthView = device.createTextureView(depthTexture); - - const renderPass = commandEncoder.beginRenderPass({ - colorAttachments: [{ - view: textureView, - clearValue: { r: 0.1, g: 0.1, b: 0.1, a: 1.0 }, - loadOp: 'clear', - storeOp: 'store', - }], - depthStencilAttachment: { - view: depthView, - depthClearValue: 1.0, - depthLoadOp: 'clear', - depthStoreOp: 'store', - }, - }); - - // Draw skybox - renderPass.setPipeline(skyboxPipeline); - renderPass.setBindGroup(0, sceneBindGroup); - renderPass.setVertexBuffer(0, skyboxVertexBuffer); - renderPass.draw(36); - - // Draw model - renderPass.setPipeline(pbrPipeline); - renderPass.setBindGroup(0, sceneBindGroup); - renderPass.setBindGroup(1, modelBindGroup); - renderPass.setBindGroup(2, materialBindGroup); - renderPass.setVertexBuffer(0, vertexBuffer); - renderPass.setIndexBuffer(indexBuffer, 'uint32'); - renderPass.drawIndexed(primitive.indexCount); - - renderPass.end(); - device.queue.submit([commandEncoder.finish()]); - - requestAnimationFrame(frame); - } - - console.log("Starting render loop - touch/drag to rotate"); - frame(); -} - -init().catch((e) => { - console.log(`Error: ${e.message || e}`); -}); diff --git a/packages/runtime-native/src/raytracing/shaders/raytracing.metal b/packages/runtime-native/src/raytracing/shaders/raytracing.metal deleted file mode 100644 index 4e2c1abc52..0000000000 --- a/packages/runtime-native/src/raytracing/shaders/raytracing.metal +++ /dev/null @@ -1,264 +0,0 @@ -/** - * MystralNative Metal Ray Tracing Shaders - * - * This file contains the Metal Shading Language (MSL) ray tracing shaders - * for hardware-accelerated ray tracing on Apple Silicon. - * - * The shader uses Metal's ray tracing API introduced in Metal 3: - * - instance_acceleration_structure for scene hierarchy - * - intersector<> for ray-triangle intersection - * - intersection_result<> for hit information - * - * Reference: https://developer.apple.com/documentation/metal/ray_tracing - * Build: xcrun -sdk macosx metal -c raytracing.metal -o raytracing.air - * xcrun -sdk macosx metallib raytracing.air -o raytracing.metallib - */ - -#include -#include - -using namespace metal; -using namespace raytracing; - -// ============================================================================ -// Uniform Structures -// ============================================================================ - -/** - * Camera uniforms for ray generation. - * Contains inverse view and projection matrices for transforming - * pixel coordinates to world-space ray directions. - */ -struct CameraUniforms { - float4x4 viewInverse; // Inverse view matrix (camera-to-world) - float4x4 projInverse; // Inverse projection matrix -}; - -/** - * Hit information passed through ray tracing pipeline. - * Extended for future use with material/lighting data. - */ -struct HitInfo { - float3 worldPosition; - float3 worldNormal; - float2 barycentrics; - uint instanceId; - uint primitiveId; -}; - -// ============================================================================ -// Ray Generation Kernel -// ============================================================================ - -/** - * Primary ray tracing kernel. - * - * Generates primary rays from the camera through each pixel, - * traces them against the acceleration structure, and writes - * the result color to the output buffer. - * - * @param tid Thread position in grid (pixel coordinates) - * @param outputSize Dimensions of the output image - * @param output Buffer to write RGBA color values - * @param camera Camera matrices for ray generation - * @param tlas Top-level acceleration structure containing the scene - */ -[[kernel]] -void raytracingKernel( - uint2 tid [[thread_position_in_grid]], - constant uint2& outputSize [[buffer(0)]], - device float4* output [[buffer(1)]], - constant CameraUniforms& camera [[buffer(2)]], - instance_acceleration_structure tlas [[buffer(3)]] -) { - // Early exit if thread is outside image bounds - if (tid.x >= outputSize.x || tid.y >= outputSize.y) { - return; - } - - // Calculate normalized device coordinates (NDC) in range [-1, 1] - // Add 0.5 to sample pixel center rather than corner - float2 pixelCenter = float2(tid) + 0.5; - float2 ndc = pixelCenter / float2(outputSize) * 2.0 - 1.0; - ndc.y = -ndc.y; // Flip Y: Metal's texture origin is top-left - - // Generate camera ray using inverse matrices - // Origin: camera position in world space (extracted from inverse view) - float4 origin = camera.viewInverse * float4(0, 0, 0, 1); - - // Direction: transform NDC through inverse projection, then to world space - float4 target = camera.projInverse * float4(ndc, 1, 1); - float4 direction = camera.viewInverse * float4(normalize(target.xyz), 0); - - // Create ray for intersection testing - ray r; - r.origin = origin.xyz; - r.direction = normalize(direction.xyz); - r.min_distance = 0.001; // Avoid self-intersection - r.max_distance = 10000.0; // Far plane - - // Create intersector for triangle geometry with instancing - // accept_any_intersection(false) = find closest hit - intersector inter; - inter.accept_any_intersection(false); - - // Perform intersection against acceleration structure - intersection_result result = inter.intersect(r, tlas); - - // Compute output color based on hit/miss - float3 color; - - if (result.type == intersection_type::triangle) { - // Hit: visualize barycentric coordinates as RGB - // This is useful for debugging geometry - float2 bary = result.triangle_barycentric_coord; - float w = 1.0 - bary.x - bary.y; // Third barycentric coord - color = float3(w, bary.x, bary.y); - - // Alternative: simple normal visualization - // Uncomment to visualize interpolated normals - // float3 normal = normalize(float3(bary, w)); - // color = normal * 0.5 + 0.5; - } else { - // Miss: procedural sky gradient - // Gradient from white at horizon to blue at zenith - float t = 0.5 * (r.direction.y + 1.0); - float3 white = float3(1.0, 1.0, 1.0); - float3 skyBlue = float3(0.5, 0.7, 1.0); - color = mix(white, skyBlue, t); - } - - // Write output color (RGBA) - uint pixelIndex = tid.y * outputSize.x + tid.x; - output[pixelIndex] = float4(color, 1.0); -} - -// ============================================================================ -// Shadow Ray Kernel (for future use) -// ============================================================================ - -/** - * Shadow ray tracing kernel. - * - * Traces shadow rays from surface points toward light sources - * to determine visibility (shadowed or lit). - * - * @param tid Thread position in grid - * @param numRays Number of shadow rays to trace - * @param shadowRays Input ray origins and directions - * @param shadowResults Output visibility (0 = shadowed, 1 = lit) - * @param tlas Scene acceleration structure - */ -[[kernel]] -void shadowRayKernel( - uint tid [[thread_position_in_grid]], - constant uint& numRays [[buffer(0)]], - device packed_float3* rayOrigins [[buffer(1)]], - device packed_float3* rayDirections [[buffer(2)]], - device float* maxDistances [[buffer(3)]], - device float* shadowResults [[buffer(4)]], - instance_acceleration_structure tlas [[buffer(5)]] -) { - if (tid >= numRays) { - return; - } - - // Create shadow ray - ray shadowRay; - shadowRay.origin = float3(rayOrigins[tid]); - shadowRay.direction = normalize(float3(rayDirections[tid])); - shadowRay.min_distance = 0.001; - shadowRay.max_distance = maxDistances[tid]; - - // Create intersector for any-hit (shadow test optimization) - intersector inter; - inter.accept_any_intersection(true); // Stop at first hit - - // Perform intersection - intersection_result result = inter.intersect(shadowRay, tlas); - - // Write visibility result - shadowResults[tid] = (result.type == intersection_type::none) ? 1.0 : 0.0; -} - -// ============================================================================ -// Cornell Box Ray Generation (for testing) -// ============================================================================ - -/** - * Cornell box ray tracing kernel with simple diffuse shading. - * - * This kernel is designed for testing the ray tracing pipeline - * with the classic Cornell box scene. - */ -[[kernel]] -void cornellBoxKernel( - uint2 tid [[thread_position_in_grid]], - constant uint2& outputSize [[buffer(0)]], - device float4* output [[buffer(1)]], - constant CameraUniforms& camera [[buffer(2)]], - instance_acceleration_structure tlas [[buffer(3)]] -) { - if (tid.x >= outputSize.x || tid.y >= outputSize.y) { - return; - } - - // Generate ray - float2 pixelCenter = float2(tid) + 0.5; - float2 ndc = pixelCenter / float2(outputSize) * 2.0 - 1.0; - ndc.y = -ndc.y; - - float4 origin = camera.viewInverse * float4(0, 0, 0, 1); - float4 target = camera.projInverse * float4(ndc, 1, 1); - float4 direction = camera.viewInverse * float4(normalize(target.xyz), 0); - - ray r; - r.origin = origin.xyz; - r.direction = normalize(direction.xyz); - r.min_distance = 0.001; - r.max_distance = 10000.0; - - intersector inter; - inter.accept_any_intersection(false); - - intersection_result result = inter.intersect(r, tlas); - - float3 color; - - if (result.type == intersection_type::triangle) { - // Get instance ID for material color - uint instanceId = result.instance_id; - - // Cornell box colors based on instance ID - // 0 = floor (white), 1 = ceiling (white), 2 = back wall (white) - // 3 = left wall (red), 4 = right wall (green) - // 5,6 = boxes (white) - float3 albedo; - switch (instanceId) { - case 3: albedo = float3(0.65, 0.05, 0.05); break; // Red - case 4: albedo = float3(0.12, 0.45, 0.15); break; // Green - default: albedo = float3(0.73, 0.73, 0.73); break; // White - } - - // Simple diffuse shading with light from above - float2 bary = result.triangle_barycentric_coord; - float w = 1.0 - bary.x - bary.y; - - // Approximate normal from barycentrics (for testing) - // In production, normals would come from vertex attributes - float3 normal = normalize(float3(bary.x - 0.5, w, bary.y - 0.5)); - - // Light direction (from ceiling) - float3 lightDir = normalize(float3(0, 1, 0)); - float NdotL = max(dot(normal, lightDir), 0.0); - - // Ambient + diffuse - color = albedo * (0.3 + 0.7 * NdotL); - } else { - // Black background for Cornell box - color = float3(0.0, 0.0, 0.0); - } - - uint pixelIndex = tid.y * outputSize.x + tid.x; - output[pixelIndex] = float4(color, 1.0); -} diff --git a/scripts/analyze-prd-075-render-advisor.mjs b/scripts/analyze-prd-075-render-advisor.mjs deleted file mode 100644 index d0f656fb92..0000000000 --- a/scripts/analyze-prd-075-render-advisor.mjs +++ /dev/null @@ -1,266 +0,0 @@ -import { createHash } from "node:crypto"; -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -const REPO_ROOT = path.resolve(new URL("..", import.meta.url).pathname); -const RAW_INPUTS = [ - { - command: - "xvfb-run -a -s '-screen 0 1600x900x24' pnpm profile:native-cpu -- --headed --verify-presentation --render-advisor --objects 4000 --render-mode independent,distinct-materials,instanced,merged,scene-collapse --passes 1 --hierarchy flat --dirty 10 --visibility all-visible --repeats 3 --samples 120 --warmup-frames 60 --warmup-ms 0 --output-dir artifacts/prd-075-render-advisor-fresh/matrix-1pass", - path: "artifacts/prd-075-render-advisor-fresh/matrix-1pass/profile-1786517175608.json", - }, - { - command: - "xvfb-run -a -s '-screen 0 1600x900x24' pnpm profile:native-cpu -- --headed --verify-presentation --render-advisor --objects 4000 --render-mode independent --passes 2 --hierarchy flat --dirty 10 --visibility all-visible --repeats 3 --samples 120 --warmup-frames 60 --warmup-ms 0 --output-dir artifacts/prd-075-render-advisor-fresh/matrix-2pass", - path: "artifacts/prd-075-render-advisor-fresh/matrix-2pass/profile-1786517201897.json", - }, - { - command: - "xvfb-run -a -s '-screen 0 1600x900x24' pnpm profile:native-cpu -- --visual-evidence fox-scale --allow-software --render-advisor --render-mode scene-collapse --output-dir artifacts/prd-075-render-advisor-fresh/fox", - path: "artifacts/prd-075-render-advisor-fresh/fox/profile-1786517229485.json", - }, -]; - -function sha256(buffer) { - return createHash("sha256").update(buffer).digest("hex"); -} - -function stable(value) { - if (Array.isArray(value)) return value.map(stable); - if (value && typeof value === "object") { - return Object.fromEntries( - Object.keys(value) - .sort() - .map((key) => [key, stable(value[key])]), - ); - } - return value; -} - -function stableHash(value) { - return sha256(JSON.stringify(stable(value))); -} - -function median(values) { - const sorted = values - .filter((value) => Number.isFinite(value)) - .slice() - .sort((a, b) => a - b); - if (sorted.length === 0) return null; - const middle = Math.floor(sorted.length / 2); - return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; -} - -function percentile(values, p) { - const sorted = values - .filter((value) => Number.isFinite(value)) - .slice() - .sort((a, b) => a - b); - if (sorted.length === 0) return null; - const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); - return sorted[index]; -} - -function scenarioKey(scenario) { - return [ - scenario.scenario ?? "matrix", - `${scenario.objectCount}`, - scenario.renderMode, - `passes-${scenario.passes}`, - scenario.hierarchy, - `dirty-${scenario.dirtyRatio}`, - scenario.visibility, - ].join("/"); -} - -function round(value, digits = 6) { - return typeof value === "number" ? Number(value.toFixed(digits)) : value; -} - -const sources = []; -const runs = []; -for (const input of RAW_INPUTS) { - const absolute = path.join(REPO_ROOT, input.path); - const raw = readFileSync(absolute); - const parsed = JSON.parse(raw.toString("utf8")); - sources.push({ - command: input.command, - evidence: parsed.evidence, - rawFile: input.path, - rawFileSha256: sha256(raw), - recordedAt: parsed.recordedAt, - source: { - branch: parsed.source.branch, - dirty: parsed.source.dirty, - sha: parsed.source.sha, - }, - }); - for (const run of parsed.runs) { - const scenario = run.result.scenario; - const advisor = run.result.renderAdvisor; - const report = advisor.report; - runs.push({ - adapterClass: run.adapterClass, - browserErrors: run.browserErrors.length, - deterministicReportHash: stableHash(report), - elapsedMs: round(advisor.elapsedMs), - evidence: run.evidence, - presentation: { - after: { - hash: run.presentation?.after?.sha256, - status: run.presentation?.after?.status, - }, - before: { - hash: run.presentation?.before?.sha256, - status: run.presentation?.before?.status, - }, - }, - recommendationCodes: report.recommendations.map((item) => item.code), - repeat: run.repeat, - scenario, - scenarioKey: scenarioKey(scenario), - counters: { - advisorDrawCalls: report.observed.renderer.drawCalls, - advisorTriangles: report.observed.renderer.triangles, - rendererDrawCalls: run.summaries.drawCalls?.median, - rendererTriangles: run.summaries.triangles?.median, - renderMedianMs: round(run.summaries.renderMs?.median), - frameMedianMs: round(run.summaries.frameMs?.median), - snapshotRenderables: report.snapshot.visibleFlagRenderableCount, - }, - constraints: report.topGroups.map((group) => group.constraintReasonCounts), - }); - } -} - -const groups = Object.values( - runs.reduce((acc, run) => { - acc[run.scenarioKey] ??= []; - acc[run.scenarioKey].push(run); - return acc; - }, {}), -).map((items) => { - const first = items[0]; - const reportHashes = [...new Set(items.map((item) => item.deterministicReportHash))]; - const codes = [...new Set(items.flatMap((item) => item.recommendationCodes))].sort(); - return { - scenario: first.scenario, - scenarioKey: first.scenarioKey, - repeats: items.length, - adapterClasses: [...new Set(items.map((item) => item.adapterClass))].sort(), - evidence: [...new Set(items.map((item) => item.evidence))].sort(), - presentationVerdict: items.every( - (item) => - item.presentation.before.status === "pass" && item.presentation.after.status === "pass", - ) - ? "pass" - : "fail", - browserErrorVerdict: items.every((item) => item.browserErrors === 0) - ? "zero-browser-errors" - : "browser-errors-present", - recommendationCodes: codes, - deterministicReportHashes: reportHashes, - deterministicReportHashStable: reportHashes.length === 1, - elapsedMs: { - median: round(median(items.map((item) => item.elapsedMs))), - p95: round( - percentile( - items.map((item) => item.elapsedMs), - 95, - ), - ), - }, - counters: { - rendererDrawCallsMedian: median(items.map((item) => item.counters.rendererDrawCalls)), - advisorDrawCallsMedian: median(items.map((item) => item.counters.advisorDrawCalls)), - rendererTrianglesMedian: median(items.map((item) => item.counters.rendererTriangles)), - advisorTrianglesMedian: median(items.map((item) => item.counters.advisorTriangles)), - renderMedianMs: round(median(items.map((item) => item.counters.renderMedianMs))), - frameMedianMs: round(median(items.map((item) => item.counters.frameMedianMs))), - snapshotRenderablesMedian: median(items.map((item) => item.counters.snapshotRenderables)), - }, - }; -}); - -const expectedPositive = new Map([ - [ - "matrix/4000/independent/passes-1/flat/dirty-0.1/all-visible", - ["TN_RENDER_ADVISE_INSTANCE_COMPATIBLE"], - ], - [ - "matrix/4000/independent/passes-2/flat/dirty-0.1/all-visible", - ["TN_RENDER_ADVISE_INSTANCE_COMPATIBLE", "TN_RENDER_ADVISE_REPEATED_PASS"], - ], -]); -const expectedSilent = new Set([ - "matrix/4000/distinct-materials/passes-1/flat/dirty-0.1/all-visible", - "matrix/4000/instanced/passes-1/flat/dirty-0.1/all-visible", - "matrix/4000/merged/passes-1/flat/dirty-0.1/all-visible", - "matrix/4000/scene-collapse/passes-1/flat/dirty-0.1/all-visible", - "fox-scale/1850/scene-collapse/passes-1/flat/dirty-0.1/all-visible", -]); -const positivePassed = groups.filter((group) => { - const expected = expectedPositive.get(group.scenarioKey); - return expected?.every((code) => group.recommendationCodes.includes(code)) === true; -}).length; -const silentPassed = groups.filter( - (group) => expectedSilent.has(group.scenarioKey) && group.recommendationCodes.length === 0, -).length; -const counterMatches = runs.filter( - (run) => - run.counters.advisorDrawCalls === run.counters.rendererDrawCalls && - run.counters.advisorTriangles === run.counters.rendererTriangles, -).length; - -const summary = { - schemaVersion: 1, - prd: "PRD-075-render-workload-advisor", - evidenceDate: "2026-08-11", - privacyPolicy: - "checked-in summary excludes absolute local paths, raw pass tokens, object/material names, user content, and raw bulky artifacts; raw artifacts are local ignored files referenced by hash only", - sources, - groups, - scorecard: { - intentionallyExpensiveRecallPct: round((positivePassed / expectedPositive.size) * 100, 2), - falsePositiveFreeSuppressionPct: round((silentPassed / expectedSilent.size) * 100, 2), - optimizedIncompatibleSuppressionPct: round((silentPassed / expectedSilent.size) * 100, 2), - rendererCounterAgreementPct: round((counterMatches / runs.length) * 100, 2), - deterministicReportHashPct: round( - (groups.filter((group) => group.deterministicReportHashStable).length / groups.length) * 100, - 2, - ), - presentationPassPct: round( - (runs.filter( - (run) => - run.presentation.before.status === "pass" && run.presentation.after.status === "pass", - ).length / - runs.length) * - 100, - 2, - ), - zeroBrowserErrorsPct: round( - (runs.filter((run) => run.browserErrors === 0).length / runs.length) * 100, - 2, - ), - mutationPrivacyUnitGatesPct: 100, - disabledAdvisorCalls: 0, - snapshotElapsedMsMedian: round(median(runs.map((run) => run.elapsedMs))), - snapshotElapsedMsP95: round( - percentile( - runs.map((run) => run.elapsedMs), - 95, - ), - ), - }, -}; - -const outArg = process.argv.indexOf("--out"); -const outPath = - outArg >= 0 - ? process.argv[outArg + 1] - : "docs/verification/data/prd-075-render-workload-advisor-2026-08-11.json"; -const absoluteOutPath = path.join(REPO_ROOT, outPath); -mkdirSync(path.dirname(absoluteOutPath), { recursive: true }); -writeFileSync(absoluteOutPath, `${JSON.stringify(summary, null, 2)}\n`); -console.log(`wrote ${outPath}`); -console.log(JSON.stringify(summary.scorecard, null, 2)); diff --git a/scripts/analyze-prd-exp-002.mjs b/scripts/analyze-prd-exp-002.mjs deleted file mode 100644 index 15bf2bfe03..0000000000 --- a/scripts/analyze-prd-exp-002.mjs +++ /dev/null @@ -1,186 +0,0 @@ -import { readFileSync, readdirSync, writeFileSync } from "node:fs"; -import path from "node:path"; - -function latestJson(dir) { - const files = readdirSync(dir) - .filter((file) => file.endsWith(".json")) - .sort(); - if (files.length === 0) throw new Error(`No json in ${dir}`); - return path.join(dir, files.at(-1)); -} - -function loadLatest(dir) { - const file = latestJson(dir); - return { file, json: JSON.parse(readFileSync(file, "utf8")) }; -} - -function median(values) { - const sorted = [...values].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; -} - -function runMedians(report, metric) { - return report.runs.map((run) => run.summaries[metric].median); -} - -function runP95s(report, metric) { - return report.runs.map((run) => run.summaries[metric].p95); -} - -function pct(candidate, control) { - return ((candidate - control) / control) * 100; -} - -function compareReports(control, candidate) { - const metrics = [ - "renderMs", - "frameMs", - "matrixWorldMs", - "drawCalls", - "triangles", - "logicalObjects", - "visibleCount", - ]; - return Object.fromEntries( - metrics.map((metric) => { - const cMed = median(runMedians(control, metric)); - const iMed = median(runMedians(candidate, metric)); - const cP95 = median(runP95s(control, metric)); - const iP95 = median(runP95s(candidate, metric)); - return [ - metric, - { - controlMedian: cMed, - candidateMedian: iMed, - medianDeltaPct: pct(iMed, cMed), - controlP95: cP95, - candidateP95: iP95, - p95DeltaPct: pct(iP95, cP95), - }, - ]; - }), - ); -} - -function keyOf(run) { - const scenario = run.result.scenario; - return [ - scenario.objectCount, - scenario.renderMode, - scenario.passes, - scenario.hierarchy, - scenario.dirtyRatio, - scenario.visibility, - ].join("|"); -} - -function aggregateMatrix(report) { - const groups = new Map(); - for (const run of report.runs) { - const key = keyOf(run); - const arr = groups.get(key) ?? []; - arr.push(run); - groups.set(key, arr); - } - return [...groups.entries()] - .map(([key, runs]) => { - const first = runs[0].result.scenario; - const metrics = [ - "renderMs", - "frameMs", - "matrixWorldMs", - "drawCalls", - "triangles", - "logicalObjects", - "visibleCount", - "materialIdentities", - ]; - const values = Object.fromEntries( - metrics.map((metric) => [metric, median(runs.map((run) => run.summaries[metric].median))]), - ); - const stageReports = runs.map((run) => run.result.rendererStages).filter(Boolean); - const stageNames = [ - ...new Set(stageReports.flatMap((report) => Object.keys(report.stages))), - ].sort(); - const stages = Object.fromEntries( - stageNames.map((name) => { - const perFrameMs = median( - stageReports.map((report) => report.stages[name]?.inclusiveMsPerMeasuredFrame ?? 0), - ); - const callsPerFrame = median( - stageReports.map((report) => report.stages[name]?.callsPerMeasuredFrame ?? 0), - ); - const outer = median( - stageReports.map( - (report) => report.stages["renderer.renderScene"]?.inclusiveMsPerMeasuredFrame ?? 0, - ), - ); - return [ - name, - { - callsPerFrame, - outerRenderScenePct: outer ? (perFrameMs / outer) * 100 : 0, - perFrameMs, - }, - ]; - }), - ); - return { - adapterClass: runs[0].adapterClass, - evidence: runs[0].evidence, - key, - missingStages: stageReports[0]?.missingStages ?? [], - scenario: first, - stages, - values, - }; - }) - .sort((a, b) => a.key.localeCompare(b.key)); -} - -function parseArgs(argv) { - const positional = []; - let output; - for (let index = 0; index < argv.length; index += 1) { - const value = argv[index]; - if (value === "--output") { - output = argv[index + 1]; - index += 1; - } else if (value === "--help") { - console.log( - "Usage: node scripts/analyze-prd-exp-002.mjs [matrix-root] [--output ]", - ); - process.exit(0); - } else { - positional.push(value); - } - } - return { - abRoot: positional[0] ?? "artifacts/native-cpu-profile/prd-exp-002-ab", - matrixRoot: positional[1], - output, - }; -} - -const args = parseArgs(process.argv.slice(2)); -const control = loadLatest(path.join(args.abRoot, "control")); -const candidate = loadLatest(path.join(args.abRoot, "instrumented-safe2")); -const output = { - ab: compareReports(control.json, candidate.json), - candidateStageHeadline: candidate.json.runs.map((run) => ({ - adapterClass: run.adapterClass, - evidence: run.evidence, - measuredFrameCount: run.result.rendererStages?.measuredFrameCount, - missingStages: run.result.rendererStages?.missingStages, - presentation: run.presentation, - repeat: run.repeat, - stages: run.result.rendererStages?.stages, - })), - files: { candidate: candidate.file, control: control.file }, - matrix: args.matrixRoot ? aggregateMatrix(loadLatest(args.matrixRoot).json) : undefined, -}; - -const serialized = `${JSON.stringify(output, null, 2)}\n`; -if (args.output) writeFileSync(args.output, serialized); -process.stdout.write(serialized); diff --git a/scripts/evidence-citations.ts b/scripts/evidence-citations.ts index 5a446590ee..66965fc259 100644 --- a/scripts/evidence-citations.ts +++ b/scripts/evidence-citations.ts @@ -27,11 +27,6 @@ export interface IArtifactClassification { readonly citedBy: readonly string[]; } -export interface ICitationScan { - readonly artifacts: readonly IArtifactClassification[]; - readonly totals: Readonly>; -} - const ROUND_LEDGER_PATTERN = /round-\d+/u; /** diff --git a/scripts/gate-status.ts b/scripts/gate-status.ts index 6606ccdcde..7c806131fe 100644 --- a/scripts/gate-status.ts +++ b/scripts/gate-status.ts @@ -9,7 +9,6 @@ import { } from "./worktree-lifecycle.js"; export const GATE_STATUS_SCHEMA_VERSION = 1; -export const GATE_HEARTBEAT_INTERVAL_MS = 5_000; export const GATE_STALE_AFTER_MS = 30_000; export const GATE_CLOCK_SKEW_MS = 5_000; export const DEFAULT_GATE_STATUS_PATH = path.resolve(process.cwd(), "artifacts/gates/status.json"); @@ -492,11 +491,6 @@ export async function finishGatePhase( return next; } -export async function writeGateStatus(record: IGateStatusRecord): Promise { - parseRecord(record); - await writeAtomic(record.statusPath, record); -} - function value(flags: readonly string[], name: string): string { const index = flags.indexOf(name); if (index < 0 || flags[index + 1] === undefined || flags[index + 1] === "") { diff --git a/scripts/realism-effects-coverage.ts b/scripts/realism-effects-coverage.ts index 4932a25ae3..0538af9c81 100644 --- a/scripts/realism-effects-coverage.ts +++ b/scripts/realism-effects-coverage.ts @@ -19,8 +19,6 @@ export const REALISM_EFFECTS_EXPORTS = [ ] as const; export const REALISM_EFFECTS_PLATFORMS = ["desktop", "android", "ios"] as const; -export type RealismEffectsExport = (typeof REALISM_EFFECTS_EXPORTS)[number]; -export type RealismEffectsPlatform = (typeof REALISM_EFFECTS_PLATFORMS)[number]; export type RealismEffectsCoverageKind = "upstream" | "template" | "not-covered"; export interface IRealismEffectsCoverageRow { diff --git a/scripts/round-ledger.ts b/scripts/round-ledger.ts index 6677a167ce..d2734fbff1 100644 --- a/scripts/round-ledger.ts +++ b/scripts/round-ledger.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -export type RoundDisposition = "framework change" | "user space" | "rejected"; export type RoundArmName = "after" | "before" | "framework" | "vanilla"; export interface RoundArm {