From f8c245534f148edf2c55d119798e6357d37f7f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:03:31 -0700 Subject: [PATCH 01/36] fix(build): fail closed on unknown native asset capabilities (VQ-01) Partial implementation. Preserve decoder-free fallback and existing desktop V8 behavior; no newly admitted codec. Real runtime screenshot and full native loader qualification remain blocked. --- .../PRD-VQ-01-native-asset-capabilities.md | 79 +++++++++ packages/assets/__tests__/report.spec.ts | 8 + packages/assets/src/compile.ts | 42 ++--- packages/assets/src/report.ts | 2 +- .../__tests__/build.spec.ts | 25 ++- .../__tests__/consumer-cli.spec.ts | 10 +- .../__tests__/native-consumer.spec.ts | 33 +++- .../__tests__/native-ktx2.spec.ts | 38 ++++- .../__tests__/templates-native.spec.ts | 27 ++- .../vq-native-asset-capabilities.spec.ts | 151 +++++++++++++++++ packages/create-threenative/src/build.ts | 160 +++++++++++------- packages/runtime-native/AGENTS.md | 20 ++- packages/runtime-native/CLAUDE.md | 20 ++- packages/runtime-native/scripts/bundle.mjs | 27 +-- 14 files changed, 501 insertions(+), 141 deletions(-) create mode 100644 docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md create mode 100644 packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md new file mode 100644 index 0000000000..ae09f60309 --- /dev/null +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -0,0 +1,79 @@ +--- +prd_contract: v1 +--- + +# PRD-VQ-01 — Native asset compatibility follows the selected runtime and actual decoders + +**Status:** PARTIAL — 2026-10-02. Bounded desktop/unknown-runtime fail-closed repair implemented; mobile selected-artifact resolution and packaged-loader qualification remain open. +**Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 0 / correctness. +**Dependencies:** None. This is a build/runtime contract repair, not permission to remove compatibility guards. + +## Grounding and intended outcome + +[packages/create-threenative/src/build.ts](https://github.com/ThreeNativeHQ/threenative/blob/d72778382b134ef8763f58825cb4d4fd8cc0f6e3/packages/create-threenative/src/build.ts) still rejects compressed assets by mobile target in `assertNativeAssetsCompatible`, while the runtime documentation identifies Android V8 as the default. The source establishes inconsistent capability selection; it does not prove that shipping V8 alone makes the Basis, Meshopt or Draco loading paths work. Workers, bundling, file access, decoder payloads and GPU formats are separate requirements. + +**Outcome:** A packaged game either decodes its declared assets on its selected runtime or is refused before publication with an accurate codec-specific reason. Android V8 and QuickJS must not be conflated; desktop QuickJS must not bypass checks simply because it is desktop. + +## Design and ownership + +Resolve one runtime capability record from the exact selected artifact/cohort before cooking. Thread it through asset cooking, the compatibility guard, native bundling and the runtime loader. Record engine, WASM support, packaged decoder versions and supported texture formats independently. An unknown capability takes an explicit decoder-free path or fails; it is never inferred from the build machine or from a desktop executable used to package Android. Keep QuickJS rollback supported. Do not silently change an authored asset or trust a manifest that disagrees with the packaged bytes. + +Reuse `runtimeHasWebAssembly`, `assertNativeAssetsCompatible`, asset compiler decoder options and `packages/runtime-native/scripts/bundle.mjs`. Extend the artifact capability contract only where necessary. No new renderer, codec implementation, or blanket Android whitelist. Preserve current WebGL and decoder-free fallbacks. + +## Required behavior + +- A table-driven suite covers web, desktop V8, desktop QuickJS, Android V8, Android QuickJS and unknown artifacts, with each codec independently allowed or refused. +- The loader validates actual KTX2 block formats and fallback transcodes. Mesh compression is separately tested; smaller download bytes are not reported as smaller GPU vertex buffers. +- Cook-cache keys include decoder capabilities and cohort identity. Switching engine or codec cannot reuse incompatible output. +- Diagnostics identify the asset, codec, selected artifact and missing capability. Build failure must leave the previous packaged artifact intact. + +## Execution phases + +The unit proof paths now exist; the Phase 3 scenario paths remain **planned implementation targets**, not passing runtime proof. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates. + +### Phase 1 — One capability decision + +- [ ] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + Partial: desktop resolves the packager's actual binary before cooking and hashes its bytes. Failed, absent, unknown and non-V8 probes take the decoder-free path. Android/iOS never probe the desktop packaging executable; their exact artifact/cohort resolution remains open. +- [ ] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + + Partial: the record governs cooking, compatibility guards, and the desktop native-backend bundle path; embedded/shared KTX2 and Meshopt/Draco declarations are refused when unavailable. The existing desktop V8 path is preserved, not newly qualified. + +### Phase 2 — The packaged bytes are authoritative + +- [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + Partial: cook keys include explicit decoder capabilities and selected desktop runtime SHA-256; QuickJS/unknown desktop bundles use the existing refusing stubs. Decoder versions, KTX2 block-format validation and manifest-versus-payload verification remain open. +- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + +### Phase 3 — Qualify the visible result + +- [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`. + +## Acceptance criteria + +- [ ] All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here. +- [ ] The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` plus the Phase 3 lifecycle scenario. + +## Performance and promotion + +Record encoded bytes, decoded texture/geometry bytes and peak load memory per codec. Enabling a path requires correct pixels and bounded lifetime, not a promised compression ratio. Numerical targets are proposed acceptance targets, not benchmark results. Pin the fixture, metrics and thresholds before changing the implementation; never relax a failing threshold just to mark this PRD complete. Shared abstractions must pass the repository's reuse/LOC admission rule. Appearance remains editable source. + +## Blocked on + +Physical Android performance and thermals require a named phone and are separate from emulator correctness. Missing codec qualifications must remain individual exclusions. + +This executor has no GPU device, Android SDK/adb or KVM. Xvfb is denied with EPERM; browser/native pixels and emulator correctness are unrun. These limitations do not qualify any codec. The owner explicitly requires screenshot proofs for every PR (2026-10-02); no actual relevant runtime screenshot has been captured, so this PR remains draft. Test logs and synthetic images are not screenshot proof. + +## Completion record + +Update the phase boxes and this PRD only after the named proof runs. Record actual results inline, including any remaining exclusions. A merged planning or implementation PR alone is not proof that every acceptance criterion passed. Archive according to the parent PRD filing rules when the work is genuinely complete. + +### Bounded implementation verification — 2026-10-02 + +- Mandatory capability MCP search/detail reused `compileAssets`, `resolveBasisTranscoder` and the existing portable loader; no new codec or renderer was added. +- Red/green: 9 failures first demonstrated fail-open unknown engines and desktop codec bypass; 3 more demonstrated stale cache identity and QuickJS decoder inclusion. The final focused rerun passed 40/40 across capability/cache, native-KTX2 and report suites. A separate report regression failed on the false `no WebAssembly` claim and passed after diagnostics named missing qualified decoders (12/12 report tests). +- `pnpm exec vitest run packages/create-threenative/__tests__/build.spec.ts`: 29/29 passed in the combined nearest-lane run. `native-consumer.spec.ts` live caller checks pass 3/3, including exact resolved binary forwarding and preservation of the previous packaged artifact after an authored KTX2 refusal. The template-native and consumer CLI suites pass 16/16 after their packager fixtures follow the new early resolver contract. +- `pnpm --filter @threenative/assets build` and `pnpm --filter create-threenative build`: passed including declaration output and publint. Scoped create-threenative and assets TypeScript checks passed. Root lint passed with warnings; agent mirrors regenerated. +- Aggregate qualification is **not green**: root `pnpm test` stops before tests at tsx IPC `listen EPERM`; root `pnpm typecheck` was killed with exit 137. The broader native consumer lane has two unavailable-`jar` failures, separately reproduced using its unchanged baseline test source. Full build, GPU/native scenarios, decoder memory/lifecycle measurements and actual packaged-loader codec pixels remain unverified. +- All phase and acceptance boxes intentionally remain open. No full Phase 1, Phase 2, native decoder admission, visual result or PRD completion is claimed by this patch. diff --git a/packages/assets/__tests__/report.spec.ts b/packages/assets/__tests__/report.spec.ts index 3ca9225f91..854cc93602 100644 --- a/packages/assets/__tests__/report.spec.ts +++ b/packages/assets/__tests__/report.spec.ts @@ -121,6 +121,14 @@ describe("formatModelSizes", () => { }); describe("formatSkippedCompression", () => { + it("reports missing decoders without inferring that the selected engine has no WASM", () => { + const lines = formatSkippedCompression([ + { kind: "texture", files: 1, bytes: 128, reason: "platform" }, + ]); + expect(lines[0]).toContain("no qualified KTX2 decoder"); + expect(lines[0]).not.toContain("no WebAssembly"); + }); + it("names the decoder capability that a target skipped without blaming dedupe", () => { const rows: readonly ISkippedCompressionRow[] = [ { bytes: 240, files: 2, kind: "model", reason: "platform" }, diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index bcd6e90daf..b3c71c7094 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -265,31 +265,16 @@ export interface IAssetCompileOptions { */ readonly processingOrder?: "reversed" | "sorted"; /** - * The platform this bake is for, which decides whether compression can ship at all. - * - * Android and iOS run the native host without WebAssembly, so they carry no Basis transcoder - * and no Meshopt decoder: a `.ktx2` texture or a meshopt-compressed mesh in a mobile bundle is - * a black screen, and `threenative build` refuses one with `TN_NATIVE_KTX2_UNSUPPORTED`. Web - * and desktop decode both. - * - * Absent means web — a direct `compileAssets` call, or a project that compiles once and serves - * the result. `threenative build` always names its `--target`, so the passes that a platform - * cannot decode drop for that build and stay on for every other one. This is the whole reason - * `assets.textures: "none"` used to be pinned in the scaffolded config: the author was asked to - * choose one constant for four targets, and every game that wanted Android shipped its web - * build uncompressed too. The build knows its target; it decides. + * The conservative default decoder policy: web/desktop keep the existing loaders; + * Android/iOS stay decoder-free until their packaged loaders are qualified. A V8 engine + * alone does not establish a working Basis, Meshopt or Draco path. Absent means web. + * Native builds provide runtimeDecoders from their selected artifact instead. */ readonly platform?: "android" | "desktop" | "ios" | "web"; - /** - * What the runtime that will execute this bake can decode, when the caller has probed it. - * - * `platform` is a proxy: it answers "android and iOS have no WebAssembly" for the targets this - * repository ships. A desktop host can also lack WebAssembly — the Linux arm64 lane builds - * QuickJS over wgpu-native — and then the same meshopt/KTX2 passes it would keep produce an - * asset nothing can decode. `threenative build` reads the engine from the runtime binary it will - * package and passes the real capability here; absent, the platform-derived default stands. - */ + /** Which compressed outputs the selected runtime's bundle can decode, independently of WASM. */ readonly runtimeDecoders?: IAssetRuntimeDecoderCapabilities; + /** Exact selected runtime/cohort identity; changing it invalidates cooked output. */ + readonly runtimeIdentity?: string; readonly source?: string; /** Overrides resolution of three's Basis transcoder for the copy into the output root. */ readonly transcoder?: IBasisTranscoder; @@ -1594,9 +1579,8 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo `TN_ASSETS_OVERLAP: source '${sourceRoot}' and output '${outputRoot}' must be disjoint directories.`, ); } - // Android and iOS have no WebAssembly and therefore no Basis transcoder and no Meshopt - // decoder. The registry uses each pass's declaration below, so decoder-free work in the mixed - // model pass survives on those targets. + // Mobile keeps the decoder-free policy until its packaged loaders are qualified. + // Each pass declares its decoder dependency, so decoder-free model work survives. const runtimeDecoderCapabilities: IAssetRuntimeDecoderCapabilities = options.runtimeDecoders ?? { ktx2: options.platform !== "android" && options.platform !== "ios", meshopt: options.platform !== "android" && options.platform !== "ios", @@ -2534,6 +2518,14 @@ export async function compileAssets( const cacheKeys = included.map((pass) => pass.cacheKey ?? null); return JSON.stringify({ ...(cacheKeys.some((key) => key !== null) ? { passCacheKeys: cacheKeys } : {}), + ...(options.runtimeIdentity === undefined && options.runtimeDecoders === undefined + ? {} + : { + runtime: { + identity: options.runtimeIdentity ?? null, + decoders: layout.runtimeDecoderCapabilities, + }, + }), pipelineVersion: PIPELINE_VERSION, passes: included.map((pass) => pass.name), options: included.map((pass) => pass.configuration ?? null), diff --git a/packages/assets/src/report.ts b/packages/assets/src/report.ts index b8d203d6c8..9b306fd14f 100644 --- a/packages/assets/src/report.ts +++ b/packages/assets/src/report.ts @@ -587,7 +587,7 @@ export function formatSkippedCompression(rows: readonly ISkippedReportRow[]): re const reason = row.reason === "config" ? `assets.${row.kind}s is "none"` - : `this target has no WebAssembly and cannot run its ${decoders} decoder`; + : `this target has no qualified ${decoders} decoder`; return `TN_ASSETS_COMPRESSION_SKIPPED ${row.kind}: ${String(row.files)} file(s), ${(row.bytes / 1e6).toFixed(1)} MB ${action} because ${reason}.`; }); } diff --git a/packages/create-threenative/__tests__/build.spec.ts b/packages/create-threenative/__tests__/build.spec.ts index f9cb892b44..41cf385abf 100644 --- a/packages/create-threenative/__tests__/build.spec.ts +++ b/packages/create-threenative/__tests__/build.spec.ts @@ -17,6 +17,7 @@ import { nativeOrientation, parseBuildArgs, publishStagedArtifact, + resolveRuntimeAssetCapabilities, runtimeHasWebAssembly, stagingPath, writePackagingConfig, @@ -878,7 +879,7 @@ cpSync("public", out, { recursive: true }); ); }); - it("guards web-only UI on every native target and WASM on mobile only", async () => { + it("guards web-only UI on every native target and WASM without a known desktop V8 artifact", async () => { const root = await makeTempDir("threenative-mobile-bundle-"); roots.push(root); const native = path.join(root, "native.js"); @@ -893,7 +894,15 @@ cpSync("public", out, { recursive: true }); /TN_NATIVE_WEB_ONLY_UI.*src\/main\.ts.*PRD-051/u, ); } - await expect(assertNativeBundleCompatible(wasm, "desktop")).resolves.toBeUndefined(); + await expect(assertNativeBundleCompatible(wasm, "desktop")).rejects.toThrow( + "TN_NATIVE_WASM_UNSUPPORTED", + ); + const desktopV8 = resolveRuntimeAssetCapabilities( + "desktop", + path.resolve("packages/create-threenative/src/build.ts"), + (() => ({ status: 0, stdout: "+ v8 build" })) as never, + ); + await expect(assertNativeBundleCompatible(wasm, "desktop", desktopV8)).resolves.toBeUndefined(); for (const target of ["android", "ios"] as const) { await expect(assertNativeBundleCompatible(wasm, target)).rejects.toThrow( /TN_NATIVE_WASM_ON_MOBILE.*src\/game\.ts.*PRD-052/u, @@ -966,14 +975,16 @@ describe("runtime WebAssembly capability", () => { ).toBe(true); }); - it("keeps the WASM desktop backend when the runtime is unknown or unreadable", () => { - expect(runtimeHasWebAssembly(undefined)).toBe(true); - expect(runtimeHasWebAssembly("/no/such/runtime")).toBe(true); - expect(runtimeHasWebAssembly(runtime, (() => ({ status: 1, stdout: "" })) as never)).toBe(true); + it("refuses WASM when the runtime is unknown or unreadable", () => { + expect(runtimeHasWebAssembly(undefined)).toBe(false); + expect(runtimeHasWebAssembly("/no/such/runtime")).toBe(false); + expect(runtimeHasWebAssembly(runtime, (() => ({ status: 1, stdout: "" })) as never)).toBe( + false, + ); expect( runtimeHasWebAssembly(runtime, (() => { throw new Error("EACCES"); }) as never), - ).toBe(true); + ).toBe(false); }); }); diff --git a/packages/create-threenative/__tests__/consumer-cli.spec.ts b/packages/create-threenative/__tests__/consumer-cli.spec.ts index 3ecdc5fa40..96d77bac2a 100644 --- a/packages/create-threenative/__tests__/consumer-cli.spec.ts +++ b/packages/create-threenative/__tests__/consumer-cli.spec.ts @@ -44,10 +44,14 @@ async function desktopCallerFixture(): Promise<{ root: string; log: string }> { ` import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; + import { pathToFileURL } from 'node:url'; + export async function resolveDesktopRuntime(explicit) { return explicit ?? import.meta.filename; } + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const output = process.argv[process.argv.indexOf('--output') + 1]; mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, 'packaged'); writeFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2))); + } `, ); return { root, log }; @@ -57,7 +61,11 @@ test("desktop caller does not reinterpret a source checkout as an explicit runti const { root, log } = await desktopCallerFixture(); process.env.THREENATIVE_RUNTIME_SOURCE = path.join(root, "checkout"); await build({ cwd: root, target: "desktop" }); - assert.ok(!JSON.parse(await readFile(log, "utf8")).includes("--runtime")); + const args: string[] = JSON.parse(await readFile(log, "utf8")); + assert.equal( + args[args.indexOf("--runtime") + 1], + path.join(root, "node_modules/@threenative/runtime-native/scripts/package-desktop.mjs"), + ); }); test("explicit desktop binary still wins when a source override is also set", async () => { diff --git a/packages/create-threenative/__tests__/native-consumer.spec.ts b/packages/create-threenative/__tests__/native-consumer.spec.ts index 0186b34e8d..96312220bd 100644 --- a/packages/create-threenative/__tests__/native-consumer.spec.ts +++ b/packages/create-threenative/__tests__/native-consumer.spec.ts @@ -6,6 +6,7 @@ import { createServer } from "node:http"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; +import { encodeToKTX2 } from "ktx2-encoder"; import { afterEach, test } from "vitest"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { build, parseBuildArgs } from "../src/build.js"; @@ -46,26 +47,34 @@ async function callerFixture(): Promise<{ root: string; log: string }> { `, ); const log = path.join(root, "packager-args.json"); + await writeFile(path.join(root, "selected-runtime"), "fixture runtime: unknown engine"); for (const target of ["desktop", "android", "ios"]) { await writeFile( path.join(runtime, `scripts/package-${target}.mjs`), ` import { mkdirSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; + import { pathToFileURL } from 'node:url'; + export async function resolveDesktopRuntime(explicit) { + return explicit ?? ${JSON.stringify(path.join(root, "selected-runtime"))}; + } + if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const output = process.argv[process.argv.indexOf('--output') + 1]; mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, 'packaged'); writeFileSync(${JSON.stringify(log)}, JSON.stringify(process.argv.slice(2))); + } `, ); } return { root, log }; } -test("desktop consumer caller leaves runtime discovery to the packager", async () => { +test("desktop consumer caller resolves the exact package runtime before cooking", async () => { const { root, log } = await callerFixture(); await build({ cwd: root, target: "desktop" }); - assert.ok(!JSON.parse(await readFile(log, "utf8")).includes("--runtime")); + const args: string[] = JSON.parse(await readFile(log, "utf8")); + assert.equal(args[args.indexOf("--runtime") + 1], path.join(root, "selected-runtime")); }); test("desktop caller preserves an explicit maintainer binary including spaces", async () => { @@ -76,6 +85,26 @@ test("desktop caller preserves an explicit maintainer binary including spaces", assert.equal(args[args.indexOf("--runtime") + 1], process.env.THREENATIVE_RUNTIME_BINARY); }); +test("a codec refusal in the live desktop build preserves the previous packaged artifact", async () => { + const { root, log } = await callerFixture(); + await mkdir(path.join(root, "assets")); + await mkdir(path.join(root, "dist-native")); + const final = path.join(root, "dist-native/consumer-test"); + await writeFile(final, "previous working package"); + const ktx2 = await encodeToKTX2(new Uint8Array([0]), { + generateMipmap: false, + imageDecoder: async () => ({ data: new Uint8Array(4 * 4 * 4).fill(255), height: 4, width: 4 }), + isUASTC: true, + }); + await writeFile(path.join(root, "assets/rock.ktx2"), ktx2); + await assert.rejects( + build({ cwd: root, target: "desktop" }), + /TN_NATIVE_KTX2_UNSUPPORTED.*rock.ktx2.*selected-runtime.*unknown.*Basis\/KTX2/u, + ); + assert.equal(await readFile(final, "utf8"), "previous working package"); + await assert.rejects(readFile(log), { code: "ENOENT" }); +}); + test("Android source opt-in survives parsing and subprocess dispatch", async () => { const { root, log } = await callerFixture(); const options = parseBuildArgs(["build", "--target", "android", "--allow-source-build"]); diff --git a/packages/create-threenative/__tests__/native-ktx2.spec.ts b/packages/create-threenative/__tests__/native-ktx2.spec.ts index 149720ce97..0909f554b1 100644 --- a/packages/create-threenative/__tests__/native-ktx2.spec.ts +++ b/packages/create-threenative/__tests__/native-ktx2.spec.ts @@ -7,12 +7,23 @@ import { encodeToKTX2 } from "ktx2-encoder"; import { afterEach, describe, expect, it } from "vitest"; import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; -import { assertNativeAssetsCompatible, assertNativeBundleCompatible, build } from "../src/build.js"; +import { + assertNativeAssetsCompatible, + assertNativeBundleCompatible, + build, + resolveRuntimeAssetCapabilities, +} from "../src/build.js"; import { loadConfig } from "../src/config.js"; const run = promisify(execFile); const roots: string[] = []; const bundler = path.resolve("packages/runtime-native/scripts/bundle.mjs"); +const desktopV8 = () => + resolveRuntimeAssetCapabilities( + "desktop", + path.resolve("packages/create-threenative/src/build.ts"), + (() => ({ status: 0, stdout: "+ v8 build" })) as never, + ); const viteInstall = path.resolve(import.meta.dirname, "../node_modules/vite"); afterEach(async () => { @@ -43,7 +54,11 @@ export default defineGame({ scenes: {} }); return project; } -async function bundle(project: string, target: "android" | "desktop" | "ios"): Promise { +async function bundle( + project: string, + target: "android" | "desktop" | "ios", + nativeBackend = false, +): Promise { const output = path.join(project, `dist/${target}.js`); await run( process.execPath, @@ -57,6 +72,7 @@ async function bundle(project: string, target: "android" | "desktop" | "ios"): P target, "--output", output, + ...(nativeBackend ? ["--native-backend"] : []), ], { cwd: project }, ); @@ -111,12 +127,24 @@ describe("compressed assets on native targets", () => { } }, 180_000); + it("bundles decoder-free desktop QuickJS without WASM decoders", async () => { + const project = await gameProject("threenative-ktx2-quickjs-"); + const output = await bundle(project, "desktop", true); + const source = await readFile(output, "utf8"); + expect(source).not.toMatch(/\bWebAssembly\b/u); + expect(source).toContain("TN_NATIVE_KTX2_UNSUPPORTED"); + expect(source).toContain("TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED"); + await expect(assertNativeBundleCompatible(output, "desktop")).resolves.toBeUndefined(); + }, 180_000); + it("keeps three's real decoders on desktop, which has a WASM engine", async () => { const project = await gameProject("threenative-ktx2-desktop-"); const output = await bundle(project, "desktop"); const source = await readFile(output, "utf8"); - await expect(assertNativeBundleCompatible(output, "desktop")).resolves.toBeUndefined(); + await expect( + assertNativeBundleCompatible(output, "desktop", desktopV8()), + ).resolves.toBeUndefined(); expect(source).toMatch(/\bWebAssembly\b/u); expect(source).not.toContain("TN_NATIVE_KTX2_UNSUPPORTED"); }, 180_000); @@ -219,7 +247,9 @@ export default defineGame({ scenes: {} }); const config = await loadConfig(project); for (const target of ["desktop", "web"] as const) { - await expect(assertNativeAssetsCompatible(project, target, config)).resolves.toBeUndefined(); + await expect( + assertNativeAssetsCompatible(project, target, config, desktopV8()), + ).resolves.toBeUndefined(); } }, 60_000); diff --git a/packages/create-threenative/__tests__/templates-native.spec.ts b/packages/create-threenative/__tests__/templates-native.spec.ts index d2ceac1d7c..cc0133f14f 100644 --- a/packages/create-threenative/__tests__/templates-native.spec.ts +++ b/packages/create-threenative/__tests__/templates-native.spec.ts @@ -53,6 +53,21 @@ async function bundle( } /** A project with a stub runtime-native whose desktop packager is `packager`. */ +/** Package entry is both the selected-runtime resolver and a subprocess executable. */ +async function writePackager(runtime: string, target: string, source: string): Promise { + await writeFile(path.join(runtime, `scripts/package-${target}-execute.mjs`), source); + await writeFile( + path.join(runtime, `scripts/package-${target}.mjs`), + ` +import { pathToFileURL } from "node:url"; +export async function resolveDesktopRuntime() { return import.meta.filename; } +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await import("./package-${target}-execute.mjs"); +} +`, + ); +} + async function stubRuntime( project: string, packager: string, @@ -79,7 +94,7 @@ await mkdir(path.dirname(output), { recursive: true }); await writeFile(output, "globalThis.__nativeProof = true;\\n"); `, ); - await writeFile(path.join(runtime, "scripts/package-desktop.mjs"), packager); + await writePackager(runtime, "desktop", packager); } /** The packager the staged-publish tests share: a complete run that writes its output. */ @@ -408,8 +423,9 @@ await writeFile(output, "globalThis.__nativeProof = true;\\n"); `, ); for (const target of ["desktop", "android", "ios"] as const) { - await writeFile( - path.join(runtime, `scripts/package-${target}.mjs`), + await writePackager( + runtime, + target, `import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; const output = process.argv[process.argv.indexOf("--output") + 1]; @@ -462,8 +478,9 @@ await writeFile(output, "export default { start() {} };\\n"); `, ); for (const target of ["desktop", "android", "ios"] as const) { - await writeFile( - path.join(runtime, `scripts/package-${target}.mjs`), + await writePackager( + runtime, + target, `import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; const output = process.argv[process.argv.indexOf("--output") + 1]; diff --git a/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts b/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts new file mode 100644 index 0000000000..c5e971497f --- /dev/null +++ b/packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts @@ -0,0 +1,151 @@ +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { compileAssets } from "@threenative/assets"; +import { afterEach, describe, expect, it } from "vitest"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; +import { + assertNativeAssetsCompatible, + resolveRuntimeAssetCapabilities, + runtimeHasWebAssembly, +} from "../src/build.js"; + +const roots: string[] = []; +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const runtime = path.resolve("packages/create-threenative/src/build.ts"); + +describe("VQ-01 native asset capability contract", () => { + it.each([ + { target: "web", engine: "v8", expected: "browser", wasm: true, decoder: true }, + { target: "desktop", engine: "v8", expected: "v8", wasm: true, decoder: true }, + { target: "desktop", engine: "quickjs", expected: "quickjs", wasm: false, decoder: false }, + { target: "desktop", engine: "unknown", expected: "unknown", wasm: false, decoder: false }, + { target: "android", engine: "v8", expected: "unknown", wasm: false, decoder: false }, + { target: "android", engine: "quickjs", expected: "unknown", wasm: false, decoder: false }, + { target: "ios", engine: "v8", expected: "unknown", wasm: false, decoder: false }, + ] as const)( + "keeps $target separate from the build-host $engine engine", + ({ target, engine, expected, wasm, decoder }) => { + let probes = 0; + const capabilities = resolveRuntimeAssetCapabilities(target, runtime, (() => { + probes++; + return { status: 0, stdout: `Native WebGPU JS runtime - wgpu-native + ${engine} build` }; + }) as never); + expect(capabilities.engine).toBe(expected); + expect(capabilities.webAssembly).toBe(wasm); + expect(capabilities.decoders).toEqual({ ktx2: decoder, meshopt: decoder, draco: decoder }); + expect(probes).toBe(target === "desktop" ? 1 : 0); + }, + ); + + it("changes the runtime identity when the selected bytes change at the same path", async () => { + const root = await makeTempDir("vq-runtime-identity-"); + roots.push(root); + const binary = path.join(root, "runtime"); + const probe = (() => ({ status: 0, stdout: "+ v8 build" })) as never; + await writeFile(binary, "cohort one"); + const before = resolveRuntimeAssetCapabilities("desktop", binary, probe); + await writeFile(binary, "cohort two"); + const after = resolveRuntimeAssetCapabilities("desktop", binary, probe); + expect(before.identity).not.toBe(after.identity); + expect(before.artifact).toBe(after.artifact); + }); + + it.each(["runtimeIdentity", "runtimeDecoders"] as const)( + "invalidates cooked output when %s changes even with identical passes", + async (field) => { + const root = await makeTempDir("vq-runtime-cache-"); + roots.push(root); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets/unchanged.txt"), "same source bytes"); + const options = { + cwd: root, + config: { audio: "none", models: "none", textures: "none" } as const, + runtimeDecoders: { ktx2: false, meshopt: false }, + runtimeIdentity: "desktop:cohort-one:quickjs", + }; + await compileAssets(options); + const before = JSON.parse( + await readFile(path.join(root, "public/assets.manifest.json"), "utf8"), + ); + const changed = + field === "runtimeIdentity" + ? { ...options, runtimeIdentity: "desktop:cohort-two:quickjs" } + : { ...options, runtimeDecoders: { ktx2: true, meshopt: false } }; + await compileAssets(changed); + const after = JSON.parse( + await readFile(path.join(root, "public/assets.manifest.json"), "utf8"), + ); + expect(after.entries["unchanged.txt"].output).not.toBe( + before.entries["unchanged.txt"].output, + ); + await expect( + readFile(path.join(root, "public", after.entries["unchanged.txt"].output), "utf8"), + ).resolves.toBe("same source bytes"); + }, + ); + + it.each([ + { name: "missing artifact", binary: "/no/such/runtime", result: { status: 0, stdout: "" } }, + { name: "failed probe", binary: runtime, result: { status: 1, stdout: "" } }, + { + name: "unrecognized engine", + binary: runtime, + result: { status: 0, stdout: "+ future-engine build" }, + }, + { + name: "no engine declaration", + binary: runtime, + result: { status: 0, stdout: "v8 is installed on the host" }, + }, + ])("does not infer WASM from $name", ({ binary, result }) => { + expect(runtimeHasWebAssembly(binary, (() => result) as never)).toBe(false); + }); + + it.each([ + { codec: "KTX2", entry: { output: "rock.ktx2" }, code: "TN_NATIVE_KTX2_UNSUPPORTED" }, + { + codec: "Meshopt", + entry: { output: "rock.glb", extensions: ["EXT_meshopt_compression"] }, + code: "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED", + }, + { + codec: "Draco", + entry: { output: "rock.glb", extensions: ["KHR_draco_mesh_compression"] }, + code: "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED", + }, + { + codec: "embedded KTX2", + entry: { output: "rock.glb", extensions: ["KHR_texture_basisu"] }, + code: "TN_NATIVE_KTX2_UNSUPPORTED", + }, + { + codec: "shared KTX2", + entry: { output: "rock.glb", sharedImages: [{ output: "rock.ktx2", codec: "ktx2" }] }, + code: "TN_NATIVE_KTX2_UNSUPPORTED", + }, + ])( + "refuses $codec on an unknown desktop artifact, preserving the previous package", + async ({ entry, code }) => { + const root = await makeTempDir("vq-native-assets-"); + roots.push(root); + await mkdir(path.join(root, "public")); + await mkdir(path.join(root, "dist-native")); + await writeFile(path.join(root, "dist-native/game"), "previous working artifact"); + await writeFile( + path.join(root, "public/assets.manifest.json"), + JSON.stringify({ entries: { "rocks/rock": entry }, version: 1 }), + ); + const config = { assets: { output: "public" } } as Parameters< + typeof assertNativeAssetsCompatible + >[2]; + await expect(assertNativeAssetsCompatible(root, "desktop", config)).rejects.toThrow(code); + await expect(readFile(path.join(root, "dist-native/game"), "utf8")).resolves.toBe( + "previous working artifact", + ); + await expect(assertNativeAssetsCompatible(root, "web", config)).resolves.toBeUndefined(); + }, + ); +}); diff --git a/packages/create-threenative/src/build.ts b/packages/create-threenative/src/build.ts index 62497e8c9b..80087bba21 100644 --- a/packages/create-threenative/src/build.ts +++ b/packages/create-threenative/src/build.ts @@ -1,6 +1,6 @@ import { spawn, spawnSync } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; +import { createHash, randomUUID } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; import { mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -70,6 +70,7 @@ async function projectName(cwd: string): Promise { export async function assertNativeBundleCompatible( bundle: string, target: NativeBuildTarget, + capabilities = resolveRuntimeAssetCapabilities(target), ): Promise { const source = await readFile(bundle, "utf8"); const webOnlyUi = [ @@ -81,7 +82,7 @@ export async function assertNativeBundleCompatible( `TN_NATIVE_WEB_ONLY_UI: ${target} bundle contains ${webOnlyUi.map(([label]) => label).join(", ")}. Keep the portable game in src/game.ts and move DOM or React mounting to src/main.ts; native UI is owned by PRD-051.`, ); } - if (target === "desktop") return; + if (target === "desktop" && capabilities.webAssembly) return; const wasm = [ ["WebAssembly", /\bWebAssembly\b/u], ["Rapier WASM", /rapier_wasm|RAPIER_VERSION|rawrapier/u], @@ -89,7 +90,7 @@ export async function assertNativeBundleCompatible( ].filter(([, pattern]) => (pattern as RegExp).test(source)); if (wasm.length === 0) return; throw new Error( - `TN_NATIVE_WASM_ON_MOBILE: ${target} bundle contains ${wasm.map(([label]) => label).join(", ")}. Move web-only WASM imports out of src/game.ts or provide a threenative-native conditional backend; mobile navigation is owned by PRD-052.`, + `${target === "desktop" ? "TN_NATIVE_WASM_UNSUPPORTED" : "TN_NATIVE_WASM_ON_MOBILE"}: ${target} artifact ${capabilities.artifact} (${capabilities.engine}) bundle contains ${wasm.map(([label]) => label).join(", ")}. Move web-only WASM imports out of src/game.ts or provide a threenative-native conditional backend; mobile navigation is owned by PRD-052.`, ); } @@ -137,30 +138,22 @@ export function assetRoot(cwd: string, config: IResolvedThreeNativeConfig): stri return path.resolve(cwd, config.assets?.output ?? "public"); } -/** - * Refuses compiled assets a mobile native target cannot decode, at the first point the build - * knows about them — after the compile step, before a bundle or an APK exists. - * - * Android runs QuickJS and iOS runs JavaScriptCore without a WASM JIT, so neither has - * `WebAssembly`. Three's Basis/zstd transcoder, its Meshopt decoder and Draco's wasm decoder - * therefore cannot run there and are not in the mobile bundle at all (they are replaced in - * `runtime-native/scripts/bundle.mjs`, which is what keeps `TN_NATIVE_WASM_ON_MOBILE` green). - * A game that genuinely ships such an asset must hear that here rather than discover it as a - * missing texture on a phone. - */ +/** Refuse compiled codecs the selected runtime/bundle cannot decode before packaging starts. */ export async function assertNativeAssetsCompatible( cwd: string, target: BuildTarget, config: IResolvedThreeNativeConfig, + capabilities = resolveRuntimeAssetCapabilities(target), ): Promise { - if (target === "desktop" || target === "web") return; + if (target === "web") return; const outputRoot = assetRoot(cwd, config); const manifestPath = path.join(outputRoot, "assets.manifest.json"); let raw: string; try { raw = await readFile(manifestPath, "utf8"); - } catch { - return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; } let parsed: unknown; try { @@ -170,32 +163,46 @@ export async function assertNativeAssetsCompatible( `TN_NATIVE_ASSET_MANIFEST_INVALID: '${manifestPath}' is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, ); } - const entries = (parsed as { entries?: unknown }).entries; - if (typeof entries !== "object" || entries === null) { + const entries = (parsed as { entries?: unknown } | null)?.entries; + if (typeof entries !== "object" || entries === null || Array.isArray(entries)) { throw new Error(`TN_NATIVE_ASSET_MANIFEST_INVALID: '${manifestPath}' has no 'entries' object.`); } const rows = Object.entries(entries as Record).map( ([logical, entry]) => [logical, (entry ?? {}) as Record] as const, ); const ktx2 = rows - .filter(([, entry]) => typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output)) + .filter( + ([, entry]) => + (typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output)) || + (Array.isArray(entry.extensions) && entry.extensions.includes("KHR_texture_basisu")) || + (Array.isArray(entry.sharedImages) && + entry.sharedImages.some( + (image) => + image !== null && + typeof image === "object" && + typeof image.output === "string" && + /\.ktx2$/iu.test(image.output), + )), + ) .map(([logical]) => logical); - if (ktx2.length > 0) { + if (!capabilities.decoders.ktx2 && ktx2.length > 0) { throw new Error( - `TN_NATIVE_KTX2_UNSUPPORTED: ${target} cannot ship compiled KTX2 textures (${namedAssets(ktx2)}). The mobile native runtime has no WebAssembly and therefore no Basis transcoder, so nothing in the bundle can decode them. Replace or transcode the authored KTX2 source for mobile, or exclude it from mobile builds; keep compressed textures on the web target.`, + `TN_NATIVE_KTX2_UNSUPPORTED: ${target} cannot ship compiled KTX2 textures (${namedAssets(ktx2)}). Selected artifact ${capabilities.artifact} (${capabilities.engine}) has no admitted Basis/KTX2 decoder in its bundle. Replace or transcode the authored KTX2 source for ${target === "desktop" ? "this runtime" : "mobile"}, or exclude it from ${target === "desktop" ? "this runtime's" : "mobile"} builds; keep compressed textures on the web target.`, ); } const compressedModels = rows .filter(([, entry]) => (Array.isArray(entry.extensions) ? (entry.extensions as unknown[]) : []).some( (extension) => - typeof extension === "string" && COMPRESSED_MODEL_EXTENSIONS.includes(extension), + typeof extension === "string" && + COMPRESSED_MODEL_EXTENSIONS.includes(extension) && + !capabilities.decoders[extension === "KHR_draco_mesh_compression" ? "draco" : "meshopt"], ), ) .map(([logical]) => logical); if (compressedModels.length > 0) { throw new Error( - `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: ${target} cannot ship compressed model geometry (${namedAssets(compressedModels)}). The mobile native runtime has no WebAssembly and therefore no Meshopt or Draco decoder. Native compilation keeps shared images and decoder-free model rewrites while omitting Meshopt output; rebuild the native target, or keep compressed models on the web target.`, + `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: ${target} cannot ship compressed model geometry (${namedAssets(compressedModels)}). Selected artifact ${capabilities.artifact} (${capabilities.engine}) has no admitted ${rows.flatMap(([, entry]) => (Array.isArray(entry.extensions) ? entry.extensions.filter((extension) => COMPRESSED_MODEL_EXTENSIONS.includes(String(extension))) : [])).join(", ")} decoder in its bundle. Native compilation keeps shared images and decoder-free model rewrites while omitting Meshopt output; rebuild the native target, or keep compressed models on the web target.`, ); } } @@ -567,31 +574,56 @@ export async function writePackagingConfig( return output; } -/** - * Whether the runtime that will execute this build has WebAssembly. - * - * The engine decides it, not the target: V8 compiles WebAssembly, QuickJS and JavaScriptCore do - * not. A desktop build is for either engine — Linux x64, Windows and macOS use V8, the Linux - * arm64 lane builds QuickJS over wgpu-native — so the target alone cannot answer it. The runtime - * binary the packager is handed (`THREENATIVE_RUNTIME_BINARY`, set by the same build invocation) - * is probed for its engine; absent or unreadable, the answer is the historical desktop one, true, - * because every published desktop prebuilt runs V8. - */ +/** Read only the selected executable. An unrecognized or failed probe never proves WASM. */ +function runtimeEngine( + binary: string | undefined, + probe: typeof spawnSync, +): "v8" | "quickjs" | "jsc" | "unknown" { + if (binary === undefined || !existsSync(binary)) return "unknown"; + try { + const result = probe(binary, ["--version"], { encoding: "utf8", timeout: 10_000 }); + if (result.status !== 0) return "unknown"; + const engine = /\+ (\S+) build/u.exec(String(result.stdout ?? ""))?.[1]?.toLowerCase(); + return engine === "v8" || engine === "quickjs" || engine === "jsc" ? engine : "unknown"; + } catch { + return "unknown"; + } +} + export function runtimeHasWebAssembly( binary: string | undefined = process.env.THREENATIVE_RUNTIME_BINARY, probe: typeof spawnSync = spawnSync, ): boolean { - if (binary === undefined || !existsSync(binary)) return true; - let result: ReturnType; - try { - result = probe(binary, ["--version"], { encoding: "utf8" }); - } catch { - return true; + return runtimeEngine(binary, probe) === "v8"; +} + +/** + * One conservative decision for cooking, validation and bundling. Only desktop's selected + * executable is runnable here: a desktop packaging tool is never evidence about Android/iOS. + * Mobile stays decoder-free until its selected artifact and packaged loaders are qualified. + * Desktop V8 retains its existing decoder path; WASM alone does not qualify new codec targets. + */ +export function resolveRuntimeAssetCapabilities( + target: BuildTarget, + binary?: string, + probe: typeof spawnSync = spawnSync, +) { + const selected = target === "desktop" ? binary : undefined; + const engine = target === "web" ? "browser" : runtimeEngine(selected, probe); + const artifact = selected === undefined ? `${target}:unresolved` : path.resolve(selected); + let identity = artifact; + if (selected !== undefined && existsSync(selected)) { + identity = `${target}:sha256:${createHash("sha256").update(readFileSync(selected)).digest("hex")}`; } - if (result.status !== 0) return true; - const engine = /\+ (\S+) build/u.exec(String(result.stdout ?? ""))?.[1] ?? ""; - // The engine names are CMake's MYSTRAL_JS_ENGINE_NAME: v8, jsc or quickjs. Only V8 runs WASM. - return !/^(?:quickjs|jsc)$/iu.test(engine); + const webAssembly = engine === "v8" || engine === "browser"; + const bundled = target === "web" || (target === "desktop" && webAssembly); + return { + artifact, + identity, + engine, + webAssembly, + decoders: { ktx2: bundled, meshopt: bundled, draco: bundled }, + }; } async function bundleNative( @@ -711,24 +743,38 @@ async function buildNative( const config = await loadConfig(cwd, { target, profile }); announceProfile(config); assertNativeUiRendererCompatible(target, config.ui.renderer); - // A native host without WebAssembly cannot run Rapier as WASM or decode meshopt/KTX2, so it - // takes the native physics backend and a decoder-free bake. The capability is the runtime - // engine, not the target: android and iOS are always QuickJS/JSC, and a desktop host is V8 - // except on the Linux arm64 lane. `assertNativeAssetsCompatible` stays below as the backstop. - const webAssembly = runtimeHasWebAssembly(); + const entry = await nativeEntry(cwd, config); + // Resolve the same desktop binary the packager will receive, before cooking. A default + // prebuilt used to be selected only after the cook had guessed from the build machine. + let selectedRuntime: string | undefined; + if (target === "desktop") { + const module = path.join( + packageRoot(cwd, "@threenative/runtime-native"), + "scripts", + "package-desktop.mjs", + ); + const { resolveDesktopRuntime } = (await import(pathToFileURL(module).href)) as { + resolveDesktopRuntime(explicit?: string): Promise; + }; + const explicit = process.env.THREENATIVE_RUNTIME_BINARY; + selectedRuntime = await resolveDesktopRuntime( + explicit === undefined ? undefined : path.resolve(cwd, explicit), + ); + } + const capabilities = resolveRuntimeAssetCapabilities(target, selectedRuntime); await compileAssets({ config: config.assets, cwd, platform: target, - ...(webAssembly ? {} : { runtimeDecoders: { ktx2: false, meshopt: false } }), + runtimeDecoders: capabilities.decoders, + runtimeIdentity: capabilities.identity, }); - await assertNativeAssetsCompatible(cwd, target, config); - const entry = await nativeEntry(cwd, config); + await assertNativeAssetsCompatible(cwd, target, config, capabilities); const orientation = config.display.orientation; const configPath = await writePackagingConfig(cwd, config); const runtimeRoot = packageRoot(cwd, "@threenative/runtime-native"); - const bundle = await bundleNative(cwd, runtimeRoot, entry, target, !webAssembly); - await assertNativeBundleCompatible(bundle, target); + const bundle = await bundleNative(cwd, runtimeRoot, entry, target, !capabilities.webAssembly); + await assertNativeBundleCompatible(bundle, target, capabilities); // The compiled root the project configured, which `compileAssets` above baked into — not the // `public` default. A project whose cook profile writes elsewhere was packaging an empty // directory while its own assets sat next to it, unread. @@ -810,9 +856,7 @@ async function buildNative( ...(ui === undefined ? [] : ["--ui", ui]), "--config", configPath, - ...(process.env.THREENATIVE_RUNTIME_BINARY - ? ["--runtime", path.resolve(process.env.THREENATIVE_RUNTIME_BINARY)] - : []), + ...(selectedRuntime === undefined ? [] : ["--runtime", selectedRuntime]), "--output", output, ], diff --git a/packages/runtime-native/AGENTS.md b/packages/runtime-native/AGENTS.md index 8ad9a6a0cc..47bcb0e9f1 100644 --- a/packages/runtime-native/AGENTS.md +++ b/packages/runtime-native/AGENTS.md @@ -85,15 +85,17 @@ portable graph reached browser UI; `TN_NATIVE_WASM_ON_MOBILE` means Android or i WASM. Do not weaken these guards. Every packager stages the configured asset root (`assets.output`, default `public/`) through `selectManifestAssets` beside the game bundle, and a missing runtime asset must reject game startup rather than fall back to the network. -**Mobile has no compressed-asset decoders.** Android QuickJS and iOS JSC have no WASM engine, -so three's Basis/zstd transcoder (`KTX2Loader`), its Meshopt decoder and Draco's wasm decoder -cannot run there. `scripts/bundle.mjs` replaces all three with refusing stubs on the mobile -targets only — desktop keeps the real ones — which is what keeps a game that ships no -compressed asset out of `TN_NATIVE_WASM_ON_MOBILE`; their specifiers are static strings inside -`await import(...)`, so a bundler inlines them whether the game uses them or not. A game whose -compiled assets do need one is refused by `threenative build` before any bundle exists, with -`TN_NATIVE_KTX2_UNSUPPORTED` or `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`. Making mobile decode -either format is a native decoder question, not a bundler one. +**Mobile still has no qualified compressed-asset decoder path.** Android's V8 engine alone +is not loader proof: Basis, Meshopt and Draco stay refusing stubs until their packaged paths are +qualified. Android QuickJS and iOS JSC also have no WASM engine. The desktop build resolves and +hashes its exact package runtime before cooking; QuickJS, JSC and unknown engine probes take the +decoder-free compiler and bundle path. The existing desktop V8 decoder path stays unchanged. +`scripts/bundle.mjs --native-backend` selects the refusing stubs on desktop too. A compiled asset +that needs a missing decoder is refused before packaging with `TN_NATIVE_KTX2_UNSUPPORTED` or +`TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`, naming the asset and selected runtime. Authored KTX2, +embedded Basis textures and shared KTX2 images are all checked. Native cook-cache keys include +runtime identity and decoder capabilities. Full mobile artifact/cohort resolution and +packaged-loader qualification remain open in PRD-VQ-01. ## Package boundaries diff --git a/packages/runtime-native/CLAUDE.md b/packages/runtime-native/CLAUDE.md index 46d251db21..aaa582fe0d 100644 --- a/packages/runtime-native/CLAUDE.md +++ b/packages/runtime-native/CLAUDE.md @@ -87,15 +87,17 @@ portable graph reached browser UI; `TN_NATIVE_WASM_ON_MOBILE` means Android or i WASM. Do not weaken these guards. Every packager stages the configured asset root (`assets.output`, default `public/`) through `selectManifestAssets` beside the game bundle, and a missing runtime asset must reject game startup rather than fall back to the network. -**Mobile has no compressed-asset decoders.** Android QuickJS and iOS JSC have no WASM engine, -so three's Basis/zstd transcoder (`KTX2Loader`), its Meshopt decoder and Draco's wasm decoder -cannot run there. `scripts/bundle.mjs` replaces all three with refusing stubs on the mobile -targets only — desktop keeps the real ones — which is what keeps a game that ships no -compressed asset out of `TN_NATIVE_WASM_ON_MOBILE`; their specifiers are static strings inside -`await import(...)`, so a bundler inlines them whether the game uses them or not. A game whose -compiled assets do need one is refused by `threenative build` before any bundle exists, with -`TN_NATIVE_KTX2_UNSUPPORTED` or `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`. Making mobile decode -either format is a native decoder question, not a bundler one. +**Mobile still has no qualified compressed-asset decoder path.** Android's V8 engine alone +is not loader proof: Basis, Meshopt and Draco stay refusing stubs until their packaged paths are +qualified. Android QuickJS and iOS JSC also have no WASM engine. The desktop build resolves and +hashes its exact package runtime before cooking; QuickJS, JSC and unknown engine probes take the +decoder-free compiler and bundle path. The existing desktop V8 decoder path stays unchanged. +`scripts/bundle.mjs --native-backend` selects the refusing stubs on desktop too. A compiled asset +that needs a missing decoder is refused before packaging with `TN_NATIVE_KTX2_UNSUPPORTED` or +`TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED`, naming the asset and selected runtime. Authored KTX2, +embedded Basis textures and shared KTX2 images are all checked. Native cook-cache keys include +runtime identity and decoder capabilities. Full mobile artifact/cohort resolution and +packaged-loader qualification remain open in PRD-VQ-01. ## Package boundaries diff --git a/packages/runtime-native/scripts/bundle.mjs b/packages/runtime-native/scripts/bundle.mjs index f09b305029..243338cfbf 100644 --- a/packages/runtime-native/scripts/bundle.mjs +++ b/packages/runtime-native/scripts/bundle.mjs @@ -148,28 +148,15 @@ function generateVFSHeader(files, outputPath) { } /** - * Three's compressed-asset decoders that cannot run on a mobile native target. - * - * Android runs QuickJS and iOS runs JavaScriptCore without a WASM JIT: neither has - * `WebAssembly`, so the Basis/zstd transcoder behind `KTX2Loader`, the Meshopt decoder and - * Draco's wasm decoder have nothing to instantiate there. Their specifiers are static strings - * inside `await import(...)`, so a bundler resolves and inlines them whether or not the game - * ships a single compressed asset — which is how one `.ktx2`-free game came to fail - * `TN_NATIVE_WASM_ON_MOBILE`. Replacing them here keeps that guard intact by keeping WASM out - * of the bundle instead of by ignoring it. - * - * The replacements construct and configure exactly like the originals so a game that ships no - * compressed asset boots normally (`createAssetLoader` builds the shared KTX2 loader eagerly); - * they refuse, by name, only when asked to decode. A game whose compiled assets do need one is - * refused earlier and louder, by `threenative build`, before this bundle is written. + * Decoder-free targets retain loader configuration/lifecycle but refuse actual decoding. + * Android V8 still lacks a qualified packaged decoder path; its engine is not the reason to + * enable one. Desktop QuickJS and an unknown desktop artifact use the same refusing stubs. */ const MOBILE_KTX2_MESSAGE = - // Deliberately never spells the WASM host object: `TN_NATIVE_WASM_ON_MOBILE` greps the - // finished bundle for that identifier, and a refusal message must not read as the thing it - // refuses. - "TN_NATIVE_KTX2_UNSUPPORTED: this Android/iOS bundle carries no Basis transcoder, because the mobile native runtime has no WASM engine. Compiled .ktx2 textures cannot be decoded here; native compilation automatically keeps decoder-free authored textures, or keep compressed textures on the web target."; + // Never spell the WASM host global: the bundle guard must not mistake diagnostics for code. + "TN_NATIVE_KTX2_UNSUPPORTED: this decoder-free native bundle carries no qualified Basis transcoder. Compiled .ktx2 textures cannot be decoded here; native compilation keeps decoder-free authored textures, or keep compressed textures on the web target."; const MOBILE_MESH_MESSAGE = - "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: this Android/iOS bundle carries no Meshopt or Draco decoder, because the mobile native runtime has no WASM engine. Compressed model geometry cannot be decoded here; native compilation keeps shared images and decoder-free model geometry, or keep compressed models on the web target."; + "TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED: this decoder-free native bundle carries no qualified Meshopt or Draco decoder. Compressed model geometry cannot be decoded here; native compilation keeps shared images and decoder-free model geometry, or keep compressed models on the web target."; const MOBILE_DECODER_STUBS = [ { @@ -340,7 +327,7 @@ void game.start().catch((error) => console.error( root: absoluteProject, define: { 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) }, plugins: - target === 'desktop' + target === 'desktop' && !nativeBackend ? [importMetaPlugin, nativeEntryPlugin] : [importMetaPlugin, mobileDecodersPlugin, nativeEntryPlugin], // One three, whatever the import graph looks like. The game resolves `three` beside itself From 8d6cd881ac54b206d82e0ba00167b0e277edb402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:26:12 -0700 Subject: [PATCH 02/36] test(vq01): qualify packaged QuickJS assets with native screenshot lane Exercise real selected runtime, decoder-free normalization, bundled bytes, native mailbox screenshots and named adapter provenance. Prepared capture lane; no unrun native PASS claimed. --- .../workflows/integration-native-assets.yml | 86 +++++++++ .../PRD-VQ-01-native-asset-capabilities.md | 15 +- ...vq-native-asset-capabilities.playtest.json | 19 ++ examples/abyss-framework/vq-assets/.gitignore | 3 + .../abyss-framework/vq-assets/generate.ts | 175 ++++++++++++++++++ .../abyss-framework/vq-assets/package.json | 5 + .../abyss-framework/vq-assets/src/game.ts | 111 +++++++++++ .../vq-assets/src/render/stage.ts | 34 ++++ .../vq-assets/threenative.config.ts | 25 +++ .../abyss-framework/vq-assets/tsconfig.json | 5 + .../__tests__/vq-native-fixture.spec.ts | 100 ++++++++++ .../native-asset-capture-proof.spec.ts | 86 +++++++++ scripts/native-asset-capture-proof.ts | 75 ++++++++ scripts/verify-native-asset-capabilities.ts | 162 ++++++++++++++++ 14 files changed, 899 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/integration-native-assets.yml create mode 100644 examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json create mode 100644 examples/abyss-framework/vq-assets/.gitignore create mode 100644 examples/abyss-framework/vq-assets/generate.ts create mode 100644 examples/abyss-framework/vq-assets/package.json create mode 100644 examples/abyss-framework/vq-assets/src/game.ts create mode 100644 examples/abyss-framework/vq-assets/src/render/stage.ts create mode 100644 examples/abyss-framework/vq-assets/threenative.config.ts create mode 100644 examples/abyss-framework/vq-assets/tsconfig.json create mode 100644 packages/create-threenative/__tests__/vq-native-fixture.spec.ts create mode 100644 scripts/__tests__/native-asset-capture-proof.spec.ts create mode 100644 scripts/native-asset-capture-proof.ts create mode 100644 scripts/verify-native-asset-capabilities.ts diff --git a/.github/workflows/integration-native-assets.yml b/.github/workflows/integration-native-assets.yml new file mode 100644 index 0000000000..35c669385c --- /dev/null +++ b/.github/workflows/integration-native-assets.yml @@ -0,0 +1,86 @@ +name: Integration native asset fallback +on: + pull_request: + paths: + - 'packages/create-threenative/src/build.ts' + - 'packages/create-threenative/src/buildReport.ts' + - 'packages/create-threenative/__tests__/vq-native-*.spec.ts' + - 'packages/assets/src/**' + - 'packages/core/src/assets.ts' + - 'packages/runtime-native/scripts/bundle.mjs' + - 'packages/runtime-native/scripts/package-desktop.mjs' + - 'packages/runtime-native/scripts/native-build.mjs' + - 'packages/runtime-native/scripts/download-deps.mjs' + - 'examples/abyss-framework/vq-assets/**' + - 'examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json' + - 'scripts/verify-native-asset-capabilities.ts' + - 'scripts/native-asset-capture-proof.ts' + - 'scripts/__tests__/native-asset-capture-proof.spec.ts' + - '.github/workflows/integration-native-assets.yml' +permissions: + contents: read +concurrency: + group: integration-native-assets-${{ github.event.pull_request.head.ref || github.ref_name }} + cancel-in-progress: true +jobs: + packaged-quickjs: + # Match native-platforms starter-linux: this architecture selects QuickJS/wgpu without + # changing any runtime default. Native dependencies use the repository's locked provisioner. + runs-on: ubuntu-24.04-arm + timeout-minutes: 60 + env: + CMAKE_BUILD_PARALLEL_LEVEL: '2' + TN_PLAYTEST_ALLOW_SOFTWARE: '1' + TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha }} + - uses: ./.github/actions/pnpm + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: '22' + cache: pnpm + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - run: pnpm install --frozen-lockfile + - name: Build this commit's JavaScript packages + uses: ./.github/actions/workspace-dist + with: + save-bundles: 'false' + pack-archives: 'false' + - name: Validate real compressed-source normalization and capture guards + run: >- + pnpm exec vitest run --maxWorkers=1 + packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts + packages/create-threenative/__tests__/vq-native-fixture.spec.ts + scripts/__tests__/native-asset-capture-proof.spec.ts + - name: Restore checksum-provisioned native dependencies + uses: actions/cache/restore@v4 + with: + path: packages/runtime-native/third_party + key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }} + - name: Install the existing native lane's system prerequisites + run: >- + sudo apt-get update && sudo apt-get install -y + libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev + libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev + libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev + libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr + libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev + - name: Build the same QuickJS host the normal ARM desktop lane ships + run: pnpm --filter @threenative/runtime-native native:build + - name: Build the real fixture and capture its packaged native executable + env: + THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral + run: node --import tsx scripts/verify-native-asset-capabilities.ts + - name: Retain raw native captures, artifact identities and decoded payload measurements + if: always() + uses: actions/upload-artifact@v7 + with: + name: vq01-native-assets-${{ github.event.pull_request.head.sha }} + path: artifacts/vq01-native-assets/ + retention-days: 30 + if-no-files-found: warn diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md index ae09f60309..0b4fc52dae 100644 --- a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -29,7 +29,7 @@ Reuse `runtimeHasWebAssembly`, `assertNativeAssetsCompatible`, asset compiler de ## Execution phases -The unit proof paths now exist; the Phase 3 scenario paths remain **planned implementation targets**, not passing runtime proof. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates. +The unit proof paths and the desktop fallback scenario now exist. Browser and Android execution remain unrun; the existence of the shared scenario is not passing runtime proof. Reuse an existing equivalent test or scenario after inspecting current code, and update the canonical proof path rather than adding a duplicate. Follow [EXECUTE.md](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/EXECUTE.md) for fixture setup, variables, review and repository gates. ### Phase 1 — One capability decision @@ -43,7 +43,8 @@ The unit proof paths now exist; the Phase 3 scenario paths remain **planned impl - [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. Partial: cook keys include explicit decoder capabilities and selected desktop runtime SHA-256; QuickJS/unknown desktop bundles use the existing refusing stubs. Decoder versions, KTX2 block-format validation and manifest-versus-payload verification remain open. -- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts`. + Partial: generated Meshopt and Draco textured/animated inputs are compared against decoder-free cooked files for exact decoded positions, triangle counts, animation samples and texture pixels. Authored KTX2 is encoded by the existing texture pass and explicitly refused; no packaged KTX2 decode is claimed. ### Phase 3 — Qualify the visible result @@ -77,3 +78,13 @@ Update the phase boxes and this PRD only after the named proof runs. Record actu - `pnpm --filter @threenative/assets build` and `pnpm --filter create-threenative build`: passed including declaration output and publint. Scoped create-threenative and assets TypeScript checks passed. Root lint passed with warnings; agent mirrors regenerated. - Aggregate qualification is **not green**: root `pnpm test` stops before tests at tsx IPC `listen EPERM`; root `pnpm typecheck` was killed with exit 137. The broader native consumer lane has two unavailable-`jar` failures, separately reproduced using its unchanged baseline test source. Full build, GPU/native scenarios, decoder memory/lifecycle measurements and actual packaged-loader codec pixels remain unverified. - All phase and acceptance boxes intentionally remain open. No full Phase 1, Phase 2, native decoder admission, visual result or PRD completion is claimed by this patch. + + +### Native screenshot qualification lane — 2026-10-02 (prepared, unrun) + +- `examples/abyss-framework/vq-assets/` is a separate opt-in fixture; the ordinary example is unchanged. It loads two actual cooked GLBs and a PNG through `ctx.assets`, then advances their authored clips with `AnimationPlayer`. The checker colours belong to those assets, not to a drawn diagnostic overlay. +- `node --import tsx scripts/verify-native-asset-capabilities.ts` requires a real QuickJS executable in `THREENATIVE_RUNTIME_BINARY`, builds through the actual desktop resolver/cook/bundle/packager, verifies decoded payload equality, the exact runtime prefix in the packaged executable and the published build-report digest, then proves an authored KTX2 refusal preserves that same package. `runDesktopPlaytest` drives the resulting executable and captures its framebuffer with the existing mailbox transport. +- `.github/workflows/integration-native-assets.yml` uses the existing Linux ARM64 QuickJS/wgpu source-build lane, checksum-locked native provisioner and normal read-only `pull_request` permissions. Hosted software rasterization is explicitly declared; no hardware-performance claim is permitted. No credentials, guard exemptions or runtime defaults change. +- Local checks: 25/25 focused capability/real-fixture/capture-validator tests passed; fixture and focused verifier TypeScript checks passed; assets and CLI packages rebuilt with declarations/publint. The actual QuickJS fixture bundle was built and contains no `WebAssembly` reference. The scenario validates and workflow YAML parses; CI structure/needs checks pass 141/141. The local native verifier refuses at the explicit missing-runtime requirement before any runtime claim. +- Capture acceptance is fail-closed: native `device.screenshot` provenance, nonempty passed live assertions, named WebGPU adapter, no lost-device/errors, exact 960×640 framebuffer, and both authored checker colours in opaque pixels in each of the three specimen regions. The transparent-RGB negative test failed before the alpha guard and passes with it. A lifecycle test confirms the fixture's owned sRGB texture clone and cached original are each disposed once, including a repeated exit. Published PNGs must be original runner bytes with matching SHA-256 and source/run provenance. +- No new screenshot or hosted-run PASS is claimed in this commit. Full mobile/cohort, packaged-decoder and lifecycle acceptance stays open. diff --git a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json new file mode 100644 index 0000000000..894a6a2ae5 --- /dev/null +++ b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "name": "vq01-packaged-quickjs-assets", + "target": "desktop", + "viewport": { "width": 960, "height": 640 }, + "warmupFrames": 30, + "steps": [{ "waitFrames": 90 }], + "artifacts": { "screenshots": true, "console": true }, + "assert": { + "diagnostics": { "noConsoleErrors": true, "noRuntimeDiagnostics": true, "runtimeReady": true }, + "resources": [ + { "id": "state", "path": "loadedModels", "equals": 2, "allowTrivial": "Both real normalized GLBs must finish loading before enter and the first observation." }, + { "id": "state", "path": "texturedMeshes", "equals": 2, "allowTrivial": "A texture image on each loaded GLB is counted before the first observation." }, + { "id": "state", "path": "vertices", "gte": 100, "allowTrivial": "Decoded GLB geometry is measured at scene entry." }, + { "id": "state", "path": "frames", "changed": true }, + { "id": "state", "path": "maxPoseDelta", "gte": 0.05, "allowTrivial": "The native warm-up can already advance both authored animation tracks." } + ] + } +} diff --git a/examples/abyss-framework/vq-assets/.gitignore b/examples/abyss-framework/vq-assets/.gitignore new file mode 100644 index 0000000000..e47b3a7dd4 --- /dev/null +++ b/examples/abyss-framework/vq-assets/.gitignore @@ -0,0 +1,3 @@ +.generated/ +.threenative/ +dist-native/ diff --git a/examples/abyss-framework/vq-assets/generate.ts b/examples/abyss-framework/vq-assets/generate.ts new file mode 100644 index 0000000000..ebc29790c9 --- /dev/null +++ b/examples/abyss-framework/vq-assets/generate.ts @@ -0,0 +1,175 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { Document, NodeIO } from "@gltf-transform/core"; +import { + ALL_EXTENSIONS, + EXTMeshoptCompression, + KHRDracoMeshCompression, + KHRMaterialsUnlit, +} from "@gltf-transform/extensions"; +import { texturePass } from "@threenative/assets"; +import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; +import { PNG } from "pngjs"; +import { BoxGeometry, type BufferGeometry, TorusGeometry } from "three"; + +const draco = createRequire(import.meta.url)("draco3dgltf") as { + createEncoderModule(): Promise; + createDecoderModule(): Promise; +}; +export const MODEL_NAMES = ["meshopt", "draco"] as const; + +function checker(a: readonly number[], b: readonly number[]): Buffer { + const png = new PNG({ width: 32, height: 32 }); + for (let y = 0; y < 32; y++) + for (let x = 0; x < 32; x++) { + const rgb = ((x >> 3) + (y >> 3)) % 2 === 0 ? a : b; + png.data.set([rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0, 255], (y * 32 + x) * 4); + } + return PNG.sync.write(png); +} + +function specimen(name: string, geometry: BufferGeometry, image: Buffer): Document { + const document = new Document(); + const buffer = document.createBuffer(); + const material = document + .createMaterial(name) + .setBaseColorTexture(document.createTexture(name).setMimeType("image/png").setImage(image)); + material.setExtension( + "KHR_materials_unlit", + document.createExtension(KHRMaterialsUnlit).createUnlit(), + ); + const primitive = document.createPrimitive().setMaterial(material); + for (const [attribute, semantic, type] of [ + ["position", "POSITION", "VEC3"], + ["normal", "NORMAL", "VEC3"], + ["uv", "TEXCOORD_0", "VEC2"], + ] as const) { + primitive.setAttribute( + semantic, + document + .createAccessor() + .setType(type) + .setArray(Float32Array.from(geometry.getAttribute(attribute).array)) + .setBuffer(buffer), + ); + } + if (!geometry.index) throw new Error("Fixture requires indexed geometry"); + primitive.setIndices( + document + .createAccessor() + .setType("SCALAR") + .setArray(Uint16Array.from(geometry.index.array)) + .setBuffer(buffer), + ); + geometry.dispose(); + const node = document + .createNode(`${name}-animated`) + .setMesh(document.createMesh(name).addPrimitive(primitive)); + document.createScene().addChild(node); + const sampler = document + .createAnimationSampler() + .setInterpolation("LINEAR") + .setInput( + document + .createAccessor() + .setType("SCALAR") + .setArray(new Float32Array([0, 1, 2])) + .setBuffer(buffer), + ) + .setOutput( + document + .createAccessor() + .setType("VEC4") + .setArray(new Float32Array([0, 0, 0, 1, 0, Math.sin(0.6), 0, Math.cos(0.6), 0, 0, 0, 1])) + .setBuffer(buffer), + ); + document + .createAnimation("turn") + .addSampler(sampler) + .addChannel( + document + .createAnimationChannel() + .setTargetNode(node) + .setTargetPath("rotation") + .setSampler(sampler), + ); + return document; +} + +export async function fixtureReader(): Promise { + await MeshoptDecoder.ready; + return new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ + "meshopt.decoder": MeshoptDecoder, + "draco3d.decoder": await draco.createDecoderModule(), + }); +} + +/** Real compressed inputs from the installed encoders; all source art is authored here. */ +export async function generateNativeAssetFixture(source: string): Promise { + await mkdir(source, { recursive: true }); + await MeshoptEncoder.ready; + for (const name of MODEL_NAMES) { + const document = specimen( + name, + name === "meshopt" + ? new BoxGeometry(1.4, 1.4, 1.4, 2, 2, 2) + : new TorusGeometry(0.7, 0.27, 12, 32), + name === "meshopt" + ? checker([20, 210, 225], [240, 85, 35]) + : checker([235, 45, 170], [245, 205, 35]), + ); + if (name === "meshopt") + document + .createExtension(EXTMeshoptCompression) + .setRequired(true) + .setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE }); + else document.createExtension(KHRDracoMeshCompression).setRequired(true); + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS).registerDependencies({ + "meshopt.encoder": MeshoptEncoder, + "draco3d.encoder": await draco.createEncoderModule(), + }); + await writeFile(path.join(source, `${name}.glb`), await io.writeBinary(document)); + } + const png = checker([95, 210, 60], [85, 100, 235]); + await writeFile(path.join(source, "checker.png"), png); + const result = await texturePass({ overrides: [{ glob: "*.png", codec: "uastc" }] }).apply( + png, + "checker.png", + ); + if (Buffer.isBuffer(result) || result.outputExtension !== ".ktx2") + throw new Error("Fixture encoder did not produce KTX2"); + return result.buffer; +} + +/** Decoded payload measurements, not download sizes or claimed GPU savings. */ +export async function inspectFixtureModel(file: string) { + const bytes = await readFile(file); + const document = await (await fixtureReader()).read(file); + const root = document.getRoot(); + return { + encodedBytes: bytes.length, + extensions: root.listExtensionsUsed().map((extension) => extension.extensionName), + triangles: root + .listMeshes() + .flatMap((mesh) => mesh.listPrimitives()) + .reduce((sum, primitive) => sum + (primitive.getIndices()?.getCount() ?? 0) / 3, 0), + positions: root + .listMeshes() + .flatMap((mesh) => mesh.listPrimitives()) + .flatMap((primitive) => Array.from(primitive.getAttribute("POSITION")?.getArray() ?? [])), + decodedGeometryBytes: root + .listAccessors() + .reduce((sum, accessor) => sum + (accessor.getArray()?.byteLength ?? 0), 0), + images: root + .listTextures() + .map((texture) => PNG.sync.read(Buffer.from(texture.getImage() ?? []))), + animation: root + .listAnimations() + .flatMap((animation) => animation.listSamplers()) + .map((sampler) => ({ + input: Array.from(sampler.getInput()?.getArray() ?? []), + output: Array.from(sampler.getOutput()?.getArray() ?? []), + })), + }; +} diff --git a/examples/abyss-framework/vq-assets/package.json b/examples/abyss-framework/vq-assets/package.json new file mode 100644 index 0000000000..2c89f456a4 --- /dev/null +++ b/examples/abyss-framework/vq-assets/package.json @@ -0,0 +1,5 @@ +{ + "name": "vq-native-assets", + "private": true, + "type": "module" +} diff --git a/examples/abyss-framework/vq-assets/src/game.ts b/examples/abyss-framework/vq-assets/src/game.ts new file mode 100644 index 0000000000..dc698c9434 --- /dev/null +++ b/examples/abyss-framework/vq-assets/src/game.ts @@ -0,0 +1,111 @@ +import { AnimationPlayer, type ICtx, Scene, defineGame } from "@threenative/core"; +import { playtest } from "@threenative/core/playtest"; +import { + type AnimationClip, + type Group, + Mesh, + type MeshStandardMaterial, + type PerspectiveCamera, + Quaternion, + type Texture, +} from "three"; +import config from "../threenative.config.js"; +import { stage } from "./render/stage.js"; + +type State = { + loadedModels: number; + texturedMeshes: number; + vertices: number; + frames: number; + maxPoseDelta: number; +}; +type Model = { scene: Group; animations: AnimationClip[] }; +const names = ["meshopt", "draco"] as const; + +export class AssetScene extends Scene { + static override readonly initialState: State = { + loadedModels: 0, + texturedMeshes: 0, + vertices: 0, + frames: 0, + maxPoseDelta: 0, + }; + #models: Model[] = []; + #png: Texture | undefined; + #players: AnimationPlayer[] = []; + #owned: Mesh[] = []; + + override async load(ctx: ICtx): Promise { + this.#models = await Promise.all(names.map((name) => ctx.assets.model(`${name}.glb`))); + // Options return an owned sRGB clone; the cached source remains loader-owned. + this.#png = await ctx.assets.texture("checker.png", {}); + } + + override enter(ctx: ICtx) { + if (!this.#png) throw new Error("PNG did not load"); + this.#owned = stage(ctx.scene, ctx.camera as PerspectiveCamera, this.#png); + let texturedMeshes = 0; + let vertices = 0; + const rotations: Quaternion[] = []; + const animated = this.#models.map((model, index) => { + model.scene.position.set(index === 0 ? -2.4 : 0, 1.35, 0); + ctx.add(model.scene); + model.scene.traverse((node) => { + if (!(node instanceof Mesh)) return; + vertices += node.geometry.getAttribute("position").count; + const materials = Array.isArray(node.material) ? node.material : [node.material]; + if (materials.some((material) => (material as MeshStandardMaterial).map?.image)) + texturedMeshes++; + }); + const player = new AnimationPlayer({ + root: model.scene, + clips: model.animations, + requiredClips: ["turn"], + strideSync: false, + }); + player.play("turn"); + this.#players.push(player); + const node = model.scene.getObjectByName(`${names[index]}-animated`); + if (!node) throw new Error(`Missing animated node: ${names[index]}`); + rotations.push(node.quaternion.clone()); + return node; + }); + ctx.state.set({ loadedModels: this.#models.length, texturedMeshes, vertices }); + let frames = 0; + let maxPoseDelta = 0; + return (_ctx: ICtx, dt: number) => { + for (const player of this.#players) player.update(dt); + for (const [index, node] of animated.entries()) + maxPoseDelta = Math.max( + maxPoseDelta, + node.quaternion.angleTo(rotations[index] ?? new Quaternion()), + ); + ctx.state.set({ frames: ++frames, maxPoseDelta }); + }; + } + + override exit(ctx: ICtx): void { + for (const player of this.#players) player.dispose(); + this.#players.length = 0; + for (const mesh of this.#owned) { + mesh.removeFromParent(); + mesh.geometry.dispose(); + for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) + material.dispose(); + } + this.#owned.length = 0; + this.#png?.dispose(); + this.#png = undefined; + this.#models.length = 0; + for (const name of names) ctx.assets.release("model", `${name}.glb`); + ctx.assets.release("texture", "checker.png"); + } +} + +export default defineGame({ + scenes: { assets: AssetScene }, + start: "assets", + plugins: [playtest()], + display: config.display, + render: config.renderer, +}); diff --git a/examples/abyss-framework/vq-assets/src/render/stage.ts b/examples/abyss-framework/vq-assets/src/render/stage.ts new file mode 100644 index 0000000000..d6a15c5d5b --- /dev/null +++ b/examples/abyss-framework/vq-assets/src/render/stage.ts @@ -0,0 +1,34 @@ +import { + Color, + CylinderGeometry, + Mesh, + MeshBasicMaterial, + type PerspectiveCamera, + PlaneGeometry, + type Scene, + type Texture, +} from "three"; + +export function stage(scene: Scene, camera: PerspectiveCamera, png: Texture): Mesh[] { + scene.background = new Color(0x101827); + camera.position.set(0, 3.2, 8.8); + camera.lookAt(0, 1.1, 0); + camera.fov = 42; + camera.updateProjectionMatrix(); + const objects: Mesh[] = []; + for (const x of [-2.4, 0, 2.4]) { + const plinth = new Mesh( + new CylinderGeometry(1.03, 1.1, 0.25, 48), + new MeshBasicMaterial({ color: 0x263951 }), + ); + plinth.position.set(x, 0.15, 0); + scene.add(plinth); + objects.push(plinth); + } + const panel = new Mesh(new PlaneGeometry(1.6, 1.6), new MeshBasicMaterial({ map: png })); + panel.position.set(2.4, 1.35, 0); + panel.rotation.y = -0.15; + scene.add(panel); + objects.push(panel); + return objects; +} diff --git a/examples/abyss-framework/vq-assets/threenative.config.ts b/examples/abyss-framework/vq-assets/threenative.config.ts new file mode 100644 index 0000000000..d9e2ad236a --- /dev/null +++ b/examples/abyss-framework/vq-assets/threenative.config.ts @@ -0,0 +1,25 @@ +import type { IThreeNativeConfig } from "@threenative/core"; + +export default { + app: { id: "com.threenative.vq01", name: "VQ-01 native assets", version: "1.0.0", build: 1 }, + assets: { + source: ".generated/assets", + output: ".generated/public", + audio: "none", + budget: "none", + lod: false, + // Ask for compression. The actual selected QuickJS runtime must remove it, not the fixture. + textures: { overrides: [{ glob: "*.png", codec: "uastc" }] }, + models: { + compact: false, + passes: { dedup: false, meshopt: true, prune: false, quantize: false, reorder: false }, + textures: "none", + virtual: "none", + }, + }, + nativeEntry: "src/game.ts", + display: { fullscreen: false, maxFps: 60 }, + window: { width: 960, height: 640, title: "VQ-01 native asset fallback", resizable: false }, + renderer: { preferWebGPU: true, resolutionScale: 1 }, + ui: { renderer: "native" }, +} satisfies IThreeNativeConfig; diff --git a/examples/abyss-framework/vq-assets/tsconfig.json b/examples/abyss-framework/vq-assets/tsconfig.json new file mode 100644 index 0000000000..5b32896060 --- /dev/null +++ b/examples/abyss-framework/vq-assets/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "types": ["node"], "rootDir": "." }, + "include": ["src/**/*.ts", "threenative.config.ts", "generate.ts"] +} diff --git a/packages/create-threenative/__tests__/vq-native-fixture.spec.ts b/packages/create-threenative/__tests__/vq-native-fixture.spec.ts new file mode 100644 index 0000000000..c1852a86d3 --- /dev/null +++ b/packages/create-threenative/__tests__/vq-native-fixture.spec.ts @@ -0,0 +1,100 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { compileAssets } from "@threenative/assets"; +import { createAssetLoader } from "@threenative/core"; +import { Group, Texture } from "three"; +import { afterEach, expect, test, vi } from "vitest"; +import { + MODEL_NAMES, + generateNativeAssetFixture, + inspectFixtureModel, +} from "../../../examples/abyss-framework/vq-assets/generate.js"; +import { AssetScene } from "../../../examples/abyss-framework/vq-assets/src/game.js"; +import config from "../../../examples/abyss-framework/vq-assets/threenative.config.js"; +import { assertNativeAssetsCompatible } from "../src/build.js"; +import { loadConfig } from "../src/config.js"; + +const roots: string[] = []; +afterEach(async () => + Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))), +); + +test("real Meshopt and Draco inputs retain decoded geometry, images and animation through the decoder-free cook", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "vq01-fixture-")); + roots.push(cwd); + const source = path.join(cwd, config.assets.source); + const ktx2 = await generateNativeAssetFixture(source); + await writeFile(path.join(cwd, "package.json"), '{"name":"vq-fixture","type":"module"}'); + await writeFile( + path.join(cwd, "threenative.config.ts"), + `export default ${JSON.stringify(config)};`, + ); + const before = await Promise.all( + MODEL_NAMES.map((name) => inspectFixtureModel(path.join(source, `${name}.glb`))), + ); + expect(before[0]?.extensions).toContain("EXT_meshopt_compression"); + expect(before[1]?.extensions).toContain("KHR_draco_mesh_compression"); + await compileAssets({ + cwd, + config: config.assets, + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, + runtimeIdentity: "test:quickjs", + }); + const output = path.join(cwd, config.assets.output); + const manifest = JSON.parse(await readFile(path.join(output, "assets.manifest.json"), "utf8")); + for (const [index, name] of MODEL_NAMES.entries()) { + const original = before[index]; + if (!original) throw new Error("Missing source measurement"); + const cooked = await inspectFixtureModel( + path.join(output, manifest.entries[`${name}.glb`].output), + ); + expect(cooked.extensions).not.toContain("EXT_meshopt_compression"); + expect(cooked.extensions).not.toContain("KHR_draco_mesh_compression"); + expect(cooked.triangles).toBe(original.triangles); + expect(cooked.positions).toEqual(original.positions); + expect(cooked.animation).toEqual(original.animation); + expect(cooked.images.map((image) => image.data)).toEqual( + original.images.map((image) => image.data), + ); + } + expect(manifest.entries["checker.png"].output).toMatch(/\.png$/u); + await expect( + assertNativeAssetsCompatible(cwd, "desktop", await loadConfig(cwd)), + ).resolves.toBeUndefined(); + await writeFile(path.join(source, "authored.ktx2"), ktx2); + await compileAssets({ + cwd, + config: config.assets, + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, + runtimeIdentity: "test:quickjs", + }); + await expect(assertNativeAssetsCompatible(cwd, "desktop", await loadConfig(cwd))).rejects.toThrow( + /TN_NATIVE_KTX2_UNSUPPORTED.*authored.ktx2/u, + ); +}); + +test("fixture exit disposes its configured PNG clone and cached original exactly once", async () => { + const original = new Texture(); + const assets = createAssetLoader({ + model: async () => ({ scene: new Group(), animations: [] }), + texture: async () => original, + }); + const scene = new AssetScene(); + const ctx = { assets } as Parameters[0]; + const dispose = vi.spyOn(Texture.prototype, "dispose"); + try { + await scene.load(ctx); + scene.exit(ctx); + expect(dispose).toHaveBeenCalledTimes(2); + expect(new Set(dispose.mock.contexts).size).toBe(2); + expect(dispose.mock.contexts).toContain(original); + scene.exit(ctx); + expect(dispose).toHaveBeenCalledTimes(2); + } finally { + dispose.mockRestore(); + assets.clear(); + } +}); diff --git a/scripts/__tests__/native-asset-capture-proof.spec.ts b/scripts/__tests__/native-asset-capture-proof.spec.ts new file mode 100644 index 0000000000..e03635aa37 --- /dev/null +++ b/scripts/__tests__/native-asset-capture-proof.spec.ts @@ -0,0 +1,86 @@ +import { PNG } from "pngjs"; +import { expect, test } from "vitest"; +import { + assertNativeAssetCapture, + inspectNativeAssetScreenshot, +} from "../native-asset-capture-proof.js"; + +// Synthetic pixels are unit inputs only. Published evidence must come from the native runner. +function image(missingRegion = -1, transparent = false): Buffer { + const png = new PNG({ width: 960, height: 640 }); + const colours = [ + [ + [20, 210, 225], + [240, 85, 35], + ], + [ + [235, 45, 170], + [245, 205, 35], + ], + [ + [95, 210, 60], + [85, 100, 235], + ], + ]; + for (let y = 180; y < 440; y++) + for (let x = 0; x < 960; x++) { + const region = Math.floor(x / 320); + if (region === missingRegion) continue; + const rgb = colours[region]?.[x % 32 < 16 ? 0 : 1] ?? [0, 0, 0]; + png.data.set( + [rgb[0] ?? 0, rgb[1] ?? 0, rgb[2] ?? 0, transparent ? 0 : 255], + (y * 960 + x) * 4, + ); + } + return PNG.sync.write(png); +} + +function report(): Parameters[0] { + return { + pass: true, + runtime: "native", + target: "desktop", + assertionResults: [{ pass: true, id: "resources.0" }], + diagnostics: [], + capture: { + target: "desktop", + rendererKind: "webgpu", + adapter: { description: "llvmpipe" }, + browserArgs: [], + captureMethod: "device.screenshot", + viewport: { width: 960, height: 640 }, + }, + }; +} + +test("requires both rendered colours for all three asset routes", () => { + expect(inspectNativeAssetScreenshot(image()).counts).toHaveLength(3); + for (const region of [0, 1, 2]) + expect(() => inspectNativeAssetScreenshot(image(region))).toThrow(`Specimen ${region}`); +}); +test("rejects an empty or wrong-sized capture", () => { + expect(() => inspectNativeAssetScreenshot(Buffer.from([]))).toThrow(); + expect(() => + inspectNativeAssetScreenshot(PNG.sync.write(new PNG({ width: 32, height: 32 }))), + ).toThrow(); +}); +test("requires native provenance and nonempty live assertions", () => { + expect(() => assertNativeAssetCapture(report())).not.toThrow(); + for (const mutation of [ + { assertionResults: [] }, + { pass: false }, + { capture: undefined }, + { capture: { target: "web", rendererKind: "webgpu", adapter: { description: "GPU" } } }, + { capture: { target: "desktop", rendererKind: "webgpu", adapter: { description: "" } } }, + { diagnostics: [{ severity: "warning", code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" }] }, + ]) + expect(() => + assertNativeAssetCapture({ ...report(), ...mutation } as Parameters< + typeof assertNativeAssetCapture + >[0]), + ).toThrow(); +}); + +test("invisible RGB cannot satisfy the native screenshot proof", () => { + expect(() => inspectNativeAssetScreenshot(image(-1, true))).toThrow("Specimen 0"); +}); diff --git a/scripts/native-asset-capture-proof.ts b/scripts/native-asset-capture-proof.ts new file mode 100644 index 0000000000..249fa461b5 --- /dev/null +++ b/scripts/native-asset-capture-proof.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { PNG } from "pngjs"; +import type { IStandalonePlaytestReport } from "../packages/playtest/src/runner/runner.js"; + +/** Rendered checker colours in each specimen's own third of the frame, not a global nonblank test. */ +export function inspectNativeAssetScreenshot(bytes: Buffer) { + const png = PNG.sync.read(bytes); + assert.equal(png.width, 960, "Native fixture capture width"); + assert.equal(png.height, 640, "Native fixture capture height"); + const counts = [ + [0, 0], + [0, 0], + [0, 0], + ]; + for (let y = 100; y < 535; y++) + for (let x = 0; x < png.width; x++) { + const offset = (y * png.width + x) * 4; + if ((png.data[offset + 3] ?? 0) < 250) continue; + const r = png.data[offset] ?? 0; + const g = png.data[offset + 1] ?? 0; + const b = png.data[offset + 2] ?? 0; + const region = Math.floor(x / 320); + const first = + region === 0 + ? g > 100 && b > 100 && r < g * 0.7 + : region === 1 + ? r > 100 && b > 80 && g < r * 0.65 + : g > 100 && g > r * 1.25 && g > b * 1.25; + const second = + region === 0 + ? r > 130 && r > g * 1.3 && b < r * 0.65 + : region === 1 + ? r > 120 && g > 100 && b < g * 0.65 + : b > 110 && b > r * 1.25 && b > g * 1.25; + const row = counts[region]; + if (row && first) row[0] = (row[0] ?? 0) + 1; + if (row && second) row[1] = (row[1] ?? 0) + 1; + } + for (const [region, row] of counts.entries()) + for (const [colour, pixels] of row.entries()) { + assert.ok( + pixels >= 200, + `Specimen ${region} checker colour ${colour}: only ${pixels} pixels`, + ); + } + return { width: png.width, height: png.height, counts }; +} + +/** No successful screenshot proof can carry a lost device, empty assertions or unnamed adapter. */ +export function assertNativeAssetCapture( + report: Pick< + IStandalonePlaytestReport, + "pass" | "assertionResults" | "capture" | "diagnostics" | "runtime" | "target" + >, +): void { + assert.equal(report.runtime, "native"); + assert.equal(report.target, "desktop"); + assert.equal(report.capture?.captureMethod, "device.screenshot"); + assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); + assert.ok(report.assertionResults?.length, "Native proof must evaluate assertions"); + assert.ok( + report.assertionResults.every((result) => result.pass), + "Native assertions failed", + ); + assert.equal(report.capture?.target, "desktop", "Native proof cannot be a browser capture"); + assert.equal(report.capture.rendererKind, "webgpu"); + assert.ok( + report.capture.adapter && Object.values(report.capture.adapter).some((value) => value.trim()), + "Native adapter identity missing", + ); + for (const diagnostic of report.diagnostics) { + assert.notEqual(diagnostic.severity, "error", diagnostic.code); + assert.doesNotMatch(diagnostic.code, /DEVICE_LOST|BRIDGE_MISSING/u); + } +} diff --git a/scripts/verify-native-asset-capabilities.ts b/scripts/verify-native-asset-capabilities.ts new file mode 100644 index 0000000000..d8e5a216dd --- /dev/null +++ b/scripts/verify-native-asset-capabilities.ts @@ -0,0 +1,162 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + MODEL_NAMES, + generateNativeAssetFixture, + inspectFixtureModel, +} from "../examples/abyss-framework/vq-assets/generate.js"; +import config from "../examples/abyss-framework/vq-assets/threenative.config.js"; +import { + build, + resolveRuntimeAssetCapabilities, +} from "../packages/create-threenative/src/build.js"; +import { hashArtifact } from "../packages/create-threenative/src/buildReport.js"; +import { runDesktopPlaytest } from "../packages/playtest/dist/runner/index.js"; +import { + assertNativeAssetCapture, + inspectNativeAssetScreenshot, +} from "./native-asset-capture-proof.js"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const project = path.join(root, "examples/abyss-framework/vq-assets"); +const output = path.join(root, "artifacts/vq01-native-assets"); +const sourceSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", +}).trim(); +const write = async (name: string, value: unknown) => + writeFile(path.join(output, name), `${JSON.stringify(value, null, 2)}\n`); +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); +await rm(output, { recursive: true, force: true }); +await mkdir(output, { recursive: true }); +await write("attempt.json", { sourceSha, runId: process.env.GITHUB_RUN_ID, status: "started" }); +try { + const runtime = process.env.THREENATIVE_RUNTIME_BINARY; + assert.ok(runtime, "THREENATIVE_RUNTIME_BINARY must name this run's actual native build"); + const capabilities = resolveRuntimeAssetCapabilities("desktop", runtime); + assert.equal( + capabilities.engine, + "quickjs", + "This proof must execute the actual QuickJS fallback", + ); + assert.deepEqual(capabilities.decoders, { ktx2: false, meshopt: false, draco: false }); + await write("runtime.json", capabilities); + await rm(path.join(project, ".generated"), { recursive: true, force: true }); + const source = path.join(project, config.assets.source); + const ktx2 = await generateNativeAssetFixture(source); + await build({ cwd: project, target: "desktop" }); + const cookedRoot = path.join(project, config.assets.output); + const manifest = JSON.parse( + await readFile(path.join(cookedRoot, "assets.manifest.json"), "utf8"), + ); + const models = []; + for (const [index, name] of MODEL_NAMES.entries()) { + const authoredPath = path.join(source, `${name}.glb`); + const cookedPath = path.join(cookedRoot, manifest.entries[`${name}.glb`].output); + const authored = await inspectFixtureModel(authoredPath); + const cooked = await inspectFixtureModel(cookedPath); + assert.ok( + authored.extensions.includes( + index === 0 ? "EXT_meshopt_compression" : "KHR_draco_mesh_compression", + ), + ); + assert.ok(!cooked.extensions.some((extension) => /meshopt|draco|basisu/u.test(extension))); + assert.deepEqual(cooked.positions, authored.positions, `${name}: decoded vertex values`); + assert.equal(cooked.triangles, authored.triangles); + assert.deepEqual(cooked.animation, authored.animation); + assert.deepEqual( + cooked.images.map((image) => image.data), + authored.images.map((image) => image.data), + ); + models.push({ + name, + sourceSha256: sha256(await readFile(authoredPath)), + cookedSha256: sha256(await readFile(cookedPath)), + sourceEncodedBytes: authored.encodedBytes, + cookedEncodedBytes: cooked.encodedBytes, + decodedGeometryBytes: cooked.decodedGeometryBytes, + decodedImageBytes: cooked.images.reduce((sum, image) => sum + image.data.length, 0), + triangles: cooked.triangles, + }); + } + assert.match(manifest.entries["checker.png"].output, /\.png$/u); + assert.doesNotMatch( + await readFile(path.join(project, ".threenative/build/game.js"), "utf8"), + /\bWebAssembly\b/u, + ); + await write("cook.json", { + models, + manifest, + measurement: + "Encoded bytes and decoded CPU buffer bytes are separate; no GPU memory reduction claimed.", + }); + const executable = path.join(project, "dist-native/vq-native-assets"); + const runtimeBytes = await readFile(runtime); + const packagedBytes = await readFile(executable); + assert.equal( + sha256(packagedBytes.subarray(0, runtimeBytes.length)), + sha256(runtimeBytes), + "Packaged executable must carry the exact selected runtime bytes before its appended game", + ); + const packaged = await hashArtifact(executable); + const buildReport = JSON.parse(await readFile(`${executable}.build-report.json`, "utf8")); + assert.equal( + buildReport.artifact.sha256, + packaged.sha256, + "Build report must describe the actual executable", + ); + await writeFile(path.join(source, "authored.ktx2"), ktx2); + await assert.rejects( + build({ cwd: project, target: "desktop" }), + /TN_NATIVE_KTX2_UNSUPPORTED.*authored.ktx2/u, + ); + assert.deepEqual( + await hashArtifact(executable), + packaged, + "Codec refusal must preserve the previous package", + ); + const report = await runDesktopPlaytest({ + target: "desktop", + desktop: { executable, hostArgs: ["--windowed"] }, + projectPath: project, + scenarioPath: "../playtests/vq-native-asset-capabilities.playtest.json", + artifactDirectory: output, + allowSoftwareAdapter: true, + headless: false, + timeoutMs: 120_000, + trace: false, + url: "", + }); + await write("report.json", report); + assertNativeAssetCapture(report); + const bytes = await readFile(path.join(output, "after.png")); + const pixels = inspectNativeAssetScreenshot(bytes); + assert.deepEqual(await hashArtifact(executable), packaged); + await write("summary.json", { + pass: true, + sourceSha, + runId: process.env.GITHUB_RUN_ID, + qualification: + "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.", + runtime: capabilities, + packaged, + models, + capture: report.capture, + pixels, + screenshotSha256: sha256(bytes), + refusedCodec: "authored KTX2", + previousPackagePreserved: true, + }); +} catch (error) { + await write("failure.json", { + pass: false, + sourceSha, + error: String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + throw error; +} From cedacefcab3360d5cc344ef071649f57782ac9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:38:50 -0700 Subject: [PATCH 03/36] test(vq01): use supported native console diagnostics for capture The actual QuickJS bridge does not expose runtime.diagnostics. Keep native readiness and console errors, require complete real host logs, and fail closed on GPU/device-loss/JavaScript/loader failures even when classified as log. Engine guards unchanged. --- .../PRD-VQ-01-native-asset-capabilities.md | 5 +- ...vq-native-asset-capabilities.playtest.json | 57 ++++++++++++++++--- .../native-asset-capture-proof.spec.ts | 55 ++++++++++++++++-- scripts/native-asset-capture-proof.ts | 22 ++++++- scripts/verify-native-asset-capabilities.ts | 8 ++- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md index 0b4fc52dae..3260dc24a5 100644 --- a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -80,7 +80,7 @@ Update the phase boxes and this PRD only after the named proof runs. Record actu - All phase and acceptance boxes intentionally remain open. No full Phase 1, Phase 2, native decoder admission, visual result or PRD completion is claimed by this patch. -### Native screenshot qualification lane — 2026-10-02 (prepared, unrun) +### Native screenshot qualification lane — 2026-10-02 (qualification in progress) - `examples/abyss-framework/vq-assets/` is a separate opt-in fixture; the ordinary example is unchanged. It loads two actual cooked GLBs and a PNG through `ctx.assets`, then advances their authored clips with `AnimationPlayer`. The checker colours belong to those assets, not to a drawn diagnostic overlay. - `node --import tsx scripts/verify-native-asset-capabilities.ts` requires a real QuickJS executable in `THREENATIVE_RUNTIME_BINARY`, builds through the actual desktop resolver/cook/bundle/packager, verifies decoded payload equality, the exact runtime prefix in the packaged executable and the published build-report digest, then proves an authored KTX2 refusal preserves that same package. `runDesktopPlaytest` drives the resulting executable and captures its framebuffer with the existing mailbox transport. @@ -88,3 +88,6 @@ Update the phase boxes and this PRD only after the named proof runs. Record actu - Local checks: 25/25 focused capability/real-fixture/capture-validator tests passed; fixture and focused verifier TypeScript checks passed; assets and CLI packages rebuilt with declarations/publint. The actual QuickJS fixture bundle was built and contains no `WebAssembly` reference. The scenario validates and workflow YAML parses; CI structure/needs checks pass 141/141. The local native verifier refuses at the explicit missing-runtime requirement before any runtime claim. - Capture acceptance is fail-closed: native `device.screenshot` provenance, nonempty passed live assertions, named WebGPU adapter, no lost-device/errors, exact 960×640 framebuffer, and both authored checker colours in opaque pixels in each of the three specimen regions. The transparent-RGB negative test failed before the alpha guard and passes with it. A lifecycle test confirms the fixture's owned sRGB texture clone and cached original are each disposed once, including a repeated exit. Published PNGs must be original runner bytes with matching SHA-256 and source/run provenance. - No new screenshot or hosted-run PASS is claimed in this commit. Full mobile/cohort, packaged-decoder and lifecycle acceptance stays open. + + +Hosted attempt [36995485869](https://github.com/ThreeNativeHQ/threenative/actions/runs/36995485869), source `8d6cd881ac54b206d82e0ba00167b0e277edb402`, built the actual ARM64 QuickJS/wgpu runtime and packaged the fixture. Exact selected-runtime prefix, decoded payload comparisons and authored-KTX2 refusal/package-preservation checks passed. The native bridge answered ready, but correctly refused the scenario's unavailable `runtime.diagnostics` observer before capturing any frame; no screenshot exists from this attempt. The native-only scenario now records that limitation and instead keeps readiness/no-console-error assertions plus mandatory complete host-console checks for native GPU validation, device loss, JavaScript and loader errors. Five regression cases failed before this observer-contract repair; error-labelled and misleadingly log-labelled failures remain fatal. Runtime engine guards are unchanged. diff --git a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json index 894a6a2ae5..9d2f97c60e 100644 --- a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json +++ b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json @@ -2,18 +2,57 @@ "schemaVersion": 1, "name": "vq01-packaged-quickjs-assets", "target": "desktop", - "viewport": { "width": 960, "height": 640 }, + "viewport": { + "width": 960, + "height": 640 + }, "warmupFrames": 30, - "steps": [{ "waitFrames": 90 }], - "artifacts": { "screenshots": true, "console": true }, + "steps": [ + { + "waitFrames": 90 + } + ], + "artifacts": { + "screenshots": true, + "console": true + }, "assert": { - "diagnostics": { "noConsoleErrors": true, "noRuntimeDiagnostics": true, "runtimeReady": true }, + "diagnostics": { + "noConsoleErrors": true, + "noRuntimeDiagnostics": false, + "runtimeReady": true, + "runtimeDiagnosticsOptOutReason": "The native bridge does not expose runtime.diagnostics. This native-only fixture instead requires no console errors, live readiness, and verifier checks of every native console line for device loss, GPU validation, JavaScript and loader failures." + }, "resources": [ - { "id": "state", "path": "loadedModels", "equals": 2, "allowTrivial": "Both real normalized GLBs must finish loading before enter and the first observation." }, - { "id": "state", "path": "texturedMeshes", "equals": 2, "allowTrivial": "A texture image on each loaded GLB is counted before the first observation." }, - { "id": "state", "path": "vertices", "gte": 100, "allowTrivial": "Decoded GLB geometry is measured at scene entry." }, - { "id": "state", "path": "frames", "changed": true }, - { "id": "state", "path": "maxPoseDelta", "gte": 0.05, "allowTrivial": "The native warm-up can already advance both authored animation tracks." } + { + "id": "state", + "path": "loadedModels", + "equals": 2, + "allowTrivial": "Both real normalized GLBs must finish loading before enter and the first observation." + }, + { + "id": "state", + "path": "texturedMeshes", + "equals": 2, + "allowTrivial": "A texture image on each loaded GLB is counted before the first observation." + }, + { + "id": "state", + "path": "vertices", + "gte": 100, + "allowTrivial": "Decoded GLB geometry is measured at scene entry." + }, + { + "id": "state", + "path": "frames", + "changed": true + }, + { + "id": "state", + "path": "maxPoseDelta", + "gte": 0.05, + "allowTrivial": "The native warm-up can already advance both authored animation tracks." + } ] } } diff --git a/scripts/__tests__/native-asset-capture-proof.spec.ts b/scripts/__tests__/native-asset-capture-proof.spec.ts index e03635aa37..c80b80faa4 100644 --- a/scripts/__tests__/native-asset-capture-proof.spec.ts +++ b/scripts/__tests__/native-asset-capture-proof.spec.ts @@ -1,10 +1,15 @@ +import { readFileSync } from "node:fs"; import { PNG } from "pngjs"; import { expect, test } from "vitest"; +import { requiredPlaytestCapabilities } from "../../packages/playtest/src/assertion-schema.js"; +import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js"; import { assertNativeAssetCapture, inspectNativeAssetScreenshot, } from "../native-asset-capture-proof.js"; +const cleanConsole = [{ type: "log", text: "TN_NATIVE_SMOKE_FIRST_FRAME" }]; + // Synthetic pixels are unit inputs only. Published evidence must come from the native runner. function image(missingRegion = -1, transparent = false): Buffer { const png = new PNG({ width: 960, height: 640 }); @@ -65,7 +70,7 @@ test("rejects an empty or wrong-sized capture", () => { ).toThrow(); }); test("requires native provenance and nonempty live assertions", () => { - expect(() => assertNativeAssetCapture(report())).not.toThrow(); + expect(() => assertNativeAssetCapture(report(), cleanConsole)).not.toThrow(); for (const mutation of [ { assertionResults: [] }, { pass: false }, @@ -75,12 +80,54 @@ test("requires native provenance and nonempty live assertions", () => { { diagnostics: [{ severity: "warning", code: "TN_PLAYTEST_SOFTWARE_DEVICE_LOST" }] }, ]) expect(() => - assertNativeAssetCapture({ ...report(), ...mutation } as Parameters< - typeof assertNativeAssetCapture - >[0]), + assertNativeAssetCapture( + { ...report(), ...mutation } as Parameters[0], + cleanConsole, + ), ).toThrow(); }); test("invisible RGB cannot satisfy the native screenshot proof", () => { expect(() => inspectNativeAssetScreenshot(image(-1, true))).toThrow("Specimen 0"); }); + +test.each([ + "[WebGPU] Device error (Validation): invalid texture format", + "[FATAL] The GPU device was lost: driver reset", + "TN_ASSETS_UNRESOLVED: authored model failed to load", + "TypeError: native renderer failed", +])("native console failures remain fatal even when classified as log: %s", (text) => { + expect(() => + assertNativeAssetCapture(report(), [...cleanConsole, { type: "log", text }]), + ).toThrow(text); +}); + +test("native scenario retains supported diagnostics without requesting an unavailable observer", () => { + const scenario = validatePlaytestScenario( + JSON.parse( + readFileSync( + "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json", + "utf8", + ), + ), + "vq01", + ); + expect(requiredPlaytestCapabilities(scenario, "desktop")).not.toContain("runtime.diagnostics"); + expect(scenario.assert?.diagnostics?.noConsoleErrors).toBe(true); + expect(scenario.assert?.diagnostics?.runtimeReady).toBe(true); +}); + +test("native console evidence cannot be omitted or empty", () => { + for (const console of [undefined, [], [{ type: "log", text: "no frame marker" }]]) { + expect(() => assertNativeAssetCapture(report(), console)).toThrow(); + } +}); + +test("unclassified error-labelled native lines remain fatal", () => { + expect(() => + assertNativeAssetCapture(report(), [ + ...cleanConsole, + { type: "error", text: "Unexpected host failure" }, + ]), + ).toThrow("Unexpected host failure"); +}); diff --git a/scripts/native-asset-capture-proof.ts b/scripts/native-asset-capture-proof.ts index 249fa461b5..451132b978 100644 --- a/scripts/native-asset-capture-proof.ts +++ b/scripts/native-asset-capture-proof.ts @@ -52,11 +52,12 @@ export function assertNativeAssetCapture( IStandalonePlaytestReport, "pass" | "assertionResults" | "capture" | "diagnostics" | "runtime" | "target" >, + nativeConsole: unknown, ): void { + assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); assert.equal(report.runtime, "native"); assert.equal(report.target, "desktop"); assert.equal(report.capture?.captureMethod, "device.screenshot"); - assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); assert.ok(report.assertionResults?.length, "Native proof must evaluate assertions"); assert.ok( report.assertionResults.every((result) => result.pass), @@ -68,6 +69,25 @@ export function assertNativeAssetCapture( report.capture.adapter && Object.values(report.capture.adapter).some((value) => value.trim()), "Native adapter identity missing", ); + assert.ok( + Array.isArray(nativeConsole) && nativeConsole.length > 0, + "Native console evidence missing", + ); + for (const entry of nativeConsole) { + assert.ok( + entry && typeof entry.text === "string" && typeof entry.type === "string", + "Malformed native console evidence", + ); + assert.notEqual(entry.type, "error", entry.text); + assert.doesNotMatch( + entry.text, + /\[FATAL\]|\[WebGPU\].*(?:Device error|Device lost|Failed)|validation error|device(?:[ _-]| was )?lost|(?:Type|Reference|Range|Syntax)Error|TN_(?:NATIVE_START_FAILED|ASSETS_UNRESOLVED|NATIVE_KTX2_UNSUPPORTED|NATIVE_MESH_COMPRESSION_UNSUPPORTED)/iu, + ); + } + assert.ok( + nativeConsole.some((entry) => entry.text.includes("TN_NATIVE_SMOKE_FIRST_FRAME")), + "Native first-frame marker missing", + ); for (const diagnostic of report.diagnostics) { assert.notEqual(diagnostic.severity, "error", diagnostic.code); assert.doesNotMatch(diagnostic.code, /DEVICE_LOST|BRIDGE_MISSING/u); diff --git a/scripts/verify-native-asset-capabilities.ts b/scripts/verify-native-asset-capabilities.ts index d8e5a216dd..31cc90dbab 100644 --- a/scripts/verify-native-asset-capabilities.ts +++ b/scripts/verify-native-asset-capabilities.ts @@ -132,7 +132,11 @@ try { url: "", }); await write("report.json", report); - assertNativeAssetCapture(report); + assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); + const nativeConsole: unknown = JSON.parse( + await readFile(path.join(output, "console.json"), "utf8"), + ); + assertNativeAssetCapture(report, nativeConsole); const bytes = await readFile(path.join(output, "after.png")); const pixels = inspectNativeAssetScreenshot(bytes); assert.deepEqual(await hashArtifact(executable), packaged); @@ -142,6 +146,8 @@ try { runId: process.env.GITHUB_RUN_ID, qualification: "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.", + observability: + "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.", runtime: capabilities, packaged, models, From 46a759ad885cede29e0fbb2c719ed75151c5baec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 03:54:29 -0700 Subject: [PATCH 04/36] test(vq01): replay native admission and require observed startup Exercise actual bridge target admission locally. Only native-supported startup/resource families belong in the scenario. Native console cleanliness, ready/compile-settled state and device screenshot provenance remain required in the verifier. No engine target guard changed. --- .../PRD-VQ-01-native-asset-capabilities.md | 3 ++ ...vq-native-asset-capabilities.playtest.json | 12 ++--- .../native-asset-capture-proof.spec.ts | 53 ++++++++++++++++++- scripts/native-asset-capture-proof.ts | 4 +- 4 files changed, 62 insertions(+), 10 deletions(-) diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md index 3260dc24a5..2d19198fea 100644 --- a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -91,3 +91,6 @@ Update the phase boxes and this PRD only after the named proof runs. Record actu Hosted attempt [36995485869](https://github.com/ThreeNativeHQ/threenative/actions/runs/36995485869), source `8d6cd881ac54b206d82e0ba00167b0e277edb402`, built the actual ARM64 QuickJS/wgpu runtime and packaged the fixture. Exact selected-runtime prefix, decoded payload comparisons and authored-KTX2 refusal/package-preservation checks passed. The native bridge answered ready, but correctly refused the scenario's unavailable `runtime.diagnostics` observer before capturing any frame; no screenshot exists from this attempt. The native-only scenario now records that limitation and instead keeps readiness/no-console-error assertions plus mandatory complete host-console checks for native GPU validation, device loss, JavaScript and loader errors. Five regression cases failed before this observer-contract repair; error-labelled and misleadingly log-labelled failures remain fatal. Runtime engine guards are unchanged. + + +Hosted attempt [36996645116](https://github.com/ThreeNativeHQ/threenative/actions/runs/36996645116), source `cedacefcab3360d5cc344ef071649f57782ac9b5`, reconfirmed build/package/payload checks but exposed the full admission rule: the **entire diagnostics assertion family is web-only**, independently of its individual booleans. No pixels were captured. A regression now replays the real native handshake through `connectPlaytestBridgeTransport`, not only its capability-name calculator; it failed with the exact hosted observation error, then passed after the scenario was limited to supported startup/resources families. Native console cleanliness and ready/compile-settled checks are mandatory in the verifier instead, with missing readiness, empty console evidence, GPU loss, validation and JavaScript errors all tested to fail. The complete focused set passes 34/34 and verifier types pass. No engine target-admission guard changed. diff --git a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json index 9d2f97c60e..22d9c2d617 100644 --- a/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json +++ b/examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json @@ -17,12 +17,6 @@ "console": true }, "assert": { - "diagnostics": { - "noConsoleErrors": true, - "noRuntimeDiagnostics": false, - "runtimeReady": true, - "runtimeDiagnosticsOptOutReason": "The native bridge does not expose runtime.diagnostics. This native-only fixture instead requires no console errors, live readiness, and verifier checks of every native console line for device loss, GPU validation, JavaScript and loader failures." - }, "resources": [ { "id": "state", @@ -53,6 +47,10 @@ "gte": 0.05, "allowTrivial": "The native warm-up can already advance both authored animation tracks." } - ] + ], + "startup": { + "maxEnteredMs": 120000, + "maxReadyMs": 120000 + } } } diff --git a/scripts/__tests__/native-asset-capture-proof.spec.ts b/scripts/__tests__/native-asset-capture-proof.spec.ts index c80b80faa4..719894c72e 100644 --- a/scripts/__tests__/native-asset-capture-proof.spec.ts +++ b/scripts/__tests__/native-asset-capture-proof.spec.ts @@ -2,6 +2,11 @@ import { readFileSync } from "node:fs"; import { PNG } from "pngjs"; import { expect, test } from "vitest"; import { requiredPlaytestCapabilities } from "../../packages/playtest/src/assertion-schema.js"; +import { PLAYTEST_PROTOCOL_LIMITS } from "../../packages/playtest/src/protocol.js"; +import { + type IBridgeTransport, + connectPlaytestBridgeTransport, +} from "../../packages/playtest/src/runner/bridgeClient.js"; import { validatePlaytestScenario } from "../../packages/playtest/src/scenario.js"; import { assertNativeAssetCapture, @@ -43,6 +48,7 @@ function image(missingRegion = -1, transparent = false): Buffer { function report(): Parameters[0] { return { pass: true, + startup: { phase: "ready", progress: 1, compileSettled: true, rule: "compile-settled" }, runtime: "native", target: "desktop", assertionResults: [{ pass: true, id: "resources.0" }], @@ -113,8 +119,9 @@ test("native scenario retains supported diagnostics without requesting an unavai "vq01", ); expect(requiredPlaytestCapabilities(scenario, "desktop")).not.toContain("runtime.diagnostics"); - expect(scenario.assert?.diagnostics?.noConsoleErrors).toBe(true); - expect(scenario.assert?.diagnostics?.runtimeReady).toBe(true); + expect(scenario.assert?.diagnostics).toBeUndefined(); + expect(requiredPlaytestCapabilities(scenario, "desktop")).toContain("runtime.startup"); + expect(scenario.assert?.startup?.maxReadyMs).toBe(120000); }); test("native console evidence cannot be omitted or empty", () => { @@ -131,3 +138,45 @@ test("unclassified error-labelled native lines remain fatal", () => { ]), ).toThrow("Unexpected host failure"); }); + +test("the actual native bridge preflight accepts the fixture's assertion families", async () => { + const scenario = validatePlaytestScenario( + JSON.parse( + readFileSync( + "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json", + "utf8", + ), + ), + "vq01", + ); + // Minimal subset of the real QuickJS handshake captured in run 36996645116. + const transport: IBridgeTransport = { + capabilities: ["browser.console", "browser.screenshot"], + waitForBridge: async () => true, + close: async () => {}, + async call(method: string): Promise { + if (method === "describe") + return { + capabilities: ["runtime.resources", "runtime.fixedStep", "runtime.startup"], + limits: PLAYTEST_PROTOCOL_LIMITS, + name: "@threenative/playtest/three", + protocolVersion: 1, + } as T; + if (method === "ready") return { ready: true } as T; + throw new Error(`Unexpected preflight call: ${method}`); + }, + }; + await expect( + connectPlaytestBridgeTransport(transport, scenario, 1000, "desktop"), + ).resolves.toBeDefined(); +}); + +test("native ready and settled compilation cannot be omitted or inferred from pixels", () => { + for (const startup of [ + undefined, + { phase: "loading", progress: 0.9, compileSettled: true, rule: "compile-settled" }, + { phase: "ready", progress: 1, compileSettled: false, rule: "compile-settled" }, + ]) { + expect(() => assertNativeAssetCapture({ ...report(), startup }, cleanConsole)).toThrow(); + } +}); diff --git a/scripts/native-asset-capture-proof.ts b/scripts/native-asset-capture-proof.ts index 451132b978..63478f2697 100644 --- a/scripts/native-asset-capture-proof.ts +++ b/scripts/native-asset-capture-proof.ts @@ -50,11 +50,13 @@ export function inspectNativeAssetScreenshot(bytes: Buffer) { export function assertNativeAssetCapture( report: Pick< IStandalonePlaytestReport, - "pass" | "assertionResults" | "capture" | "diagnostics" | "runtime" | "target" + "pass" | "assertionResults" | "capture" | "diagnostics" | "runtime" | "target" | "startup" >, nativeConsole: unknown, ): void { assert.equal(report.pass, true, JSON.stringify(report.diagnostics)); + assert.equal(report.startup?.phase, "ready", "Native world never reached ready"); + assert.equal(report.startup.compileSettled, true, "Native compilation never settled"); assert.equal(report.runtime, "native"); assert.equal(report.target, "desktop"); assert.equal(report.capture?.captureMethod, "device.screenshot"); From f13f1ad912d118ccdc3ed6db00c32d9bf7d6f0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Furtado?= <30300459+jonit-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:05:57 -0700 Subject: [PATCH 05/36] docs(vq01): publish verified packaged QuickJS screenshot proof Preserve the original native framebuffer and runner summary from successful run 36998104447. Record exact source/runtime/package/adapter/image identities, verified digests and bounded decoder-free fallback scope. Full VQ01/mobile/codec acceptance remains open. --- .../PRD-VQ-01-native-asset-capabilities.md | 15 +++- docs/verification/vq01/README.md | 26 ++++++ docs/verification/vq01/provenance.json | 62 +++++++++++++ .../vq01/quickjs-native-summary.json | 82 ++++++++++++++++++ docs/verification/vq01/quickjs-native.png | Bin 0 -> 71626 bytes 5 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 docs/verification/vq01/README.md create mode 100644 docs/verification/vq01/provenance.json create mode 100644 docs/verification/vq01/quickjs-native-summary.json create mode 100644 docs/verification/vq01/quickjs-native.png diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md index 2d19198fea..20270c5034 100644 --- a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -4,7 +4,7 @@ prd_contract: v1 # PRD-VQ-01 — Native asset compatibility follows the selected runtime and actual decoders -**Status:** PARTIAL — 2026-10-02. Bounded desktop/unknown-runtime fail-closed repair implemented; mobile selected-artifact resolution and packaged-loader qualification remain open. +**Status:** PARTIAL — 2026-10-02. Bounded desktop/unknown-runtime fail-closed repair implemented and packaged Linux QuickJS fallback rendered on the hosted lane; mobile selected-artifact resolution and compressed-decoder qualification remain open. **Batch:** [Visual quality execution batch](https://github.com/ThreeNativeHQ/threenative/blob/d9ac5b4e97f6b1383bd163d91619cffa7c6c0ef5/docs/PRDs/batch-2026-10-01-visual-quality/README.md). **Wave:** 0 / correctness. **Dependencies:** None. This is a build/runtime contract repair, not permission to remove compatibility guards. @@ -64,7 +64,7 @@ Record encoded bytes, decoded texture/geometry bytes and peak load memory per co Physical Android performance and thermals require a named phone and are separate from emulator correctness. Missing codec qualifications must remain individual exclusions. -This executor has no GPU device, Android SDK/adb or KVM. Xvfb is denied with EPERM; browser/native pixels and emulator correctness are unrun. These limitations do not qualify any codec. The owner explicitly requires screenshot proofs for every PR (2026-10-02); no actual relevant runtime screenshot has been captured, so this PR remains draft. Test logs and synthetic images are not screenshot proof. +This local executor has no GPU device, Android SDK/adb or KVM, and Xvfb is unavailable. The hosted Linux ARM64 lane now provides real packaged QuickJS fallback pixels below. Browser and Android decoder correctness remain unrun. The owner's per-PR screenshot requirement is satisfied for this bounded fallback; the PR remains draft because full PRD acceptance and remaining platform gates are open. ## Completion record @@ -94,3 +94,14 @@ Hosted attempt [36995485869](https://github.com/ThreeNativeHQ/threenative/action Hosted attempt [36996645116](https://github.com/ThreeNativeHQ/threenative/actions/runs/36996645116), source `cedacefcab3360d5cc344ef071649f57782ac9b5`, reconfirmed build/package/payload checks but exposed the full admission rule: the **entire diagnostics assertion family is web-only**, independently of its individual booleans. No pixels were captured. A regression now replays the real native handshake through `connectPlaytestBridgeTransport`, not only its capability-name calculator; it failed with the exact hosted observation error, then passed after the scenario was limited to supported startup/resources families. Native console cleanliness and ready/compile-settled checks are mandatory in the verifier instead, with missing readiness, empty console evidence, GPU loss, validation and JavaScript errors all tested to fail. The complete focused set passes 34/34 and verifier types pass. No engine target-admission guard changed. + + +### Actual packaged native screenshot — 2026-10-02 + +[Hosted run 36998104447](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447) **passed**, executing source `46a759ad885cede29e0fbb2c719ed75151c5baec` on `ubuntu-24.04-arm`. The selected runtime is QuickJS/wgpu, runtime SHA-256 `da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042`; the packaged game SHA-256 is `e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d`. + +The original 960×640 native `device.screenshot` was downloaded, ZIP/image digests verified, and visually inspected. The Meshopt-source cube, Draco-source torus and PNG panel all visibly carry their authored textures after decoder-free cooking. Eight live assertions pass, including both loaded/textured models, decoded vertex presence, advancing frames/pose, and actual startup milestones. Native readiness is `ready` with settled compilation; strict host-console checks pass and the report contains no diagnostics. The named adapter is Mesa llvmpipe (LLVM 20.1.2, 128 bits), so this is rendered correctness on a hosted software adapter, not hardware-performance evidence. Authored KTX2 remains refused before publication, preserving the already packaged executable byte-for-byte. + +![Actual packaged QuickJS framebuffer: Meshopt and Draco sources normalized before execution, with a standalone PNG](../../verification/vq01/quickjs-native.png) + +[Provenance and scope](../../verification/vq01/README.md), [machine-readable provenance](../../verification/vq01/provenance.json), and [unchanged runner summary](../../verification/vq01/quickjs-native-summary.json). The PNG SHA-256 is `1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81`. This verifies the bounded desktop QuickJS fallback and screenshot request. It does not tick the unchanged web, Android, actual compressed-decoder or full lifecycle acceptance boxes. diff --git a/docs/verification/vq01/README.md b/docs/verification/vq01/README.md new file mode 100644 index 0000000000..d95eef3928 --- /dev/null +++ b/docs/verification/vq01/README.md @@ -0,0 +1,26 @@ +# VQ-01: actual packaged QuickJS fallback + +![Packaged native QuickJS framebuffer](quickjs-native.png) + +Left: a Meshopt-authored cube. Centre: a Draco-authored torus. Right: a standalone PNG texture. The two compressed model sources were decoded by the ordinary asset cook before execution; QuickJS is not being credited with compressed-codec support. + +The image is the unchanged `after.png` from [successful native run 36998104447](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447), source `46a759ad885cede29e0fbb2c719ed75151c5baec`, [artifact 11222881424](https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447/artifacts/11222881424). It was captured by the actual packaged executable using the native mailbox's `device.screenshot`, on the named Mesa llvmpipe software adapter. No browser capture, mock renderer, synthetic diagnostic image, crop, recolour or image-generation step is used here. + +## What passed + +- The actual ARM64 QuickJS/wgpu host was built through the repository's locked provisioner and normal native build path. +- Build selection, cooking, compatibility checks, native bundling and final packaging ran through the changed production code. The packaged executable retains the exact selected runtime bytes before its appended game. +- Decoded model positions, triangle counts, texture pixels and animation samples match the authored Meshopt/Draco inputs. Encoded source bytes are measured separately from decoded CPU accessor bytes. +- Authored KTX2 was refused before publishing and left the previous package byte-identical. +- Eight live assertions passed, covering loaded/textured models, decoded vertices, advancing frames/pose and startup milestones. Startup reached ready with compilation settled. +- Strict native host-console checks, actual native adapter/capture provenance, and both authored checker colours as opaque pixels in each specimen region passed. Report diagnostics are empty. +- Downloaded ZIP SHA-256: `5440effcf3acc3978207f8eba941888b0fbbf3de117c43f664145c5f7930fa90`. +- PNG SHA-256: `1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81` (71,626 bytes, 960×640). The downloaded image was visually inspected. + +## Limits + +This is a Linux desktop QuickJS **decoder-free fallback** proof. It does not qualify Android/iOS, native Basis/Meshopt/Draco decoder admission, hardware performance, peak-load memory, or full repeated-scene lifecycle. The `diagnostics` assertion family is web-only and the game bridge does not expose `runtime.diagnostics`; mandatory native host-console and readiness checks provide the recorded diagnostics evidence without changing engine target guards. + +The unchanged runner summary calls its total decoded accessor count `decodedGeometryBytes`; that count includes animation samples. It is a CPU accessor-byte count, not measured GPU allocation or a claimed compression-related GPU saving. + +[Machine-readable provenance](provenance.json) and [unchanged runner summary](quickjs-native-summary.json) retain the source, run, runtime, package, adapter and image identities. [Canonical PRD](../../PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md) remains partial. diff --git a/docs/verification/vq01/provenance.json b/docs/verification/vq01/provenance.json new file mode 100644 index 0000000000..c2bd1e09f5 --- /dev/null +++ b/docs/verification/vq01/provenance.json @@ -0,0 +1,62 @@ +{ + "schemaVersion": 1, + "sourceSha": "46a759ad885cede29e0fbb2c719ed75151c5baec", + "workflowRun": { + "id": 36998104447, + "url": "https://github.com/ThreeNativeHQ/threenative/actions/runs/36998104447", + "jobId": 110809358283, + "conclusion": "success" + }, + "downloadedArtifact": { + "id": 11222881424, + "name": "vq01-native-assets-46a759ad885cede29e0fbb2c719ed75151c5baec", + "zipSha256": "5440effcf3acc3978207f8eba941888b0fbbf3de117c43f664145c5f7930fa90" + }, + "screenshot": { + "file": "quickjs-native.png", + "originalArtifactFile": "after.png", + "sha256": "1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81", + "bytes": 71626, + "width": 960, + "height": 640, + "unaltered": true, + "visuallyInspected": true + }, + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 640, + "width": 960 + } + }, + "runtime": { + "artifact": "/home/runner/work/threenative/threenative/packages/runtime-native/build/tn-linux/mystral", + "identity": "desktop:sha256:da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "engine": "quickjs", + "webAssembly": false, + "decoders": { + "ktx2": false, + "meshopt": false, + "draco": false + } + }, + "packaged": { + "kind": "file", + "name": "vq-native-assets", + "sha256": "e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d" + }, + "assertionsPassed": 8, + "diagnostics": [], + "qualification": "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.", + "observability": "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.", + "measurementNote": "The raw summary field decodedGeometryBytes is the sum of decoded glTF accessor buffers, including animation samples. These are CPU accessor bytes, not measured GPU allocations; no compression-related GPU memory saving is claimed." +} diff --git a/docs/verification/vq01/quickjs-native-summary.json b/docs/verification/vq01/quickjs-native-summary.json new file mode 100644 index 0000000000..d054a20198 --- /dev/null +++ b/docs/verification/vq01/quickjs-native-summary.json @@ -0,0 +1,82 @@ +{ + "pass": true, + "sourceSha": "46a759ad885cede29e0fbb2c719ed75151c5baec", + "runId": "36998104447", + "qualification": "Packaged Linux QuickJS fallback correctness on a named hosted software adapter. No Android, iOS, native compressed-codec admission or hardware-performance claim.", + "observability": "Native host console, live readiness and resource observations; runtime.diagnostics is not exposed by this bridge.", + "runtime": { + "artifact": "/home/runner/work/threenative/threenative/packages/runtime-native/build/tn-linux/mystral", + "identity": "desktop:sha256:da16138cc37938a14ce6831476aa58972a0d469ba2172b6a06faf6d1d56c6042", + "engine": "quickjs", + "webAssembly": false, + "decoders": { + "ktx2": false, + "meshopt": false, + "draco": false + } + }, + "packaged": { + "kind": "file", + "name": "vq-native-assets", + "sha256": "e20910ccaf495d5455bcba4fa74299fb6f7dd86718e0802488dbb6970cab883d" + }, + "models": [ + { + "name": "meshopt", + "sourceSha256": "d67a2eeb9b22ce62843ad224ed3eaeab09b28af9b05ba2175dc1c6b5511c7812", + "cookedSha256": "71c635f4ed93f2a8f124e0f7a2143b9f4b277b8706afb022ed2c9c8023fc8880", + "sourceEncodedBytes": 4096, + "cookedEncodedBytes": 3908, + "decodedGeometryBytes": 2076, + "decodedImageBytes": 4096, + "triangles": 48 + }, + { + "name": "draco", + "sourceSha256": "1f448f031080d8369c587bd2fb5bbd485419f3ad4b9d741ff5d7eda58567dda3", + "cookedSha256": "261cc389a2f626f7983fda36ba2b24a7622bf12aaa07955872a78269a5f9f230", + "sourceEncodedBytes": 4956, + "cookedEncodedBytes": 20240, + "decodedGeometryBytes": 18396, + "decodedImageBytes": 4096, + "triangles": 768 + } + ], + "capture": { + "adapter": { + "architecture": "unknown", + "description": "Mesa 25.2.8-0ubuntu0.24.04.3 (LLVM 20.1.2)", + "device": "llvmpipe (LLVM 20.1.2, 128 bits)", + "vendor": "llvmpipe" + }, + "browserArgs": [], + "captureMethod": "device.screenshot", + "rendererKind": "webgpu", + "target": "desktop", + "viewport": { + "height": 640, + "width": 960 + } + }, + "pixels": { + "width": 960, + "height": 640, + "counts": [ + [ + 10449, + 10750 + ], + [ + 7139, + 6688 + ], + [ + 9603, + 9746 + ] + ] + }, + "screenshotSha256": "1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81", + "refusedCodec": "authored KTX2", + "previousPackagePreserved": true +} diff --git a/docs/verification/vq01/quickjs-native.png b/docs/verification/vq01/quickjs-native.png new file mode 100644 index 0000000000000000000000000000000000000000..b323d0247603c25759e6e82d5c9e71ce705f771a GIT binary patch literal 71626 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μTb%!#2sFsLimd)ZW&!khG!Q8WD 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 z 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 literal 0 HcmV?d00001 From b8ca3945e6e88dc49ebbb4557808190265a97abb Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 2 Oct 2026 18:58:02 -0700 Subject: [PATCH 06/36] ci: fold integration-native-assets.yml into integration.yml (one workflow, per AGENTS.md) The `native-assets` lane joins the shared `paths` gate as an anchored ERE translated from the old workflow's `paths:` globs, and `packaged-quickjs` keeps its steps, env, timeout and artifact names on the same ARM64 QuickJS lane behind `needs: paths`. Editing integration.yml keeps the lane's gate true, exactly as editing integration-native-assets.yml used to. The PRD record now names the lane instead of the deleted file. --- .../workflows/integration-native-assets.yml | 86 ------------------- .github/workflows/integration.yml | 12 ++- .../PRD-VQ-01-native-asset-capabilities.md | 2 +- 3 files changed, 12 insertions(+), 88 deletions(-) delete mode 100644 .github/workflows/integration-native-assets.yml diff --git a/.github/workflows/integration-native-assets.yml b/.github/workflows/integration-native-assets.yml deleted file mode 100644 index 35c669385c..0000000000 --- a/.github/workflows/integration-native-assets.yml +++ /dev/null @@ -1,86 +0,0 @@ -name: Integration native asset fallback -on: - pull_request: - paths: - - 'packages/create-threenative/src/build.ts' - - 'packages/create-threenative/src/buildReport.ts' - - 'packages/create-threenative/__tests__/vq-native-*.spec.ts' - - 'packages/assets/src/**' - - 'packages/core/src/assets.ts' - - 'packages/runtime-native/scripts/bundle.mjs' - - 'packages/runtime-native/scripts/package-desktop.mjs' - - 'packages/runtime-native/scripts/native-build.mjs' - - 'packages/runtime-native/scripts/download-deps.mjs' - - 'examples/abyss-framework/vq-assets/**' - - 'examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json' - - 'scripts/verify-native-asset-capabilities.ts' - - 'scripts/native-asset-capture-proof.ts' - - 'scripts/__tests__/native-asset-capture-proof.spec.ts' - - '.github/workflows/integration-native-assets.yml' -permissions: - contents: read -concurrency: - group: integration-native-assets-${{ github.event.pull_request.head.ref || github.ref_name }} - cancel-in-progress: true -jobs: - packaged-quickjs: - # Match native-platforms starter-linux: this architecture selects QuickJS/wgpu without - # changing any runtime default. Native dependencies use the repository's locked provisioner. - runs-on: ubuntu-24.04-arm - timeout-minutes: 60 - env: - CMAKE_BUILD_PARALLEL_LEVEL: '2' - TN_PLAYTEST_ALLOW_SOFTWARE: '1' - TN_PLAYTEST_NATIVE_SCREENSHOT_TIMEOUT_MS: '30000' - steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha }} - - uses: ./.github/actions/pnpm - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 - with: - node-version: '22' - cache: pnpm - - uses: actions/setup-python@v7 - with: - python-version: '3.12' - - run: pnpm install --frozen-lockfile - - name: Build this commit's JavaScript packages - uses: ./.github/actions/workspace-dist - with: - save-bundles: 'false' - pack-archives: 'false' - - name: Validate real compressed-source normalization and capture guards - run: >- - pnpm exec vitest run --maxWorkers=1 - packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts - packages/create-threenative/__tests__/vq-native-fixture.spec.ts - scripts/__tests__/native-asset-capture-proof.spec.ts - - name: Restore checksum-provisioned native dependencies - uses: actions/cache/restore@v4 - with: - path: packages/runtime-native/third_party - key: native-third-party-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('packages/runtime-native/scripts/download-deps.mjs', 'packages/runtime-native/scripts/package-android.mjs') }} - - name: Install the existing native lane's system prerequisites - run: >- - sudo apt-get update && sudo apt-get install -y - libasound2-dev libcurl4-openssl-dev libfontconfig1-dev libfreetype6-dev - libpulse-dev libudev-dev libx11-dev libxext-dev libxrandr-dev - libxcursor-dev libxi-dev libxfixes-dev libxss-dev libxkbcommon-dev - libgtk-3-dev libwebkit2gtk-4.1-dev libxcomposite-dev xcompmgr - libvulkan1 mesa-vulkan-drivers vulkan-tools xvfb zlib1g-dev - - name: Build the same QuickJS host the normal ARM desktop lane ships - run: pnpm --filter @threenative/runtime-native native:build - - name: Build the real fixture and capture its packaged native executable - env: - THREENATIVE_RUNTIME_BINARY: ${{ github.workspace }}/packages/runtime-native/build/tn-linux/mystral - run: node --import tsx scripts/verify-native-asset-capabilities.ts - - name: Retain raw native captures, artifact identities and decoded payload measurements - if: always() - uses: actions/upload-artifact@v7 - with: - name: vq01-native-assets-${{ github.event.pull_request.head.sha }} - path: artifacts/vq01-native-assets/ - retention-days: 30 - if-no-files-found: warn diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2552db7862..10843cdd7d 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -26,7 +26,7 @@ concurrency: cancel-in-progress: true jobs: - # The per-lane trigger filters these five files used to carry, as one gate: the pull request's own + # The per-lane trigger filters these six files used to carry, as one gate: the pull request's own # changed paths decide which lanes run, and a lane this commit does not touch is skipped rather than # spending a runner. Editing this workflow keeps every lane's gate true, which is what # `.github/workflows/integration-.yml` did for each of them. @@ -42,6 +42,7 @@ jobs: csg: ${{ steps.filter.outputs.csg }} decals: ${{ steps.filter.outputs.decals }} ik: ${{ steps.filter.outputs.ik }} + native-assets: ${{ steps.filter.outputs.native-assets }} tone: ${{ steps.filter.outputs.tone }} vegetation: ${{ steps.filter.outputs.vegetation }} steps: @@ -57,6 +58,7 @@ jobs: lane csg '^examples/integrations/csg/.*$|^\.github/workflows/integration\.yml$' lane decals '^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^\.github/workflows/integration\.yml$' lane ik '^examples/integrations/ik/.*$|^\.github/workflows/integration\.yml$' + lane native-assets '^packages/create-threenative/src/build\.ts$|^packages/create-threenative/src/buildReport\.ts$|^packages/create-threenative/__tests__/vq-native-[^/]*\.spec\.ts$|^packages/assets/src/.*$|^packages/core/src/assets\.ts$|^packages/runtime-native/scripts/bundle\.mjs$|^packages/runtime-native/scripts/package-desktop\.mjs$|^packages/runtime-native/scripts/native-build\.mjs$|^packages/runtime-native/scripts/download-deps\.mjs$|^examples/abyss-framework/vq-assets/.*$|^examples/abyss-framework/playtests/vq-native-asset-capabilities\.playtest\.json$|^scripts/verify-native-asset-capabilities\.ts$|^scripts/native-asset-capture-proof\.ts$|^scripts/__tests__/native-asset-capture-proof\.spec\.ts$|^\.github/workflows/integration\.yml$' lane tone '^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^\.github/workflows/integration\.yml$' lane vegetation '^examples/integrations/vegetation/.*$|^\.github/workflows/integration\.yml$' @@ -244,3 +246,11 @@ jobs: name: tone-exposure-${{ github.event.pull_request.head.sha }} path: artifacts/tone-exposure if-no-files-found: error + + packaged-quickjs: + name: packaged native asset fallback + if: ${{ !github.event.pull_request.draft && needs.paths.outputs.native-assets == 'true' }} + needs: paths + # Match native-platforms starter-linux: this architecture selects QuickJS/wgpu without + # changing any runtime default. Native dependencies use the repository's locked provisioner. + runs-on: ubuntu-24.04-arm diff --git a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md index 20270c5034..910934a031 100644 --- a/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md @@ -84,7 +84,7 @@ Update the phase boxes and this PRD only after the named proof runs. Record actu - `examples/abyss-framework/vq-assets/` is a separate opt-in fixture; the ordinary example is unchanged. It loads two actual cooked GLBs and a PNG through `ctx.assets`, then advances their authored clips with `AnimationPlayer`. The checker colours belong to those assets, not to a drawn diagnostic overlay. - `node --import tsx scripts/verify-native-asset-capabilities.ts` requires a real QuickJS executable in `THREENATIVE_RUNTIME_BINARY`, builds through the actual desktop resolver/cook/bundle/packager, verifies decoded payload equality, the exact runtime prefix in the packaged executable and the published build-report digest, then proves an authored KTX2 refusal preserves that same package. `runDesktopPlaytest` drives the resulting executable and captures its framebuffer with the existing mailbox transport. -- `.github/workflows/integration-native-assets.yml` uses the existing Linux ARM64 QuickJS/wgpu source-build lane, checksum-locked native provisioner and normal read-only `pull_request` permissions. Hosted software rasterization is explicitly declared; no hardware-performance claim is permitted. No credentials, guard exemptions or runtime defaults change. +- The `native-assets` lane in `.github/workflows/integration.yml` — the per-feature workflow this PRD first added, folded into the one integration workflow root AGENTS.md now requires — uses the existing Linux ARM64 QuickJS/wgpu source-build lane, checksum-locked native provisioner and normal read-only `pull_request` permissions. Hosted software rasterization is explicitly declared; no hardware-performance claim is permitted. No credentials, guard exemptions or runtime defaults change. - Local checks: 25/25 focused capability/real-fixture/capture-validator tests passed; fixture and focused verifier TypeScript checks passed; assets and CLI packages rebuilt with declarations/publint. The actual QuickJS fixture bundle was built and contains no `WebAssembly` reference. The scenario validates and workflow YAML parses; CI structure/needs checks pass 141/141. The local native verifier refuses at the explicit missing-runtime requirement before any runtime claim. - Capture acceptance is fail-closed: native `device.screenshot` provenance, nonempty passed live assertions, named WebGPU adapter, no lost-device/errors, exact 960×640 framebuffer, and both authored checker colours in opaque pixels in each of the three specimen regions. The transparent-RGB negative test failed before the alpha guard and passes with it. A lifecycle test confirms the fixture's owned sRGB texture clone and cached original are each disposed once, including a repeated exit. Published PNGs must be original runner bytes with matching SHA-256 and source/run provenance. - No new screenshot or hosted-run PASS is claimed in this commit. Full mobile/cohort, packaged-decoder and lifecycle acceptance stays open. From 64023080ed3c00df92d5a32edd4a28d02b91b285 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Fri, 2 Oct 2026 18:58:07 -0700 Subject: [PATCH 07/36] style: format the published VQ-01 runner summary so root lint passes Pure biome formatting of docs/verification/vq01/quickjs-native-summary.json, committed on this branch in f13f1ad91 and untouched since; every value is identical. --- .../verification/vq01/quickjs-native-summary.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/docs/verification/vq01/quickjs-native-summary.json b/docs/verification/vq01/quickjs-native-summary.json index d054a20198..c4dfd5e4c4 100644 --- a/docs/verification/vq01/quickjs-native-summary.json +++ b/docs/verification/vq01/quickjs-native-summary.json @@ -61,20 +61,7 @@ "pixels": { "width": 960, "height": 640, - "counts": [ - [ - 10449, - 10750 - ], - [ - 7139, - 6688 - ], - [ - 9603, - 9746 - ] - ] + "counts": [[10449, 10750], [7139, 6688], [9603, 9746]] }, "screenshotSha256": "1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81", "refusedCodec": "authored KTX2", From 4285a33469f1db433a6e5f7c5ba2c58d89e7d4af Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 15:40:26 -0700 Subject: [PATCH 08/36] docs(PRD-VQ-01): tick Phase 1 and Phase 2 on their own proofs after the merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each Phase 1 and Phase 2 box names a unit proof command. Re-ran them at the merge commit c29a21031: the capability suite passes 19/19 and the capability plus fixture pair passes 21/21. The results sit beside each box; the existing partial notes stay, so the open Android artifact resolution, decoder versions, KTX2 block-format validation and manifest-versus-payload work are still recorded as open. Phase 3 and the acceptance boxes are untouched — their proofs are the browser and Android scenarios, which did not run. Also repoint the verification README at the PRD's new assets/ path, which is what pnpm check:docs flagged after develop's folder move. --- .../assets/PRD-VQ-01-native-asset-capabilities.md | 12 ++++++++---- docs/verification/vq01/README.md | 2 +- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md index 1dbd9a6567..1532547c95 100644 --- a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md @@ -33,17 +33,21 @@ The unit proof paths and the desktop fallback scenario now exist. Browser and An ### Phase 1 — One capability decision -- [ ] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [x] Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + Verified 2026-10-03 at `c29a21031` (merge of `origin/develop` into this branch): `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` passed 19/19, exit 0. Partial: desktop resolves the packager's actual binary before cooking and hashes its bytes. Failed, absent, unknown and non-V8 probes take the decoder-free path. Android/iOS never probe the desktop packaging executable; their exact artifact/cohort resolution remains open. -- [ ] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [x] Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + Verified 2026-10-03 at `c29a21031`: same command passed 19/19, exit 0. Partial: the record governs cooking, compatibility guards, and the desktop native-backend bundle path; embedded/shared KTX2 and Meshopt/Draco declarations are refused when unavailable. The existing desktop V8 path is preserved, not newly qualified. ### Phase 2 — The packaged bytes are authoritative -- [ ] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. +- [x] Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts`. + Verified 2026-10-03 at `c29a21031`: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts` passed 19/19, exit 0. Partial: cook keys include explicit decoder capabilities and selected desktop runtime SHA-256; QuickJS/unknown desktop bundles use the existing refusing stubs. Decoder versions, KTX2 block-format validation and manifest-versus-payload verification remain open. -- [ ] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts`. +- [x] Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts`. + Verified 2026-10-03 at `c29a21031`: `pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts` passed 21/21 (19 + 2), exit 0. Partial: generated Meshopt and Draco textured/animated inputs are compared against decoder-free cooked files for exact decoded positions, triangle counts, animation samples and texture pixels. Authored KTX2 is encoded by the existing texture pass and explicitly refused; no packaged KTX2 decode is claimed. ### Phase 3 — Qualify the visible result diff --git a/docs/verification/vq01/README.md b/docs/verification/vq01/README.md index d95eef3928..6d2028b22c 100644 --- a/docs/verification/vq01/README.md +++ b/docs/verification/vq01/README.md @@ -23,4 +23,4 @@ This is a Linux desktop QuickJS **decoder-free fallback** proof. It does not qua The unchanged runner summary calls its total decoded accessor count `decodedGeometryBytes`; that count includes animation samples. It is a CPU accessor-byte count, not measured GPU allocation or a claimed compression-related GPU saving. -[Machine-readable provenance](provenance.json) and [unchanged runner summary](quickjs-native-summary.json) retain the source, run, runtime, package, adapter and image identities. [Canonical PRD](../../PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md) remains partial. +[Machine-readable provenance](provenance.json) and [unchanged runner summary](quickjs-native-summary.json) retain the source, run, runtime, package, adapter and image identities. [Canonical PRD](../../PRDs/assets/PRD-VQ-01-native-asset-capabilities.md) remains partial. From 03d2903a80ca6740d10ee1ede019c988b9949a93 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 15:42:02 -0700 Subject: [PATCH 09/36] ci: keep the native-assets lane off develop's world-capture assets filter The re-applied native-assets filter still claimed all of packages/assets/src, which develop's world-capture lane now owns. scripts/__tests__/ci-integration-scope.spec.ts proves a packages/assets/src/index.ts change selects world-capture alone, and it failed on the overlap. Narrow the claim to the two modules this lane's proof actually depends on, compile.ts and report.ts, the decoder-free cook and the build report. Selection checked against the real parser: build.ts and compile.ts select native-assets, index.ts still selects world-capture alone. --- .github/workflows/integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 4251d02e86..e81b29e366 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -63,7 +63,7 @@ jobs: decals ^packages/playtest/.*$|^packages/create-threenative/templates/shooter/src/render/decals\.ts$|^packages/create-threenative/templates/shooter/src/scenes/Play\.ts$|^packages/create-threenative/__tests__/shooter-decals[^/]*\.spec\.ts$|^packages/create-threenative/__tests__/fixtures/bounded-decals/.*$|^packages/runtime-native/.*$|^scripts/xvfb\.sh$|^\.github/actions/workspace-dist/.*$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^pnpm-lock\.yaml$ fluid-particles ^packages/core/src/fluid-particles\.ts$|^packages/core/src/gpu-readback\.ts$|^packages/core/__tests__/fluid-particles\.spec\.ts$|^packages/core/patches/.*$|^packages/playtest/.*$|^examples/prd476-fluid-particles/.*$|^scripts/verify-fluid-collision\.ts$|^scripts/verify-fluid-consumers\.ts$|^scripts/verify-fluid-collision-native\.ts$|^scripts/__tests__/fluid-collision-native-proof\.spec\.ts$|^packages/runtime-native/.*$|^\.github/actions/workspace-dist/.*$|^scripts/fluid-collision-proof\.ts$|^scripts/__tests__/fluid-collision-proof\.spec\.ts$|^scripts/__tests__/fluid-collision-view\.spec\.ts$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^scripts/xvfb\.sh$|^scripts/workspace-packages\.ts$ ik ^examples/integrations/ik/.*$ - native-assets ^packages/create-threenative/src/build\.ts$|^packages/create-threenative/src/buildReport\.ts$|^packages/create-threenative/__tests__/vq-native-[^/]*\.spec\.ts$|^packages/assets/src/.*$|^packages/core/src/assets\.ts$|^packages/runtime-native/scripts/bundle\.mjs$|^packages/runtime-native/scripts/package-desktop\.mjs$|^packages/runtime-native/scripts/native-build\.mjs$|^packages/runtime-native/scripts/download-deps\.mjs$|^examples/abyss-framework/vq-assets/.*$|^examples/abyss-framework/playtests/vq-native-asset-capabilities\.playtest\.json$|^scripts/verify-native-asset-capabilities\.ts$|^scripts/native-asset-capture-proof\.ts$|^scripts/__tests__/native-asset-capture-proof\.spec\.ts$ + native-assets ^packages/create-threenative/src/build\.ts$|^packages/create-threenative/src/buildReport\.ts$|^packages/create-threenative/__tests__/vq-native-[^/]*\.spec\.ts$|^packages/assets/src/(compile|report)\.ts$|^packages/core/src/assets\.ts$|^packages/runtime-native/scripts/bundle\.mjs$|^packages/runtime-native/scripts/package-desktop\.mjs$|^packages/runtime-native/scripts/native-build\.mjs$|^packages/runtime-native/scripts/download-deps\.mjs$|^examples/abyss-framework/vq-assets/.*$|^examples/abyss-framework/playtests/vq-native-asset-capabilities\.playtest\.json$|^scripts/verify-native-asset-capabilities\.ts$|^scripts/native-asset-capture-proof\.ts$|^scripts/__tests__/native-asset-capture-proof\.spec\.ts$ tone ^scripts/xvfb\.sh$|^packages/playtest/.*$|^examples/abyss-framework/src/render/toneCalibration\.ts$|^examples/abyss-framework/src/tone-main\.ts$|^examples/abyss-framework/tone\.html$|^examples/abyss-framework/tone\.vite\.config\.ts$|^examples/abyss-framework/playtests/tone\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-tone-exposure\.ts$|^scripts/tone-capture-proof\.ts$|^scripts/__tests__/tone-capture-proof\.spec\.ts$|^scripts/non-visual-scenarios\.mjs$|^package\.json$|^pnpm-lock\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$|^packages/create-threenative/(template-assets|templates/[^/]+/src/render)/(autoExposure|exposureGraph|exposureReadback|exposureSettings|worldEnvironment)\.ts$ vegetation ^examples/integrations/vegetation/.*$ world-capture ^packages/core/src/world[^/]*\.ts$|^packages/core/__tests__/world-capture-fixture\.spec\.ts$|^packages/playtest/.*$|^packages/assets/.*$|^examples/abyss-framework/src/scenes/WorldProbe\.ts$|^examples/abyss-framework/src/world-main\.ts$|^examples/abyss-framework/src/style\.css$|^examples/abyss-framework/assets/world/.*$|^examples/abyss-framework/index\.html$|^examples/abyss-framework/vite\.config\.ts$|^examples/abyss-framework/threenative\.config\.ts$|^examples/abyss-framework/playtests/phase477-world-capture\.playtest\.json$|^examples/abyss-framework/package\.json$|^scripts/verify-world-capture\.ts$|^scripts/world-capture-manifest\.ts$|^scripts/world-visual-gate\.ts$|^scripts/capture-guard\.ts$|^scripts/__tests__/verify-world-capture\.spec\.ts$|^scripts/__tests__/world-capture-manifest\.spec\.ts$|^scripts/__tests__/world-visual-gate\.spec\.ts$|^package\.json$|^pnpm-lock\.yaml$|^pnpm-workspace\.yaml$|^\.github/actions/pnpm/.*$|^\.github/actions/playwright-chromium/.*$ From e62bd7a9ef1845fc89c6d71ee4a3a88746f9202e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 15:56:04 -0700 Subject: [PATCH 10/36] fix(typecheck): give the root program one copy of core for the VQ-01 fixture The root tsconfig mapped the bare `@threenative/core` specifier to `packages/core/src` and left the `./playtest` subpath to fall through `node_modules` to `packages/core/dist`. The VQ-01 fixture at `examples/abyss-framework/vq-assets/src/game.ts` puts both in one expression (`plugins: [playtest()]` beside `defineGame`), so nominal types from two copies met and `pnpm typecheck` failed with `#private` missing on `RenderChain`. Map the subpath to its source entry like the bare specifier, the way a sibling example's own program already resolves both through the package exports. --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 87e24b23d7..e91e6217d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ "baseUrl": ".", "paths": { "@threenative/core": ["packages/core/src/index.ts"], + "@threenative/core/playtest": ["packages/core/src/playtest.ts"], "@threenative/physics": ["packages/physics/src/index.ts"], "@threenative/playtest": ["packages/playtest/src/index.ts"], "@threenative/ui": ["packages/ui/src/index.ts"] From 5e5ec9829839bc205ad325164232b63ffd15bc59 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 16:04:27 -0700 Subject: [PATCH 11/36] feat(assets): cook embedded EXT_texture_webp images (PRD-485 phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decodeImageBytes reads WebP through sharp — already declared in this package's dependencies and unused until now, so no dependency and no new native binary reaches a game (every template already ships the same one via @gltf-transform/cli). An embedded WebP now cooks like a PNG: KTX2 on a target that decodes it, a capped PNG on the decoder-free path, and the pass disposes EXT_texture_webp once no image is left in it because the glTF writer emits every attached extension. The pass's "never grow a native dependency" rule changes with it: this is a Node-only build-time package and sharp is the only native thing in it. Gates: red before (TN_ASSETS_TEXTURE_CONTAINER: 'character.glb#checker' is not a PNG or JPEG), green after — 26/26 model-texture-pass, 474 passed / 2 skipped across packages/assets/__tests__, pnpm typecheck, pnpm lint, assets build + publint. --- ...high-quality-assets-go-through-the-cook.md | 1 + .../__tests__/model-texture-pass.spec.ts | 192 +++++++++++++++++- packages/assets/src/passes/decode-image.ts | 29 ++- packages/assets/src/passes/model-textures.ts | 9 +- packages/assets/src/passes/texture.ts | 2 +- 5 files changed, 226 insertions(+), 7 deletions(-) diff --git a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md index 9f042a7dca..67de9533ed 100644 --- a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md +++ b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md @@ -49,6 +49,7 @@ Uncooked UE5 Megascans import as LOD0 only. The importer decompresses the larges **Status:** NOT STARTED **Files:** `packages/assets/src/passes/decode-image.ts`, `packages/assets/src/passes/model-textures.ts`; `packages/assets/__tests__/model-texture-pass.spec.ts` - [ ] [local] A GLB with an `EXT_texture_webp` image cooks to KTX2 on a decoding target and to a capped PNG on the decoder-free path. No output still requires `EXT_texture_webp`. proof: red-green case in `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts`. + Implementation: `sharp` decodes WebP in `decode-image.ts` — the first rung of the decision above, already declared at `packages/assets/package.json:57`, so no dependency is added and no new binary is installed for a game (every template already carries `@gltf-transform/cli`, which drags the same native package). It loads in the pass's worker thread and stays external in `dist`. `compressEmbeddedTextures` disposes `EXT_texture_webp` once no image is left in it, because the glTF writer emits every attached extension. The rule that `decode-image.ts` "must never grow a native dependency" is changed here: this is a Node-only build-time package, and `sharp` is the only native thing in it. Spec: the `embedded EXT_texture_webp` block of `model-texture-pass.spec.ts`, with the KTX2 leg decoding mip 0 through the shipped `basis_transcoder`. - [ ] [local] `lumen-hall` builds on web with `models: "none"` deleted, and its asset scenario passes. proof: `npx threenative build` in the game, then `node packages/playtest/dist/runner/cli.js playtests/assets.playtest.json --url --browser-recipe webgpu`. #### Phase 2: The Fab tree cooks by default diff --git a/packages/assets/__tests__/model-texture-pass.spec.ts b/packages/assets/__tests__/model-texture-pass.spec.ts index 7458841893..db27849d8f 100644 --- a/packages/assets/__tests__/model-texture-pass.spec.ts +++ b/packages/assets/__tests__/model-texture-pass.spec.ts @@ -1,9 +1,11 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { type Document, NodeIO } from "@gltf-transform/core"; -import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { ALL_EXTENSIONS, EXTTextureWebP } from "@gltf-transform/extensions"; import { read as readKTX2 } from "ktx-parse"; import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; +import { PNG } from "pngjs"; import { describe, expect, it, vi } from "vitest"; import { buildFixtureDocument } from "../../../test-support/generate-fixture-model.js"; import { rgbaPng } from "../../../test-support/png.js"; @@ -81,6 +83,118 @@ async function compiled( return { buffer: result.buffer, entry: result.entry ?? {} }; } +/** + * Two flat halves, never one solid colour: `prune` deletes a single-colour texture and folds it + * into the material factor, which would leave these cases measuring a material and no image. + */ +const WEBP_HALVES = [ + [200, 130, 40], + [20, 60, 220], +] as const; + +/** A lossless WebP of the requested size, encoded by the decoder the pass uses. */ +async function tinyWebp(size = 4): Promise { + const sharp = (await import("sharp")).default; + const pixels = Buffer.alloc(size * size * 4); + for (let index = 0; index < size * size; index += 1) { + pixels.set([...WEBP_HALVES[index % size < size / 2 ? 0 : 1], 255], index * 4); + } + return sharp(pixels, { raw: { channels: 4, height: size, width: size } }) + .webp({ lossless: true }) + .toBuffer(); +} + +/** + * The shared fixture with its base colour replaced by an `EXT_texture_webp` image, which is what + * a Fab or McD export ships: the extension declared and required, the payload in a WebP. + */ +async function fixtureWithWebp(size?: number): Promise { + const document = buildFixtureDocument(); + document.createExtension(EXTTextureWebP).setRequired(true); + document + .getRoot() + .listTextures()[0] + ?.setImage(await tinyWebp(size)) + .setMimeType("image/webp"); + // The writer only emits the extensions it was told about, and an undeclared `image/webp` + // would be the invalid document this fixture exists to avoid. + return Buffer.from(await new NodeIO().registerExtensions(ALL_EXTENSIONS).writeBinary(document)); +} + +interface IBasisModule { + readonly KTX2File: new ( + bytes: Uint8Array, + ) => { + close(): void; + getHeight(): number; + getImageTranscodedSizeInBytes(mip: number, layer: number, face: number, format: number): number; + getWidth(): number; + isValid(): boolean; + startTranscoding(): boolean; + transcodeImage( + dst: Uint8Array, + mip: number, + layer: number, + face: number, + format: number, + getAlphaForOpaqueFormats: number, + channel0: number, + channel1: number, + ): boolean; + }; + readonly initializeBasis: () => void; + readonly transcoder_texture_format: Readonly>; +} + +/** + * Mip 0 of a KTX2 as RGBA8, decoded by the same `basis_transcoder` the runtime ships. Three + * publishes it CommonJS inside an ESM package, so it is evaluated here the way its own wrapper + * does — the alternative is trusting the container header instead of the pixels. + */ +async function transcodeFirstLevel(ktx2: Uint8Array): Promise { + const paths = basisTranscoderPaths(); + const directory = path.dirname(paths.javascriptPath); + const source = await readFile(paths.javascriptPath, "utf8"); + const shim: { exports: unknown } = { exports: {} }; + new Function("module", "exports", "require", "__filename", "__dirname", source)( + shim, + shim.exports, + createRequire(import.meta.url), + paths.javascriptPath, + directory, + ); + const factory = shim.exports as (options: { + locateFile: (file: string) => string; + }) => Promise; + const basis = await factory({ locateFile: (file) => path.join(directory, file) }); + basis.initializeBasis(); + const file = new basis.KTX2File(ktx2); + // The emscripten bindings report booleans as numbers, so both calls are judged on truth. + expect(file.isValid()).toBeTruthy(); + expect(file.startTranscoding()).toBeTruthy(); + // The WASM enum's numbering is not three's TranscoderFormat map, so the shipped enum names it. + const rgba32 = basis.transcoder_texture_format.cTFRGBA32; + if (rgba32 === undefined) throw new Error("the shipped transcoder exposes no cTFRGBA32 format"); + const format = rgba32.value; + const pixels = new Uint8Array(file.getImageTranscodedSizeInBytes(0, 0, 0, format)); + expect(file.transcodeImage(pixels, 0, 0, 0, format, 0, -1, -1)).toBeTruthy(); + file.close(); + return pixels; +} + +/** Every channel within `tolerance` of the source half the pixel belongs to. */ +function expectColours(pixels: Uint8Array, width: number, tolerance: number): void { + expect(pixels.length).toBe(width * width * 4); + for (let offset = 0; offset < pixels.length; offset += 4) { + const half = WEBP_HALVES[(offset / 4) % width < width / 2 ? 0 : 1]; + for (let channel = 0; channel < 3; channel += 1) { + expect(Math.abs((pixels[offset + channel] ?? 0) - (half[channel] ?? 0))).toBeLessThanOrEqual( + tolerance, + ); + } + } +} + describe("embedded model textures", () => { it("should transcode every embedded image to KTX2 and declare KHR_texture_basisu", async () => { const input = await fixtureWithTextures({ width: 32 }); @@ -291,6 +405,82 @@ describe("embedded model textures", () => { }); }); +describe("embedded EXT_texture_webp", () => { + it("should cook a WebP image to KTX2 and drop the extension", async () => { + // A named per-slot codec also skips the keep-smaller-source guard: a 4x4 WebP is smaller + // than the KTX2 container around it, and the test needs the cook to have run. + const { buffer } = await compiled(await fixtureWithWebp(), { + textures: { overrides: [{ codec: "uastc", slot: "baseColorTexture" }] }, + }); + const json = JSON.parse(buffer.subarray(20, 20 + buffer.readUInt32LE(12)).toString("utf8")) as { + extensionsRequired?: string[]; + extensionsUsed?: string[]; + }; + expect(json.extensionsUsed ?? []).not.toContain("EXT_texture_webp"); + expect(json.extensionsRequired ?? []).not.toContain("EXT_texture_webp"); + expect(json.extensionsRequired ?? []).toContain("KHR_texture_basisu"); + + const cooked = (await readOutput(buffer)) + .getRoot() + .listTextures() + .find((texture) => texture.getName() === "checker"); + expect(cooked?.getMimeType()).toBe("image/ktx2"); + const image = cooked?.getImage() ?? new Uint8Array(); + expect(readKTX2(image)).toMatchObject({ pixelHeight: 4, pixelWidth: 4 }); + // UASTC is lossless for 8-bit RGBA, so the transcoded mip is the source colour: proof the + // WebP payload reached the encoder instead of a container that merely looked like one. + expectColours(await transcodeFirstLevel(image), 4, 1); + }); + + it("should cap an over-cap WebP image to PNG on a target with no transcoder", async () => { + const root = await makeTempDir("threenative-webp-decoder-free-"); + await mkdir(path.join(root, "assets")); + // 8x8 against a 4-longest-edge cap: `cappedSize` never returns less than one 4x4 block, so + // only an over-cap source can reach the PNG the decoder-free path emits. + await writeFile(path.join(root, "assets", "prop.glb"), await fixtureWithWebp(8)); + + await compileAssets({ + config: { models: { sharedImages: false, textures: { maxSize: 4 } } }, + cwd: root, + platform: "android", + }); + + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ) as { entries: Record> }; + const entry = manifest.entries["prop.glb"]; + if (entry === undefined || typeof entry.output !== "string") { + throw new Error("no manifest entry for 'prop.glb'"); + } + expect(entry.extensions ?? []).not.toContain("EXT_texture_webp"); + + const cooked = (await readOutput(await readFile(path.join(root, "public", entry.output)))) + .getRoot() + .listTextures() + .find((texture) => texture.getName() === "checker"); + expect(cooked?.getMimeType()).toBe("image/png"); + const image = Buffer.from(cooked?.getImage() ?? new Uint8Array()); + expect(parsePng(image)).toMatchObject({ height: 4, width: 4 }); + // A PNG is lossless, so the resampled colour must be the source one. + expectColours(PNG.sync.read(image).data, 4, 1); + }); + + it("should still fail closed on a container that is not an image", async () => { + const document = buildFixtureDocument(); + document + .getRoot() + .listTextures()[0] + ?.setImage(Buffer.from("GIF89a not an image at all")) + .setMimeType("image/png"); + const input = Buffer.from(await new NodeIO().writeBinary(document)); + await expect( + apply(input, { + passes: { dedup: false, meshopt: false, prune: false, quantize: false, reorder: false }, + }), + ).rejects.toThrow(/TN_ASSETS_MODEL_TEXTURE_UNDECODABLE.*TN_ASSETS_TEXTURE_CONTAINER/su); + }); +}); + describe("mesh simplification", () => { it("should leave triangles untouched unless simplification is declared", async () => { const input = await fixtureWithTextures({ width: 32 }); diff --git a/packages/assets/src/passes/decode-image.ts b/packages/assets/src/passes/decode-image.ts index b9b6ce00b4..c14dc1bc09 100644 --- a/packages/assets/src/passes/decode-image.ts +++ b/packages/assets/src/passes/decode-image.ts @@ -3,9 +3,10 @@ import { parsePng } from "../png.js"; /** * Decodes an encoded image's bytes to tight 8-bit RGBA for the Basis encoder, which takes - * decoded pixels through its Node-side `imageDecoder` hook. Pure-JS decoders on purpose: - * this package is Node-only and must never grow a native dependency. Containers neither - * decoder reads throw — a source this step cannot encode fails the build naming the file, + * decoded pixels through its Node-side `imageDecoder` hook. PNG and JPEG are pure-JS decoders + * already in the dependency tree; WebP is not, so it goes through `sharp` — declared but + * previously unused, native, and confined to this build-time package (PRD-485). Containers none + * of the three reads throw: a source this step cannot encode fails the build naming the file, * never silently ships uncompressed. */ export async function decodeImageBytes( @@ -23,8 +24,28 @@ export async function decodeImageBytes( assertRgba(image.data.length, image.width, image.height, logicalPath); return { data: new Uint8Array(image.data), height: image.height, width: image.width }; } + // RIFF container, `WEBP` form type — what an EXT_texture_webp image carries out of Fab. + if ( + bytes.length >= 12 && + bytes.toString("ascii", 0, 4) === "RIFF" && + bytes.toString("ascii", 8, 12) === "WEBP" + ) { + const sharp = (await import("sharp")).default; + try { + const { data, info } = await sharp(bytes) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + assertRgba(data.length, info.width, info.height, logicalPath); + return { data, height: info.height, width: info.width }; + } catch (error) { + throw new Error( + `TN_ASSETS_TEXTURE_UNREADABLE: '${logicalPath}' is a WebP the decoder could not read: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } throw new Error( - `TN_ASSETS_TEXTURE_CONTAINER: '${logicalPath}' is not a PNG or JPEG the KTX2 encoder can read; convert it to .png or .jpg.`, + `TN_ASSETS_TEXTURE_CONTAINER: '${logicalPath}' is not a PNG, JPEG or WebP the KTX2 encoder can read; convert it to .png or .jpg.`, ); } diff --git a/packages/assets/src/passes/model-textures.ts b/packages/assets/src/passes/model-textures.ts index 07fb3d808b..bddab7cd02 100644 --- a/packages/assets/src/passes/model-textures.ts +++ b/packages/assets/src/passes/model-textures.ts @@ -1,5 +1,5 @@ import type { Document, Texture } from "@gltf-transform/core"; -import { KHRTextureBasisu } from "@gltf-transform/extensions"; +import { EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions"; import { getTextureColorSpace, listTextureInfo, listTextureSlots } from "@gltf-transform/functions"; import { read as readKTX2 } from "ktx-parse"; import { PNG } from "pngjs"; @@ -583,6 +583,13 @@ export async function compressEmbeddedTextures( // and a glTF that pretends otherwise fails at the first frame instead of at load. document.createExtension(KHRTextureBasisu).setRequired(true); } + // A glTF writer emits every extension still attached, so a model authored with WebP images + // would keep *requiring* EXT_texture_webp after the cook replaced those images. Dropped only + // once no image is left in the container: an over-cap image the project chose to keep as + // authored is still a WebP a reader has to understand. + if (!textures.some((texture) => texture.getMimeType() === "image/webp")) { + document.disposeExtension(EXTTextureWebP.EXTENSION_NAME); + } return { bytesAfter, bytesBefore, diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index 14026a2362..b9fa713a96 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -224,7 +224,7 @@ export function textureResizePass(options: ITextureResizeOptions): IAssetPass { }; } -/** PNG signature or JPEG SOI, the two containers `decodeImageBytes` reads. */ +/** PNG signature or JPEG SOI, the two containers the standalone pass sniffs headers for. */ function isPngOrJpeg(bytes: Buffer): boolean { if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { return true; From ba1544cf47cd3ee49a7e4714c442f82b0b3a866d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 16:05:18 -0700 Subject: [PATCH 12/36] docs(PRD-485): tick embedded WebP cook on its red-green spec --- .../PRD-485-high-quality-assets-go-through-the-cook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md index 67de9533ed..8a1839dcfa 100644 --- a/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md +++ b/docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md @@ -48,7 +48,7 @@ Uncooked UE5 Megascans import as LOD0 only. The importer decompresses the larges #### Phase 1: Embedded WebP cooks **Status:** NOT STARTED **Files:** `packages/assets/src/passes/decode-image.ts`, `packages/assets/src/passes/model-textures.ts`; `packages/assets/__tests__/model-texture-pass.spec.ts` -- [ ] [local] A GLB with an `EXT_texture_webp` image cooks to KTX2 on a decoding target and to a capped PNG on the decoder-free path. No output still requires `EXT_texture_webp`. proof: red-green case in `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts`. +- [x] [local] A GLB with an `EXT_texture_webp` image cooks to KTX2 on a decoding target and to a capped PNG on the decoder-free path. No output still requires `EXT_texture_webp`. proof: red-green case in `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts`. **Result 2026-10-03 (`5e5ec9829`):** red `TN_ASSETS_TEXTURE_CONTAINER … is not a PNG or JPEG` before; green 3/3 after (KTX2 mip 0 transcodes back to the source within 1, decoder-free capped PNG, unknown container still throws); file 26/26, `packages/assets` 474 passed. Implementation: `sharp` decodes WebP in `decode-image.ts` — the first rung of the decision above, already declared at `packages/assets/package.json:57`, so no dependency is added and no new binary is installed for a game (every template already carries `@gltf-transform/cli`, which drags the same native package). It loads in the pass's worker thread and stays external in `dist`. `compressEmbeddedTextures` disposes `EXT_texture_webp` once no image is left in it, because the glTF writer emits every attached extension. The rule that `decode-image.ts` "must never grow a native dependency" is changed here: this is a Node-only build-time package, and `sharp` is the only native thing in it. Spec: the `embedded EXT_texture_webp` block of `model-texture-pass.spec.ts`, with the KTX2 leg decoding mip 0 through the shipped `basis_transcoder`. - [ ] [local] `lumen-hall` builds on web with `models: "none"` deleted, and its asset scenario passes. proof: `npx threenative build` in the game, then `node packages/playtest/dist/runner/cli.js playtests/assets.playtest.json --url --browser-recipe webgpu`. From c2eb9725a8d7fcb8f42d4ba67e20313241253444 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 16:10:34 -0700 Subject: [PATCH 13/36] feat(vq01): make the shared VQ-01 fixture's web proof runnable The Phase 3 web box names a browser command but nothing could serve the fixture to it: examples/abyss-framework/vq-assets had no web entry at all, and `threenative build` refused it with UNRESOLVED_ENTRY. - index.html + src/main.ts: the minimal template's own canvas mount, so the web build has the entry page every Vite build needs and the unchanged game and cooked assets are what render. - scripts/vq01-web-proof.sh: generates the authored Meshopt/Draco GLBs and PNG through the same function the native verifier uses, cooks the fixture for web, and runs the PRD's proof command against a managed preview server on 127.0.0.1:5193. The box stays open. This lane has no browser, so only the parts a browser does not need were verified: the build publishes dist/ (exit 0), the scenario loads through loadPlaytestScenario, the server command answers 200 for the page and for the 1034430-byte game bundle, and the fixture still typechecks. --- .../PRD-VQ-01-native-asset-capabilities.md | 1 + examples/abyss-framework/vq-assets/index.html | 28 +++++++++++++++ .../abyss-framework/vq-assets/src/main.ts | 8 +++++ scripts/vq01-web-proof.sh | 34 +++++++++++++++++++ 4 files changed, 71 insertions(+) create mode 100644 examples/abyss-framework/vq-assets/index.html create mode 100644 examples/abyss-framework/vq-assets/src/main.ts create mode 100755 scripts/vq01-web-proof.sh diff --git a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md index 1532547c95..29a79f11e6 100644 --- a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md @@ -53,6 +53,7 @@ The unit proof paths and the desktop fallback scenario now exist. Browser and An ### Phase 3 — Qualify the visible result - [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. + Runner: `sh scripts/vq01-web-proof.sh ` generates the authored GLBs/PNG, cooks the fixture for web and serves it on `127.0.0.1:5193` for that command. Unrun here: this lane has no browser. - [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`. ## Acceptance criteria diff --git a/examples/abyss-framework/vq-assets/index.html b/examples/abyss-framework/vq-assets/index.html new file mode 100644 index 0000000000..8660e2a08e --- /dev/null +++ b/examples/abyss-framework/vq-assets/index.html @@ -0,0 +1,28 @@ + + + + + + VQ-01 native asset fallback + + + +
+ + + \ No newline at end of file diff --git a/examples/abyss-framework/vq-assets/src/main.ts b/examples/abyss-framework/vq-assets/src/main.ts new file mode 100644 index 0000000000..dfdd4f793d --- /dev/null +++ b/examples/abyss-framework/vq-assets/src/main.ts @@ -0,0 +1,8 @@ +import game from "./game.js"; + +const app = document.querySelector("#app"); +if (app === null) throw new Error("Missing #app element."); +void game.start().then(() => { + const canvas = game.ctx?.renderer.domElement; + if (canvas !== undefined) app.prepend(canvas); +}); diff --git a/scripts/vq01-web-proof.sh b/scripts/vq01-web-proof.sh new file mode 100755 index 0000000000..2fe5c62b22 --- /dev/null +++ b/scripts/vq01-web-proof.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# PRD-VQ-01 Phase 3, web box: cook the unchanged examples/abyss-framework/vq-assets fixture for the +# web target, then run the shared scenario against it in a WebGPU browser. +# +# sh scripts/vq01-web-proof.sh +# +# The web build and its preview server are started here; the browser run is the runner's job and +# is the one step whose result this script cannot report for you. +set -eu + +artifacts=${1:?usage: sh scripts/vq01-web-proof.sh } +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$root" + +# The authored sources the cook reads: real Meshopt and Draco GLBs plus the PNG it transcodes to +# KTX2. The native verifier generates them through the same function, so both targets cook the +# same inputs instead of two sets that can drift. +node --import tsx -e ' +const { generateNativeAssetFixture } = await import("./examples/abyss-framework/vq-assets/generate.ts"); +await generateNativeAssetFixture("examples/abyss-framework/vq-assets/.generated/assets"); +' + +# The web target build: cooks .generated/assets into .generated/public and publishes dist/. +(cd examples/abyss-framework/vq-assets && node "$root/packages/create-threenative/dist/threenative.js" build) + +# `pnpm --dir` runs the vite that examples/abyss-framework already installs, with the fixture as +# its root; the runner owns the server lifecycle and probes 5193 for readiness. +node packages/playtest/dist/runner/cli.js \ + examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json \ + --url http://127.0.0.1:5193/ \ + --server-command 'pnpm --dir examples/abyss-framework exec vite preview vq-assets --host 127.0.0.1 --port 5193 --strictPort' \ + --browser-recipe webgpu \ + --headed \ + --artifacts "$artifacts" \ No newline at end of file From c9d33523d79f756f34deac0e8aa4057995febd07 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 3 Oct 2026 16:12:30 -0700 Subject: [PATCH 14/36] test(vq01): web codec proof passes on WebGPU; the fixture page links an empty icon Without an icon link the browser requests /favicon.ico, which 404s and fails noConsoleErrors. Phase 3 box 1 ticked with the run. --- docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md | 2 +- examples/abyss-framework/vq-assets/index.html | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md index 29a79f11e6..9f5e212acf 100644 --- a/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md +++ b/docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md @@ -52,7 +52,7 @@ The unit proof paths and the desktop fallback scenario now exist. Browser and An ### Phase 3 — Qualify the visible result -- [ ] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. +- [x] The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu`. **Result 2026-10-03:** `sh scripts/vq01-web-proof.sh` exit 0 on nvidia/turing WebGPU (headed, private Xvfb): 8/8 assertions incl. `diagnostics` (no console/network/runtime errors), loaded and textured models, decoded vertices, advancing frames and pose; the after-frame shows the Meshopt cube, Draco torus and KTX2 panel each with its authored checker. The first run failed `diagnostics` on one 404 — the new fixture page linked no icon, so the browser fetched `/favicon.ico`; fixed with an empty data-URI icon. Runner: `sh scripts/vq01-web-proof.sh ` generates the authored GLBs/PNG, cooks the fixture for web and serves it on `127.0.0.1:5193` for that command. Unrun here: this lane has no browser. - [ ] The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: `node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}`. diff --git a/examples/abyss-framework/vq-assets/index.html b/examples/abyss-framework/vq-assets/index.html index 8660e2a08e..d05ad3cc29 100644 --- a/examples/abyss-framework/vq-assets/index.html +++ b/examples/abyss-framework/vq-assets/index.html @@ -4,6 +4,7 @@ VQ-01 native asset fallback +