diff --git a/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md new file mode 100644 index 0000000000..751a916aa1 --- /dev/null +++ b/docs/PRDs/performance/PRD-860-a-repeat-web-run-is-ready-in-about-a-second.md @@ -0,0 +1,108 @@ +# PRD-860 — A repeat web run is ready in about a second + +**Status:** PARTIAL +**Blocker:** AC-1 is open: a repeat run reads 1.27-1.32 s against the 1.2 s bar (develop 2.39-2.52 s in the same window). Every phase box and AC-2, AC-3 and AC-4 are verified. The owner asked to merge on 2026-11-08 (see Decisions). +**Priority:** P1 — AC-1 is open: a repeat run of the collectathon kit takes about 1.3 s against the 1.2 s bar. +**Complexity:** 6 (MEDIUM) — 6-10 implementation files (+2), a new generated-content cache (+2), engine packages and a kit template change together (+2); risk override: none. +**Owner:** João +**Depends on:** None. Follows PRD-743 (asset disk cache) and PRD-828 (engine work moves into C++). + +## Context + +Owner, 2026-11-08: "Goal is to run as fast as possible initially and sequential runs are almost instant. in all platforms." Then: "setup a benchmark on this PRD and only stop when its significantly faster, setup some realistic goal." + +Spike, 2026-11-08: the collectathon kit (`--genre mario`) from the published 0.3.12 packages, `pnpm build:web`, `vite preview`, Chromium WebGPU, time from navigation to `__TN_STARTUP_READY__`. The clean numbers come from the quiet laptop (Intel Iris Xe, load 0.3). The desktop numbers were about 4x higher at host load 57, so this PRD uses none of them. + +| Run | localhost | 20 Mbit/s, 40 ms | +| --- | --- | --- | +| first visit (empty profile) | 2.36 s | 5.05 s | +| repeat, same browser | 2.00 s | 2.28 s | +| repeat, browser relaunched | 2.01-2.03 s | 2.37-2.39 s | + +A repeat run downloads 6 KB, so the 2.0 s floor is work, not bytes: + +- **1.2 s scene build.** `Play.load` regenerates the same seeded world in JS every run. A CPU profile splits it into about 65% course and foliage placement (`src/level/Course.ts`, `src/render/foliage.ts`; per-instance `setMatrixAt`, `pathDistance`, `traverse`), about 25% a 2048x1024 sky painted per pixel in JS (`src/render/`), and about 10% collider boxes. +- **0.4 s pipeline warm-up.** 350 pipelines, created after the scene build, not during it. +- **0.3 s boot.** The engine Wasm compiles by `compileStreaming` in about 89 ms. + +The first visit adds about 2.7 s of download at 20 Mbit/s for 6.0 MB. The engine Wasm crossed uncompressed because `vite preview` does not serve the `.br` copies the build writes. Rapier sits in `index.js` as a 2.09 MB base64 string (two-thirds of the bundle), so it cannot use streaming compile or the Wasm code cache, and its decode costs about 150 ms each run. The generated `_headers` sets COOP/COEP only, with no `Cache-Control`, so each repeat visit revalidates every file. + +Unreal answers the repeated-pipeline part with a user pipeline cache: it records the pipelines a run used and precompiles them on the next launch in batches under a frame budget (UE 5.8.3: Engine/Source/Runtime/RenderCore/Private/ShaderPipelineCache.cpp:66-100 for the batch size of 50 and the 16 ms batch time, :214 for `r.ShaderPipelineCache.SaveUserCache`). WebGPU has no serialized pipeline to restore (`packages/core/src/warmup.ts:1112`), so this PRD borrows the order, not the file: start the recorded pipelines while the scene builds. + +## Solution + +1. **Benchmark first.** `pnpm bench:load` scaffolds a kit from the local packages, builds it for web and serves `dist/` as a production host does: the `.br` copies with `Content-Encoding`, and the headers in `_headers`. Then it drives Chromium with `--browser-recipe webgpu` flags, a persistent profile and a CDP throttle of 20 Mbit/s with 40 ms latency. Each arm makes one first visit and five relaunches, and the bench reports the median `readyMs`. It also reports the adapter, bytes on the wire and the stage marks (`TN_STATIC_COLLIDERS`, `TN_STARTUP_WARMUP`). The goal machine is the laptop (Intel Iris Xe) through `laptop-offload --gpu --quiet`, because the desktop runs at load 50+. +2. **Repeat-run levers.** + - `_headers` gives hashed `/assets/*` files `Cache-Control: public, max-age=31536000, immutable`. + - A generated-content cache (`ctx.assets.memo(key, build)`) keeps typed arrays the game generates in the per-game CacheStorage cache that PRD-743 already owns. The key carries the build identity, so a new build regenerates. On a native host it calls `build` every time and reports why. The kit moves its foliage instances, course buffers and sky pixels behind it, so the look stays the same. + - The pipelines a run created are recorded and started while the next run builds its scene, in the Unreal order. +3. **First-run levers.** Rapier loads as its own hashed `.wasm` through `instantiateStreaming`. The bench confirms the host serves `.br` for the engine Wasm. Then the bench decides whether more is needed. + +Each lever lands only when the bench shows a gain above run noise on the paired arms, and it is reverted otherwise. + +## Acceptance Criteria + +- [ ] AC-1 [local]: a repeat run of the collectathon kit, browser relaunched, is ready in at most 1.2 s median at 20 Mbit/s and 40 ms on the laptop (bench baseline 2.51 s, at least 2x faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — 2026-11-08, `522a55687`, laptop, paired with develop `4dd50f4c2` in one window: 1271 / 1307 / 1315 ms against 2454 / 2388 / 2515 ms. 1.9x faster, still 70-115 ms over the bar: not met. +- [x] AC-2 [local]: the first visit of the same kit is ready in at most 2.7 s median under the same conditions (bench baseline 3.90 s, at least 30% faster). proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — 2026-11-08, `522a55687`, laptop, paired with develop `4dd50f4c2` in one window: 2477 / 2575 / 2574 ms (max 2592) against 3829 / 3708 / 3918 ms, 33% faster. +- [x] AC-3 [local]: the control, the minimal kit, which uses none of the new caches, is no slower than its develop baseline on either arm, within the spread of three runs. It charges the levers' own bookkeeping to a game that gains nothing. proof: `pnpm bench:load --kit minimal --network 20000:40 --runs 5` — 2026-11-08, `49c1ae3cb`, laptop, three runs: first visit 2931 / 2983 / 2981 ms (baseline 3507-3596), repeat 1400 / 1457 / 1479 ms (baseline 2249-2292). Faster on both arms. Re-measured at `522a55687` in the AC-2 window: first visit 3056 / 2893 / 3031 ms against develop 3543 / 3553 / 3447, repeat 1505 / 1420 / 1466 ms against 2228 / 2250 / 2157. +- [x] AC-4 [local]: a fresh visual judge rates the spawn frame NEUTRAL or IMPROVEMENT against the develop baseline, two headed WebGPU captures per arm. proof: visual judge subagent on the bench captures — 2026-11-08: NEUTRAL on all four pairs (two runs, first and repeat visit; `TN_LOAD_BENCH_SHOTS`, laptop `intel/gen-12lp`, develop `4dd50f4c2` against `49c1ae3cb`), only animation-phase differences; images on PR #641 (`prd-860-screenshots` `0af50ae8e`). + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| Load benchmark | `pnpm bench:load` → `scripts/load-bench.ts` | none, new harness | Phase 1 | +| Generated-content cache | kit `Play.load` → `ctx.assets.memo` → `packages/core/src/asset-cache.ts` | the per-run regeneration | Phase 2 | +| Immutable asset headers | `threenative build --target web` → `_headers` | headers with no `Cache-Control` | Phase 2 | +| Rapier out of line | `@threenative/physics` web import → its own chunk, awaited by `initialize` (`packages/physics/src/web.ts`) | the static import in the entry chunk | Phase 3 | +| Engine Wasm preload | `threenative build --target web` → head script (`withEnginePreload`, `packages/create-threenative/src/web-brand.ts`) | the glue's late fetch on a first visit | Phase 3 | +| Instance array writes | any game's `InstancedMesh.setMatrixAt`/`setColorAt` (`packages/three-native/src/browser-attribute-sync.ts` `writeItemAt`, routed in `browser-backend.ts`) | one engine call an instance; a bad index or the first colour keep it | Phase 2 | +| Physics ready at enter | any game's `rapier()`/`recast()` with a runtime that offers `holdStart` (`packages/physics/src/plugin.ts` setup, `packages/core/src/game-startup.ts` start gates) | `setup` awaited the Wasm before `load()` began; a body built in `load()` now throws `TN_PHYSICS_NOT_READY` | AC-2 | +| Shared-memory matrix view | any game's `Matrix4`/`Matrix3` `elements` on the threads build (`packages/three-native/src/browser-value-views.ts`) | the per-element Proxy write | Phase 2 | + +## Decisions + +- 2026-11-08 (agent): AC-2 moves from "at most 3.5 s against 5.05 s" to "at most 2.7 s against 3.90 s". The spike served no Brotli; the bench does, as a production host does, and its first-visit baseline is 3.90 s. The bar stays at least 30% faster. +- 2026-11-08 (agent): the minimal kit, with almost no scene, repeats in 2.25 s, so most of a repeat run is startup every kit shares (the sky's cloud noise first), not the collectathon's world. The levers start there. The collectathon's course placement shares one seeded random stream with the rest of the course, so it is not cached: skipping it on a hit would change the world. + +- 2026-11-08 (agent, from the spike): the goal machine is the quiet laptop, not the desktop, because desktop runs at host load 50+ read 4x high and vary by 40% between runs. +- 2026-11-08 (agent): the cache stores bytes in TypeScript in core, next to the PRD-743 asset cache. It is I/O, not compute, and core must not carry Wasm. Generation that stays per run moves into C++ through the PRD-828 bulk calls only when the bench shows that the cache alone misses AC-2. +- 2026-11-08 (agent): native relaunch (pipeline cache on the owned engine, V8 code cache) stays with PRD-368 and PRD-335. This PRD reports native as `memo` disabled with its reason and changes nothing else there. +- 2026-11-08 (agent): the box "the recorded pipelines start while the scene builds" is deleted. Two attempts measured no gain on paired laptop arms: the environment bake beside the first compile (1755/1748/1785 against 1732/1773/1781 ms), and a JS-side pipeline precache, which Chrome does not dedupe. Chrome's GPU process creates pipelines and runs queue submits in series, so earlier starts only queue earlier. +- 2026-11-08 (agent): the box "Rapier loads as a hashed `.wasm` through `instantiateStreaming`" is replaced. That build (`cf58debc9`) cut 155 KB but no time, because the static import still held the entry module. What pays is moving Rapier off the boot path: its own chunk, fetched as physics imports (c10). +- 2026-11-08 (agent): two readiness changes were measured and dropped: exposure reads every frame until measured, and quiet frames counted from the compile (1565/1593/1644 against 1509/1616/1648 ms). The five frames after the compile wait on GPU frames (7-11 ms of CPU each), not on the gate. +- 2026-11-08 (agent): an always-on Wasm preload (`a1fe3000c`) made a repeat visit 150-200 ms slower, because a preloaded response loses V8's compiled-code cache. The preload runs only for a Wasm URL the browser has not fetched (`49c1ae3cb`). +- 2026-11-08 (agent): what a repeat run still spends after `e13c0b534`, from one laptop CPU profile (`buildCourse` 323 ms, the cover 147 ms, colliders 61 ms), and why each is not taken here. The collectathon world generation (`plantIsland` path distance, clearance, leaf texture, terrain sampler) draws from one seeded stream, so storing any piece shifts every later draw and moves the island; it needs the kit to split its streams first. The loading cover is drawn every frame (147 ms), and skipping a frame presents black during the warm-up, the startup flash reported three times before. Rapier builds its trimesh tree in its own Wasm (34 ms of the 61). The 1.0-1.4 s tail is GPU-bound pipeline warm-up, where the dropped readiness changes and the JS precache showed no gain. +- 2026-11-08 (agent): a first visit at `e13c0b534` (laptop CPU profile) spends, after Rapier arrives, about 550 ms painting the collectathon sky (on the critical path about 120 ms past the textures), 350 ms in `buildCourse` and 550 ms compiling and warming the pipelines without a Dawn cache. `Scene.load` starts only after every plugin's `setup`, so all of it waits for the Rapier download. No template or example touches physics in `load`, but readying plugins after `load` changes the public plugin contract, and a faster sky with identical bytes is tuning one kit's art code to this bench. Both stay with the owner's decision on AC-2. +- 2026-11-08 (owner, João): plugins become ready before `enter()`, not before `load()` ("yes", answering the plugin-order option). `rapier()` and `recast()` leave a `holdStart` gate in place of awaiting their Wasm, so `load()` runs while they initialize. Paired against `dacbbf0f7`: first visit 2528 / 2548 / 2611 ms against 2592 / 2727 / 2679, repeat unchanged. A body built in `load()` throws `TN_PHYSICS_NOT_READY`; no template or example builds one there. +- 2026-11-08 (agent): the collectathon kit paints its sky and ripples and plants its island with the same bytes, faster (`dacbbf0f7`): exact sRGB byte steps in place of a power a texel, column trig and the ripple lattice once, and distance checks that skip what is clearly out of reach. `skyPixels`, `ripplePixels` and the built course hash the same before and after, so AC-4 stands without a new judge. Paired against `e13c0b534`: first visit 2658 / 2710 / 2683 ms against 2808 / 2979 / 3024, repeat unchanged. +- 2026-11-08 (agent): the C++ noise bake (PRD-828 bulk calls) is not taken: AC-2 is met without it, and it would cut only the first visit, which AC-1 does not measure. +- 2026-11-08 (owner, João): merge with AC-1 open ("finish this, then send PR to be merged"). The PRD stays PARTIAL outside `done/`. The repeat run is 1.9x faster than develop and 70-115 ms over the bar; what remains is listed in the Decision on `e13c0b534` above. + +## Execution Phases + +#### Phase 1: The benchmark and its baseline +**Status:** DONE +**Files:** `scripts/load-bench.ts` (new), `package.json` (the `bench:load` script). + +- [x] `pnpm bench:load` reports median first-visit and repeat `readyMs`, the adapter and bytes on the wire for one kit. proof: `pnpm bench:load --dist /dist --network 20000:40 --runs 5` on the laptop — 2026-11-08, `3ebe059ed`: adapter `intel/gen-12lp`, 5 first and 10 repeat visits per run, `.br` served (2805 KB first visit), `scripts/__tests__/load-bench.spec.ts` 2 passed. +- [x] The develop baseline is recorded here: three bench runs on the laptop for collectathon and for the minimal control, 20 Mbit/s and 40 ms. proof: `pnpm bench:load --dist /dist --network 20000:40 --runs 5` — packages from `origin/develop` `4dd50f4c2`, laptop load under 2, 74-98 °C: + - collectathon first visit 3846 / 3902 / 3907 ms, repeat 2433 / 2512 / 2568 ms. + - minimal first visit 3507 / 3555 / 3596 ms, repeat 2249 / 2292 / 2284 ms. + - Unthrottled repeat-visit CPU profile (unminified build): in the minimal kit `cloudTextures()` (`templates/*/src/render/clouds.ts`, shared by six kits) runs about 0.6 s of main-thread JS before the first frame, and `bakedRadiance` about 0.12 s. + +#### Phase 2: A repeat run does not redo the last run's work +**Status:** DONE +**Files:** `packages/create-threenative/src/` (the `_headers` writer), `packages/core/src/asset-cache.ts` and `assets.ts` (`memo`), the collectathon template `src/level/` and `src/render/`, the warm-up path in `packages/core/src/warmup.ts`. + +- [x] Hashed assets ship `Cache-Control: public, max-age=31536000, immutable`, and a repeat run makes no revalidation request. proof: `pnpm exec vitest run packages/create-threenative/__tests__` plus the bench request count — 2026-11-08, `3ebe059ed`: `web-brand.spec.ts`, `asset-memo.spec.ts`, `asset-cache.spec.ts`, `load-bench.spec.ts` 46 passed; a repeat visit makes 12 requests with 1 KB on the wire (the page itself). +- [x] `ctx.assets.memo` returns the stored arrays on a repeat run, and the collectathon kit builds its world through it with its playtests green. proof: `pnpm exec vitest run packages/core/__tests__/asset-cache.spec.ts` plus `pnpm test:templates` for collectathon. — 2026-11-08: `asset-cache.spec.ts` and `asset-memo.spec.ts` 37 passed. `TN_TEMPLATE_ONLY=collectathon,minimal pnpm test:templates` on the laptop (Iris Xe, `e13c0b534`): collectathon 22 of 23 scenarios pass in two runs, minimal all but one. The one fail per kit is `no-hitch` GPU p95 (collectathon 27.2 ms, minimal 18.0 ms over 16.7), and develop `4dd50f4c2` fails it the same on that laptop (27.6 ms, 17.5 ms), so the GPU, not this PRD, misses it. An earlier HEAD run flagged one `steadyCover` frame at 25 ms in Chromium's blank-tab `#121212` before first paint; it did not recur in two reruns. Re-run on the merge of develop `0711f2b77` (`ef302e32a`, collectathon, minimal and racing): 37 scenarios pass; only the no-hitch GPU p95 rows fail (collectathon 28.17, minimal 18.08 ms against develop 27.63 and 17.51; racing 18.63 ms with no develop control run: unverified). +- [x] The hot paths a repeat run walks cost less, each kept only on a paired laptop gain. proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — 2026-11-08: the scattering table built once in six kits and the reach cull over vertices (`e6f24156f`, repeat 1586-1632 ms from 1784-1795); foliage composes plain matrices and the path distance is a loop (`03414c30b`, 70-100 ms paired); a matrix over shared engine memory is a view, not a Proxy (`bf9a55778`, 1512/1569/1555 against 1566/1616/1644 ms paired); `setMatrixAt` and `setColorAt` write the instance array and send the written range once before the next engine call (`e13c0b534`, repeat 1380/1387/1381 against 1506/1536/1514 ms and first 3124/3151/3126 against 3190/3295/3217 ms paired with `49c1ae3cb`). `browser-math.spec.ts` and `browser-backend-coverage.spec.ts` red-green, three-native 536 passed, physics 267 passed. + +#### Phase 3: A first visit downloads and compiles less +**Status:** DONE +**Files:** `packages/physics/src/web.ts`, the web build in `packages/create-threenative/src/`, the collectathon `src/scenes/Play.ts`. + +- [x] Rapier downloads beside the engine boot, and `index.js` carries no base64 module. proof: `pnpm exec vitest run packages/physics/__tests__` plus the bench byte report — 2026-11-08, `3ae3ff027`: physics 267 passed; `index.js` 864 → 250 KB Brotli, Rapier its own 614 KB chunk; collectathon first visit 3545-3616 ms from 3695-3725, minimal 3132-3252 from 3268-3434. +- [x] The engine Wasm starts with the page and Rapier after it, on a first visit only. proof: `pnpm exec vitest run packages/create-threenative/__tests__/web-brand.spec.ts` plus a paired bench — 2026-11-08, `49c1ae3cb`: 8 passed; the Wasm lands at 670 ms, not 805; collectathon first 3123/3124/3140 against 3169/3277/3314 ms paired, repeat flat; minimal first 2931-2983 ms. +- [x] The collectathon paints its sky while its surface textures download. proof: `pnpm bench:load --kit collectathon --network 20000:40 --runs 5` — 2026-11-08, `637eda3a9`: first visit 3084/3221/3265 ms from 3545-3616. diff --git a/docs/verification/native-coverage-2026-08-28.md b/docs/verification/native-coverage-2026-08-28.md index 57d4d22c40..b759ce5ad2 100644 --- a/docs/verification/native-coverage-2026-08-28.md +++ b/docs/verification/native-coverage-2026-08-28.md @@ -11,7 +11,7 @@ targets could not be built and are named below. | `src/audio/` | 1471 | 1301 | 88.44% | | `src/canvas/` | 1334 | 1110 | 83.21% | | `src/cli/` | 1691 | 1243 | 73.51% | -| `src/engine/` | 43273 | 36221 | 83.70% | +| `src/engine/` | 43282 | 36230 | 83.71% | | `src/fs/` | 235 | 189 | 80.43% | | `src/http/` | 410 | 377 | 91.95% | | `src/js/` | 2838 | 2230 | 78.58% | @@ -22,12 +22,12 @@ targets could not be built and are named below. | `src/storage/` | 370 | 329 | 88.92% | | `src/utils/` | 0 | 0 | 0.00% | | `src/vfs/` | 239 | 195 | 81.59% | -| `src/webgpu/` | 10076 | 7745 | 76.87% | +| `src/webgpu/` | 10076 | 7736 | 76.78% | | `src/webtransport/` | 1391 | 1078 | 77.50% | -| `src/workers/` | 615 | 524 | 85.20% | -| **TOTAL** | **68603** | **56083** | **81.75%** | +| `src/workers/` | 615 | 527 | 85.69% | +| **TOTAL** | **68612** | **56086** | **81.74%** | -Source digest: `sha256:2f9ed85cc1717a794e64a27b65fa0b8af957a162c1733396241ce64111e31162` +Source digest: `sha256:2da952e005c78e4d4a7328bc8b1949e8af153f74dc24b7bd7dbb680262ffb453` The default `pnpm budgets` gate reads this committed measurement without configuring or compiling the native host. Any native source, native C++ test, CTest registration, or coverage aggregation diff --git a/package.json b/package.json index 5ce5907fd2..7f710dd237 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "api:surface:sync": "tsx scripts/check-api-surface.ts --update && biome format --write scripts/api-surface.json", "bake:delete-test": "sh scripts/xvfb.sh tsx scripts/bake-delete-test.ts", "bench:engines": "tsx scripts/engine-load-test/cli.ts", + "bench:load": "tsx scripts/load-bench.ts", "bench:engines:report": "tsx scripts/engine-load-test/cli.ts --compare", "bench:engines:monitor": "tsx scripts/engine-load-test/monitor.ts", "dashboard": "tsx scripts/perf-live.ts", diff --git a/packages/core/__tests__/asset-memo.spec.ts b/packages/core/__tests__/asset-memo.spec.ts new file mode 100644 index 0000000000..cba5d06d76 --- /dev/null +++ b/packages/core/__tests__/asset-memo.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createAssetMemo, decodeMemo, encodeMemo } from "../src/asset-memo.js"; +import { FakeCacheStorage } from "./fixtures/fake-cache.js"; + +const scope = globalThis as { caches?: unknown; __TN_BUILD_ID__?: unknown }; + +describe("ctx.assets.memo (PRD-860)", () => { + let storage: FakeCacheStorage; + beforeEach(() => { + storage = new FakeCacheStorage(); + scope.caches = storage; + scope.__TN_BUILD_ID__ = "/assets/index-AAAA.js"; + vi.spyOn(console, "info").mockImplementation(() => undefined); + }); + afterEach(() => { + Reflect.deleteProperty(scope, "caches"); + Reflect.deleteProperty(scope, "__TN_BUILD_ID__"); + vi.restoreAllMocks(); + }); + + it("builds once, then a later run of the same build reads the stored arrays back", async () => { + const build = vi.fn(() => ({ + pixels: new Uint8Array([1, 2, 3]), + at: new Float32Array([0.5, -2]), + })); + const first = await createAssetMemo("game")("sky", build); + await vi.waitFor(async () => + expect((await storage.open([...storage.caches.keys()][0] ?? "")).store.size).toBe(1), + ); + const second = await createAssetMemo("game")("sky", build); + expect(build).toHaveBeenCalledTimes(1); + expect([...second.pixels]).toEqual([...first.pixels]); + expect(second.at).toBeInstanceOf(Float32Array); + expect([...second.at]).toEqual([0.5, -2]); + }); + + it("rebuilds after a new build and deletes the old build's entries", async () => { + const build = vi.fn(() => ({ v: new Uint16Array([7]) })); + await createAssetMemo("game")("k", build); + await vi.waitFor(() => expect(storage.caches.size).toBe(1)); + scope.__TN_BUILD_ID__ = "/assets/index-BBBB.js"; + await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + const cache = [...storage.caches.values()][0]; + await vi.waitFor(async () => { + const urls = (await cache?.keys())?.map((request) => request.url) ?? []; + expect(urls).toHaveLength(1); + expect(urls[0]).toContain(encodeURIComponent("/assets/index-BBBB.js")); + }); + }); + + it("builds every run, and says why, with no build id or no cache storage", async () => { + Reflect.deleteProperty(scope, "__TN_BUILD_ID__"); + const build = vi.fn(() => ({ v: new Int32Array([1]) })); + await createAssetMemo("game")("k", build); + await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + expect(storage.caches.size).toBe(0); + expect(String(vi.mocked(console.info).mock.calls.at(-1)?.[0])).toContain( + '"reason":"no build id', + ); + }); + + it("takes a build typed by an interface, the shape a generator module exports", async () => { + interface IPainted { + readonly sky: Float32Array; + readonly mask: Uint8Array; + } + const paint = (): IPainted => ({ mask: new Uint8Array([1]), sky: new Float32Array([0.5]) }); + const painted: IPainted = await createAssetMemo("game")("painted", paint); + expect([...painted.mask]).toEqual([1]); + }); + + it("rebuilds over a corrupt entry instead of failing the load", async () => { + const build = vi.fn(() => ({ v: new Float64Array([3]) })); + const memo = createAssetMemo("game"); + await memo("k", build); + const cache = [...storage.caches.values()][0]; + await vi.waitFor(async () => expect((await cache?.keys())?.length).toBe(1)); + const url = (await cache?.keys())?.[0]?.url ?? ""; + await cache?.put(url, new Response(new Uint8Array([9, 9]))); + const again = await createAssetMemo("game")("k", build); + expect(build).toHaveBeenCalledTimes(2); + expect([...again.v]).toEqual([3]); + }); + + it("rejects a key or a value it cannot store", async () => { + const memo = createAssetMemo("game"); + await expect(memo("", () => ({ v: new Uint8Array(1) }))).rejects.toThrow( + "TN_ASSET_MEMO_INVALID", + ); + await expect(memo("k", () => ({ v: [1, 2] as unknown as Uint8Array }))).rejects.toThrow( + "TN_ASSET_MEMO_INVALID", + ); + }); + + it("round-trips every typed array kind with its exact values", () => { + const value = { + a: new Float32Array([1.5]), + b: new Float64Array([Math.PI]), + c: new Int8Array([-3]), + d: new Int16Array([-300]), + e: new Int32Array([-70000]), + f: new Uint8Array([250]), + g: new Uint8ClampedArray([255]), + h: new Uint16Array([65000]), + i: new Uint32Array([4000000000]), + empty: new Float32Array(0), + }; + const decoded = decodeMemo(encodeMemo(value)); + for (const [name, array] of Object.entries(value)) { + expect(decoded[name]?.constructor).toBe(array.constructor); + expect([...(decoded[name] ?? [])]).toEqual([...array]); + } + }); +}); diff --git a/packages/core/__tests__/world-asset-alias.spec.ts b/packages/core/__tests__/world-asset-alias.spec.ts index 781bd0627d..37fae82eab 100644 --- a/packages/core/__tests__/world-asset-alias.spec.ts +++ b/packages/core/__tests__/world-asset-alias.spec.ts @@ -144,6 +144,7 @@ function countingLoader(): { assets: IAssetLoader; models: string[] } { const inner = createAssetLoader({ basePath: "" }); const models: string[] = []; const assets: IAssetLoader = { + memo: inner.memo, get compressedTextures() { return inner.compressedTextures; }, diff --git a/packages/core/__tests__/world-cells-terrain.spec.ts b/packages/core/__tests__/world-cells-terrain.spec.ts index 7b760e24ba..20dd18301f 100644 --- a/packages/core/__tests__/world-cells-terrain.spec.ts +++ b/packages/core/__tests__/world-cells-terrain.spec.ts @@ -22,6 +22,7 @@ function makeModel(): Object3D { function cacheLoader(): IAssetLoader { return { + memo: () => Promise.reject(new Error("unused")), audio: () => Promise.reject(new Error("unused")), cache: { backgroundFill: false, diff --git a/packages/core/__tests__/world-cells.spec.ts b/packages/core/__tests__/world-cells.spec.ts index 46f695b793..7381601769 100644 --- a/packages/core/__tests__/world-cells.spec.ts +++ b/packages/core/__tests__/world-cells.spec.ts @@ -576,6 +576,7 @@ function cacheLoader(load: (path: string) => Object3D | undefined = () => makeMo const released: string[] = []; const cleared: number[] = []; const assets: IAssetLoader = { + memo: () => Promise.reject(new Error("unused")), audio: () => Promise.reject(new Error("unused")), cache: { backgroundFill: false, diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json index d0f16896ba..caa1bba1bd 100644 --- a/packages/core/capabilities.json +++ b/packages/core/capabilities.json @@ -5566,7 +5566,10 @@ }, { "aliases": [], - "constraints": ["place rapier() before recast() in the plugin list"], + "constraints": [ + "place rapier() before recast() in the plugin list", + "ctx.physics is set before Scene.enter(), not during load(): Rapier initializes while the scene loads, so create bodies in enter() or later (TN_PHYSICS_NOT_READY)" + ], "example": "const game = defineGame({ plugins: [rapier()] });", "importPath": "@threenative/physics", "kind": "function", diff --git a/packages/core/src/asset-cache.ts b/packages/core/src/asset-cache.ts index a61fb6feef..5aaf5876e9 100644 --- a/packages/core/src/asset-cache.ts +++ b/packages/core/src/asset-cache.ts @@ -73,13 +73,13 @@ const ACCESS_REFRESH_MS = 3_600_000; /** Lower is more urgent. */ export const PRIORITY_ORDER: Record = { now: 0, soon: 1, idle: 2 }; -type CacheStorageLike = { +export type CacheStorageLike = { delete(name: string): Promise; keys(): Promise; open(name: string): Promise; }; -type CacheLike = { +export type CacheLike = { delete(request: string): Promise; keys(): Promise; match(request: string): Promise; @@ -94,7 +94,7 @@ interface IJob { priority: AssetPriority; } -function cacheStorage(): CacheStorageLike | undefined { +export function cacheStorage(): CacheStorageLike | undefined { return (globalThis as { caches?: CacheStorageLike }).caches; } diff --git a/packages/core/src/asset-memo.ts b/packages/core/src/asset-memo.ts new file mode 100644 index 0000000000..69d1c3d438 --- /dev/null +++ b/packages/core/src/asset-memo.ts @@ -0,0 +1,202 @@ +import { type CacheLike, cacheStorage, manifestFingerprint } from "./asset-cache.js"; +import { isNative } from "./platform.js"; + +/** + * Generated content kept between runs (PRD-860): `ctx.assets.memo(key, build)` returns what `build` + * returned the last time this exact build of the game ran, read back from disk, and calls `build` + * only when nothing is stored. A seeded world, a painted sky or a baked placement list is the same + * every run of one build, so a repeat visit stops paying for it. + * + * The value is named typed arrays, the shape a generator hands to geometry, instancing and + * textures. The build identity is the hashed entry script the web build writes into the page + * (`__TN_BUILD_ID__`), so any code change regenerates and the old build's entries are deleted. With + * no build identity (the dev server) or no Cache API (a native host), `build` runs every time and + * `TN_ASSET_MEMO` says why. + */ +export type MemoArray = + | Float32Array + | Float64Array + | Int8Array + | Int16Array + | Int32Array + | Uint8Array + | Uint8ClampedArray + | Uint16Array + | Uint32Array; + +export type MemoArrays = Readonly>; +/** Every field a typed array: a record or an interface, which has no index signature. */ +export type MemoShape = { readonly [K in keyof T]: MemoArray }; + +export type AssetMemo = >( + key: string, + build: () => T | Promise, +) => Promise; + +const KINDS = { + Float32Array, + Float64Array, + Int16Array, + Int32Array, + Int8Array, + Uint16Array, + Uint32Array, + Uint8Array, + Uint8ClampedArray, +} as const; +type Kind = keyof typeof KINDS; + +const BUILD_ID_GLOBAL = "__TN_BUILD_ID__"; +const MEMO_CACHE = "threenative-memo"; +/** A host no request ever reaches: the Cache API only needs an absolute url to key on. */ +const MEMO_ORIGIN = "https://memo.threenative.invalid/"; +const MAGIC = 0x4f4d4e54; // "TNMO" + +function invalid(detail: string): Error { + return new Error(`TN_ASSET_MEMO_INVALID: ${detail}`); +} + +function kindOf(value: unknown): Kind | undefined { + for (const kind of Object.keys(KINDS) as Kind[]) if (value instanceof KINDS[kind]) return kind; + return undefined; +} + +/** Header (magic, JSON length, JSON) then each array's bytes at an 8-byte boundary. */ +export function encodeMemo(value: MemoArrays): ArrayBuffer { + const entries = Object.entries(value).map(([name, array]) => { + const kind = kindOf(array); + if (kind === undefined) throw invalid(`"${name}" is not a typed array.`); + return { array, kind, name }; + }); + const align = (n: number): number => Math.ceil(n / 8) * 8; + const header = new TextEncoder().encode( + JSON.stringify(entries.map(({ array, kind, name }) => ({ kind, length: array.length, name }))), + ); + let offset = align(8 + header.byteLength); + const placed = entries.map((entry) => { + const at = offset; + offset = align(at + entry.array.byteLength); + return { ...entry, at }; + }); + const buffer = new ArrayBuffer(offset); + const view = new DataView(buffer); + view.setUint32(0, MAGIC, true); + view.setUint32(4, header.byteLength, true); + new Uint8Array(buffer, 8, header.byteLength).set(header); + for (const { array, at } of placed) + new Uint8Array(buffer, at, array.byteLength).set( + new Uint8Array(array.buffer, array.byteOffset, array.byteLength), + ); + return buffer; +} + +/** The inverse of `encodeMemo`; throws on anything it did not write. */ +export function decodeMemo(buffer: ArrayBuffer): Record { + const view = new DataView(buffer); + if (buffer.byteLength < 8 || view.getUint32(0, true) !== MAGIC) + throw invalid("stored entry is not a memo."); + const headerBytes = view.getUint32(4, true); + if (8 + headerBytes > buffer.byteLength) throw invalid("stored entry is truncated."); + const header: unknown = JSON.parse( + new TextDecoder().decode(new Uint8Array(buffer, 8, headerBytes)), + ); + if (!Array.isArray(header)) throw invalid("stored entry has no array list."); + const result: Record = {}; + let offset = Math.ceil((8 + headerBytes) / 8) * 8; + for (const entry of header as Array<{ kind?: unknown; length?: unknown; name?: unknown }>) { + const kind = entry.kind as Kind; + const Ctor = KINDS[kind]; + if (Ctor === undefined || typeof entry.name !== "string" || !Number.isInteger(entry.length)) + throw invalid("stored entry has a malformed array."); + const length = entry.length as number; + const bytes = length * Ctor.BYTES_PER_ELEMENT; + if (offset + bytes > buffer.byteLength) throw invalid("stored entry is truncated."); + result[entry.name] = new Ctor(buffer, offset, length); + offset = Math.ceil((offset + bytes) / 8) * 8; + } + return result; +} + +function report(fields: Record): void { + console.info(`TN_ASSET_MEMO:${JSON.stringify(fields)}`); +} + +function now(): number { + return globalThis.performance?.now() ?? Date.now(); +} + +export function createAssetMemo(namespace: string): AssetMemo { + const storage = cacheStorage(); + const id = (globalThis as Record)[BUILD_ID_GLOBAL]; + const buildId = typeof id === "string" && id !== "" ? id : undefined; + const reason = + storage === undefined + ? isNative() + ? "native host has no Cache API: generated content rebuilds each run" + : "cache API unavailable: generated content rebuilds each run" + : buildId === undefined + ? "no build id (dev server or a page the web build did not write): generated content rebuilds each run" + : undefined; + const prefix = buildId === undefined ? "" : `${MEMO_ORIGIN}${encodeURIComponent(buildId)}/`; + const name = `${MEMO_CACHE}-${manifestFingerprint([{ url: namespace }]).slice(0, 8)}`; + let opened: Promise | undefined; + /** Opens once, deleting what another build stored, so a cache holds one build's content. */ + const open = (): Promise => { + opened ??= (async () => { + const cache = await storage?.open(name); + if (cache === undefined) return undefined; + for (const request of await cache.keys()) + if (!request.url.startsWith(prefix)) await cache.delete(request.url); + return cache; + })().catch(() => undefined); + return opened; + }; + const checked = >(key: string, value: T): T => { + if (typeof value !== "object" || value === null) + throw invalid(`build for "${key}" returned no object.`); + for (const [field, array] of Object.entries(value)) + if (kindOf(array) === undefined) + throw invalid(`build for "${key}" returned "${field}", not a typed array.`); + return value; + }; + + return async >(key: string, build: () => T | Promise): Promise => { + if (typeof key !== "string" || key === "") throw invalid("key must be a non-empty string."); + const started = now(); + if (reason !== undefined) { + const value = checked(key, await build()); + report({ key, hit: false, ms: Math.round(now() - started), reason }); + return value; + } + const url = `${prefix}${encodeURIComponent(key)}`; + const cache = await open(); + if (cache !== undefined) { + try { + const stored = await cache.match(url); + if (stored !== undefined) { + const buffer = await stored.arrayBuffer(); + const value = decodeMemo(buffer) as unknown as T; + report({ bytes: buffer.byteLength, hit: true, key, ms: Math.round(now() - started) }); + return value; + } + } catch { + // A damaged entry is a miss: delete it and build, never fail the load over a cache. + await cache.delete(url).catch(() => undefined); + } + } + const value = checked(key, await build()); + const encoded = encodeMemo(value as MemoArrays); + const builtMs = Math.round(now() - started); + if (cache === undefined) { + report({ hit: false, key, ms: builtMs, reason: "cache storage refused to open" }); + return value; + } + void cache + .put(url, new Response(encoded)) + .then(() => report({ bytes: encoded.byteLength, hit: false, key, ms: builtMs, stored: true })) + .catch((error: unknown) => + report({ hit: false, key, ms: builtMs, stored: false, reason: String(error) }), + ); + return value; + }; +} diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index f610893458..8d20ffd6dd 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -20,6 +20,7 @@ import { canonicalUrl, createAssetCache, } from "./asset-cache.js"; +import { type AssetMemo, createAssetMemo } from "./asset-memo.js"; import { TN_VIRTUAL_GEOMETRY, VirtualGeometryPlugin } from "./clustered-mesh.js"; import { GEOMETRY_ASSET_KEY } from "./geometry-capture.js"; import { attachInstancedLod } from "./instanced-batch-lod.js"; @@ -218,6 +219,16 @@ export interface IAssetLoader { * a game that drives a loader outside `defineGame` may call it after its own reveal. */ startBackgroundFill(): Promise; + /** + * Generated content kept between runs (PRD-860): returns what `build` returned the last time this + * build of the game ran, read from disk, and calls `build` only when nothing is stored. Use it for + * seeded work that is the same every run, such as placement lists, painted sky pixels or terrain + * heights. The value is named typed arrays. A new build regenerates. On the dev server and on a + * native host `build` runs every time, and `TN_ASSET_MEMO` says why. + * + * @example const { pixels } = await ctx.assets.memo("sky", () => ({ pixels: paintSky() })); + */ + memo: AssetMemo; /** * How many loads this loader has been asked for and how many have settled (resolved or * rejected). A loading view reads the ratio; the runtime folds it into `ctx.startup.progress` @@ -1514,6 +1525,7 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad get progress() { return { pending: [...pending], requested, requestedBytes, settled, settledBytes }; }, + memo: createAssetMemo(canonicalUrl(manifestUrl)), prefetch: prefetchPaths, resolve: async (path) => (await resolveCandidates(path)).urls, startBackgroundFill: () => { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 8370427631..ccac151918 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -235,6 +235,9 @@ const BUG_REPORT_SKILL_PATHS = [ // Re-measured on the merge of develop into the Strata hardening branch: the develop tree moved // every no-install scaffold (three 0.187.0, the rts kit and the dropped Blender server), so no value // from either parent applies to the merged bytes. +// Recomputed 2026-11-08 (PRD-860): seven kits take the generated-content memo (the cloud noise, the +// collectathon sky) and one scattering table a run; collectathon then paints and plants the same bytes +// faster; re-measured on the merge of develop 0711f2b77 (PRD-859) by this test. const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // PRD-829 (2026-11-06): all sixteen trees move, measured by this test. Each kit AGENTS/CLAUDE pair // gains the `npx threenative doctor --fix` command line. The new `.threenative/agent-files.json` @@ -378,7 +381,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Re-pinned 2026-10-31: the starter crate gets a stacked-disc contact shadow (render/contactShadow.ts) // that keeps its contact coverage above the 0.02 floor at the core +1 EV key. // 2026-11-04 (PRD-777 on PRD-632): action-rpg AGENTS/CLAUDE name the spell factories in one line. Measured by this test. - "action-rpg": "8530428c87d1f1927cce69d9748dea727ff713afd3ffc1b22a72b0a24d8b1a82", + "action-rpg": "4507092b86970ea491d62fc69df656daaf9a88f76b09695301adf1c468cb1a79", // PRD-732 (2026-10-23): the new Hop Garden kit, measured through createProject (method checked // against platformer's unchanged value). Recomputed 2026-10-24 for the PRD-732 polish round: the // new hero, the dust and sparkle effects and the effects scenario. Recomputed 2026-10-24 for @@ -392,7 +395,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Recomputed 2026-10-24 for the shadow window: the 52 m map now travels 14 m ahead of the hero, // so the castle and trees the camera sees keep their shadows; the 2048 map and shadow pass cost // stay the same. - collectathon: "dc87cfcd5b8b3b927cea013b11c15870315bc9a98fc524fde55dfe04d3d66f6c", + collectathon: "7182d581f53549f738b356315c0af7caed7fe275f77be3c822be06eeb6d4c0a4", // PRD-733 (2026-10-23): the new Lantern Vale kit, measured through createProject. // Recomputed 2026-10-24 for the owner perf round: the fixed-step catch-up cap and the gentler // wind on the crowns, clusters and grass tufts. Only this tree moves. @@ -404,10 +407,10 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { adventure: "b3e72b48f9c3a31075ccbe98fc8bc377598c4596047f7fee0772579f6ee7b633", // PRD-734 (2026-10-24): the new Clover Cup kart kit, measured through createProject. kart: "4fe986f16315e0c3533bd115b9f16712bc094c1a1872fcf35361c37bfa82be1e", - minimal: "7061e7e24a98928cb39d1cffd9d50b279040b69bff9ef48d99e4065d95489116", + minimal: "36dc45bd50b04aaf04d42232d7f548cd235085bea03b085c5518117ab2abb7ac", platformer: "68f8a8b87fa7e8f0e07bb05c12cea13125372de6171e4f504f600e1b445397c7", puzzle: "d2b2cbbd19542993b0cdad6cb041ee4186a69e4dc068f79d32022f62be2a589d", - racing: "afb32d59ca43baece601ea1f677443c9e3e8d62b8a2bfefedd2f7f6ca91af2d4", + racing: "0efb3823e631eef952938ed5df3ef6e9fc3129faf4bc084e1931ca142d1ed704", rain: "ebb3600d8ad42a87a1aa97888a6ceb55c55f587571dc2ad3588b7e982d1656aa", // Re-pinned 2026-10-28: the rts high tier turns camera motion blur off (an RTS pan smeared every unit). // Re-pinned 2026-10-28 (PRD-765): rts becomes AstraCraft — painted ground, mesas, scrub, ACES 1.02 look and the command HUD; @@ -426,7 +429,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // `performance.maxTriangles` bound (45,000 to 54,000), which is a scenario file in the tree. // Measured by this test. rts: "eeb55962ecb4ffe14c7356a571477b12169718b9b9b94a2d67775a42dadb9d44", - runner: "cf5b97e6820851cbdd8761d565f1b3083497e746a736aa2e1d9334b2cf3edb0b", + runner: "b94708cc70588a519fdae80c410c9515714c2f4bc64a72556e53a832dd6244b0", // Finite-height readiness plus hull, sail, and buoy interpolation from the current swell pose. // Re-measured 2026-11-03 (PRD-617): its AGENTS/CLAUDE engine section no longer claims an engine // pin the template does not carry. Measured by this test. @@ -435,7 +438,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // samples as the adapter happens to draw. Only this tree moves. sailing: "2399fac8e544b03927b542492cba2b57ad5619457359eddbfe816bb3f4e737a3", // Recomputed 2026-10-26 (PRD-746): the scene merges the town through mergeByMaterial (src/townBatch.ts). - shooter: "435f2356890eb531fb42f5a36101efb94845be2823b3589c3daa464b73dc3070", + shooter: "ac425ad2d0b7398b77880652d8b59d232c6380d5377f86711294984666f2d9d6", snow: "9a647027643ed505d2962732926649e11d12e844b87771a07ad75e8525a4ad5e", // Measured 2026-10-16 through createProject after starter's completed-work quality fallback fix. // Recomputed 2026-10-16: starter's adaptiveQuality split its policy resolution into qualityPolicy.ts @@ -449,7 +452,7 @@ const PRD_201_PARENT_SCAFFOLD_HASHES: Readonly> = { // Play.ts's motion samples between presented frames and fixed steps, and moved two native // playtests; #606 moved the render resolve. Starter is the one template both sides changed. // Measured by this test. - starter: "58fa57ba8bc8dde0169e8fecf5cb72a8934c237eee79ae358eea65470aa85682", + starter: "591d44465e0a50cec033e821df39ca12fc121fd8a39273d291801c274a023c81", "tower-defense": "c1ff21a577d9a495ccf8383bbb66b880e8fc9bd325c4ea96a9c2d72716a02357", }; diff --git a/packages/create-threenative/__tests__/web-brand.spec.ts b/packages/create-threenative/__tests__/web-brand.spec.ts index 6a37a3c550..0de243a3a8 100644 --- a/packages/create-threenative/__tests__/web-brand.spec.ts +++ b/packages/create-threenative/__tests__/web-brand.spec.ts @@ -2,7 +2,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { makeTempDir } from "../../../test-support/temp-dir.js"; -import { createWebBrandPlugin } from "../src/web-brand.js"; +import { createWebBrandPlugin, withBuildId, withEnginePreload } from "../src/web-brand.js"; const roots: string[] = []; @@ -186,7 +186,8 @@ describe("web branding adapter", () => { }, }); expect(emitted._headers).toBe( - "/*\n Cross-Origin-Embedder-Policy: require-corp\n Cross-Origin-Opener-Policy: same-origin\n", + "/*\n Cross-Origin-Embedder-Policy: require-corp\n Cross-Origin-Opener-Policy: same-origin\n" + + "/assets/*\n Cache-Control: public, max-age=31536000, immutable\n", ); }); it("leaves upstream three alone in a native bundle, whose host is not a browser (PRD-637)", async () => { @@ -260,4 +261,85 @@ describe("web branding adapter", () => { expect(html.match(/rel="icon"/gu)).toHaveLength(1); expect(html).toContain('rel="manifest" href="/manifest.webmanifest"'); }); + + it("writes the hashed entry script as the build id ctx.assets.memo keys on, and none in dev (PRD-860)", () => { + const built = withBuildId( + '\n \n', + ); + expect(built).toContain( + '', + ); + expect(built.indexOf("__TN_BUILD_ID__")).toBeLessThan(built.indexOf('type="module"')); + const dev = ''; + expect(withBuildId(dev)).toBe(dev); + }); + + it("starts the engine's Wasm with the page, then Rapier, instead of after the entry script ran (PRD-860)", () => { + const html = + '\n \n'; + const bundle = { + "assets/index-A1.js": { fileName: "assets/index-A1.js", type: "chunk", moduleIds: [] }, + "assets/tn-native-engine-web-threads-T1.wasm": { + fileName: "assets/tn-native-engine-web-threads-T1.wasm", + type: "asset", + }, + "assets/tn-native-engine-web-P1.wasm": { + fileName: "assets/tn-native-engine-web-P1.wasm", + type: "asset", + }, + "assets/rapier-R1.js": { + fileName: "assets/rapier-R1.js", + type: "chunk", + moduleIds: ["/n/node_modules/@dimforge/rapier3d-compat/rapier.es.js"], + }, + }; + const threaded = withEnginePreload(html, bundle, true); + // Isolated, the threads build; not isolated, nothing (the isolation worker may reload the page). + expect(threaded).toContain('"/game/assets/tn-native-engine-web-threads-T1.wasm"'); + expect(threaded).not.toContain("web-P1.wasm"); + expect(threaded).toContain('"/game/assets/rapier-R1.js"'); + expect(threaded.indexOf("tn-native-engine-web-threads-T1")).toBeLessThan( + threaded.indexOf('type="module"'), + ); + // A repeat visit skips it: a preloaded response loses V8's compiled-code cache, and recompiling the + // module cost a laptop's repeat visit ~150 ms. The script runs as the page would run it. + const run = (isolated: boolean, stored: Record) => { + const links: { rel: string; href: string; onload?: () => void }[] = []; + const source = /`; +/** + * The build identity `ctx.assets.memo` keys on (PRD-860): the hashed entry script, which changes + * whenever any of the game's code does. Only a production page has one; the dev server's entry is + * `src/main.ts`, so a dev page writes none and generated content rebuilds every run there. + */ +export function withBuildId(html: string): string { + const entry = /]*\btype="module"[^>]*\bsrc="([^"]*\/assets\/[^"]+\.js)"/iu.exec( + html, + )?.[1]; + if (entry === undefined) return html; + return html.replace( + /\n > | undefined, + threaded: boolean, +): string { + if (bundle === undefined) return html; + const files = Object.values(bundle); + const entry = /]*\btype="module"[^>]*\bsrc="([^"]*)"/iu.exec(html)?.[1]; + const entryFile = files.find((file) => entry?.endsWith(file.fileName) === true); + if (entry === undefined || entryFile === undefined) return html; + const base = entry.slice(0, entry.length - entryFile.fileName.length); + const href = (test: (file: IBundleFile) => boolean): string | undefined => { + const found = files.find(test); + return found === undefined ? undefined : base + found.fileName; + }; + const threads = href((file) => /tn-native-engine-web-threads-[^/]*\.wasm$/u.test(file.fileName)); + const plain = href((file) => + /tn-native-engine-web-(?!threads-)[^/]*\.wasm$/u.test(file.fileName), + ); + const wasm = threaded ? threads : plain; + if (wasm === undefined) return html; + const rapier = href( + (file) => + file.type === "chunk" && + file.moduleIds?.some((id) => id.includes("/@dimforge/rapier3d")) === true, + ); + const quote = (value: string | undefined) => + JSON.stringify(value ?? null).replaceAll("<", "\\u003c"); + const script = ``; + return html.replace(/