From 264da09876a48ff43f2f34e9f709dc8f6f1c60b7 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 12:42:26 -0700 Subject: [PATCH 01/57] feat(assets): cook standalone mobile textures to raw ASTC --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 21 ++-- packages/assets/__tests__/compile.spec.ts | 17 +-- .../assets/__tests__/image-quality.spec.ts | 86 +++++++++++++- .../assets/__tests__/texture-pass.spec.ts | 109 +++++++++++++++++- packages/assets/src/compile.ts | 18 ++- packages/assets/src/image-quality.ts | 82 ++++++++++++- packages/assets/src/passes/texture.ts | 15 ++- 7 files changed, 319 insertions(+), 29 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 5076a0c4dc..e3416c5689 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 -**Status:** NOT STARTED -**Priority:** P2 — Mobile ASTC cook, decoder-free admission and the emulator proof are all unbuilt. +**Status:** PARTIAL +**Priority:** P2 — Standalone mobile ASTC cooking is implemented; embedded/lightmap cooking, admission and native proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -28,7 +28,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Solution -1. **Cook (packages/assets, mechanism).** For `--target android|ios`, the texture pass transcodes each cooked Basis texture once, at build time in Node, to ASTC 4×4 blocks. It writes a KTX2 with the matching `vkFormat` (sRGB for colour, UNORM for data), the full mip chain and no supercompression. Basis already targets ASTC 4×4 (`TRANSCODE_TARGETS`), so the bytes match what V8 makes at runtime. The manifest records the format. +1. **Cook (packages/assets, mechanism).** For `--target android|ios`, the texture pass transcodes each cooked Basis texture once, at build time in Node, to ASTC 4×4 blocks. It writes a KTX2 with the matching `vkFormat` (sRGB for colour, UNORM for data), the full mip chain and no supercompression. The installed Basis transcoder supports direct ASTC 4×4 output for both codecs. V8 currently selects BC1/ETC2 for ETC1S, so this mobile output does not claim byte identity with that runtime selection. The manifest records the format. 2. **Admission (create-threenative).** The build gate separates Basis KTX2, which needs a decoder, from raw-block KTX2, which needs none. Raw ASTC is admitted on every mobile runtime. Basis KTX2 keeps today's refusal on QuickJS and JSC. 3. **Device check (fail closed).** At startup, a game whose package holds ASTC on an adapter without `texture-compression-astc` stops with a named error that names the first texture. No silent RGBA8 fallback is shipped. 4. **Block size.** Only 4×4, because ETC1S and UASTC transcode to 4×4 and to no larger block. Unreal's 6×6 size default would need a new encoder and is out of scope. @@ -36,9 +36,13 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Risks:** - **Package size.** Raw ASTC 4×4 is 8 bits per pixel before the APK's deflate. ETC1S Basis is far smaller. Phase 2 records the bytes and the APK size. -- **Quality.** ETC1S transcoded to ASTC keeps ETC1S quality. The PRD-351 compression floor still applies to the source. +- **Quality.** The existing PRD-351 ladder measures the Basis candidate before the offline transcode. ASTC rendering and final visual quality remain unverified until the native and visual proof lanes run. - **Emulator.** ASTC support on an emulator may differ from hardware (as recorded in PRD-097). The emulator proves the path. Only a device proves VRAM. +## Decisions + +- **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. @@ -60,14 +64,17 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Execution Phases #### Phase 1: Cook and admit raw ASTC -**Status:** NOT STARTED -**Files:** `packages/assets/src/passes/texture.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` +**Status:** PARTIAL +**Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` + Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, preserving the quality ladder, caps, codec opt-outs and target cache identity. Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The full assets suite, native bundling, emulator, timing, GPU and visual judge were not run. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host **Status:** NOT STARTED -**Files:** `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` +**Files:** `native-bundle.mjs` (generated native bundle: decoder-free raw KTX2 route), `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing + +**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, owner `/root/prd568_support`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active implementation/review. No push, merge or cleanup yet. diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 277b98118a..7d2e272229 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -194,11 +194,8 @@ describe("compileAssets", () => { ).toBe(true); }); - it("should drop compression for a platform that cannot decode it, and say which", async () => { - // Android and iOS run the native host without WebAssembly, so a `.ktx2` in the bundle is a - // black screen. Before this the author had to pin `textures: "none"` in the config to satisfy - // that one target, and the pin then followed their web build — a scaffolded game shipped - // 2,003 MB of uncompressed output for it. The build names the target; the engine decides. + it("should cook decoder-free ASTC on mobile while web keeps Basis", async () => { + // ASTC needs no runtime decoder; model Basis/Meshopt remain unavailable. const root = await makeTempDir("threenative-compile-platform-"); await mkdir(path.join(root, "assets")); const source = rgbaPng({ @@ -210,12 +207,17 @@ describe("compileAssets", () => { }); await writeFile(path.join(root, "assets", "rock.png"), source); - const mobile = await compileAssets({ cwd: root, platform: "android", transcoder: TRANSCODER }); + const mobile = await compileAssets({ + cwd: root, + platform: "android", + transcoder: TRANSCODER, + config: { textures: { floor: { ssim: 0, meanDeltaE00: 100 } } }, + }); const mobileManifest = JSON.parse( await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), ) as { entries: Record }; - expect(mobileManifest.entries["rock.png"]?.output).toMatch(/\.png$/u); + expect(mobileManifest.entries["rock.png"]?.output).toMatch(/\.ktx2$/u); expect(mobile.skippedCompression).toEqual([ { bytes: 0, @@ -224,7 +226,6 @@ describe("compileAssets", () => { kind: "model", reason: "platform", }, - { bytes: source.length, files: 1, kind: "texture", reason: "platform" }, ]); // The same config, the target that can decode it: compression ships. diff --git a/packages/assets/__tests__/image-quality.spec.ts b/packages/assets/__tests__/image-quality.spec.ts index 23eab86286..5f21556aaf 100644 --- a/packages/assets/__tests__/image-quality.spec.ts +++ b/packages/assets/__tests__/image-quality.spec.ts @@ -5,7 +5,7 @@ import { read as readKTX2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import { deltaE00, imageQuality, ssim } from "../src/image-quality.js"; -import { measureKtx2 } from "../src/image-quality.js"; +import { measureKtx2, transcodeKtx2ToAstc } from "../src/image-quality.js"; import { encodeToKTX2 } from "../src/ktx2-encoder.js"; import { decodeImageBytes } from "../src/passes/decode-image.js"; @@ -23,6 +23,90 @@ function pixels(noise = false): Uint8Array { } describe("image quality instrument", () => { + it.each([false, true])( + "transcodes every ETC1S/UASTC mip directly to raw ASTC (UASTC=%s)", + async (isUASTC) => { + const data = new Uint8Array(8 * 8 * 4); + for (let i = 0; i < 64; i += 1) + data.set(i % 8 < 4 ? [200, 130, 40, 255] : [20, 60, 220, 255], i * 4); + const encoded = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, height: 8, width: 8 }), + isUASTC, + isSetKTX2SRGBTransferFunc: !isUASTC, + }); + const paths = basisTranscoderPaths(); + const shim = { exports: {} as unknown }; + new Function( + "module", + "exports", + "require", + "__filename", + "__dirname", + await readFile(paths.javascriptPath, "utf8"), + )( + shim, + shim.exports, + createRequire(import.meta.url), + paths.javascriptPath, + path.dirname(paths.javascriptPath), + ); + const basis = await ( + shim.exports as (options: { wasmBinary: Uint8Array }) => Promise<{ + initializeBasis(): void; + transcoder_texture_format: { cTFASTC_4x4_RGBA: { value: number } }; + KTX2File: new ( + bytes: Uint8Array, + ) => { + isETC1S(): boolean; + startTranscoding(): boolean; + getLevels(): number; + getImageTranscodedSizeInBytes(...args: number[]): number; + transcodeImage(dst: Uint8Array, ...args: number[]): boolean; + close(): void; + delete(): void; + }; + }> + )({ wasmBinary: await readFile(paths.wasmPath) }); + basis.initializeBasis(); + const file = new basis.KTX2File(encoded); + try { + expect(Boolean(file.isETC1S())).toBe(!isUASTC); + expect(Boolean(file.startTranscoding())).toBe(true); + const format = basis.transcoder_texture_format.cTFASTC_4x4_RGBA.value; + expect(format).toBe(10); + const raw = readKTX2(await transcodeKtx2ToAstc(encoded)); + expect(raw.vkFormat).toBe(isUASTC ? 157 : 158); + expect(raw.supercompressionScheme).toBe(0); + expect(raw.globalData).toBeNull(); + expect(raw.levelCount).toBe(4); + expect(raw.levels.map((level) => level.levelData.byteLength)).toEqual([64, 16, 16, 16]); + for (let mip = 0; mip < file.getLevels(); mip += 1) { + const expected = new Uint8Array(file.getImageTranscodedSizeInBytes(mip, 0, 0, format)); + expect(Boolean(file.transcodeImage(expected, mip, 0, 0, format, 0, -1, -1))).toBe(true); + expect(expected.some((byte) => byte !== 0)).toBe(true); + expect(raw.levels[mip]?.levelData).toEqual(expected); + expect(raw.levels[mip]?.uncompressedByteLength).toBe(expected.byteLength); + } + expect(raw.dataFormatDescriptor[0]).toMatchObject({ + colorModel: 162, + texelBlockDimension: [3, 3, 0, 0], + bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0], + }); + } finally { + file.close(); + file.delete(); + } + }, + ); + it("rejects invalid or incomplete Basis input for the mobile cook", async () => { + await expect(transcodeKtx2ToAstc(new Uint8Array([0]))).rejects.toThrow(); + const data = new Uint8Array(8 * 8 * 4).fill(128); + const encoded = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 8, height: 8 }), + generateMipmap: false, + }); + await expect(transcodeKtx2ToAstc(encoded)).rejects.toThrow(/TN_ASSETS_ASTC_INPUT/); + }); it("encodes both RDO lambdas through the current vendored encoder with Zstd", async () => { const fixture = "examples/prd493-terrain-splat/public/world/terrain/tex/layer-00_diff.jpg"; const input = await readFile(new URL(`../../../${fixture}`, import.meta.url)); diff --git a/packages/assets/__tests__/texture-pass.spec.ts b/packages/assets/__tests__/texture-pass.spec.ts index 7de23298b9..c31e733904 100644 --- a/packages/assets/__tests__/texture-pass.spec.ts +++ b/packages/assets/__tests__/texture-pass.spec.ts @@ -59,6 +59,102 @@ function compressiblePng(alpha?: (x: number, y: number) => number): Buffer { } describe("the ktx2 texture pass", () => { + it.each(["android", "ios"] as const)( + "cooks raw ASTC on %s without admitting decoder-backed custom passes", + async (platform) => { + const root = await makeTempDir("threenative-mobile-astc-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "colour.png"), compressiblePng()); + await writeFile(path.join(root, "assets", "map_normal.png"), compressiblePng()); + const config = { + textures: { + maxSize: 32, + overrides: [ + { glob: "colour.png", codec: "etc1s" }, + { glob: "map_normal.png", codec: "uastc" }, + ], + floor: { ssim: 0, meanDeltaE00: 100 }, + }, + } satisfies IAssetSourceConfig; + const readOutputs = async () => { + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ) as { + entries: Record< + string, + { + output: string; + format: string; + transcodeTargets?: string[]; + quality: { codec: string }; + } + >; + }; + return Promise.all( + Object.values(manifest.entries).map(async (entry) => ({ + entry, + bytes: await readFile(path.join(root, "public", entry.output)), + })), + ); + }; + await compileAssets({ + cwd: root, + config, + platform: "web", + transcoder: TRANSCODER, + concurrency: 1, + }); + const web = await readOutputs(); + await compileAssets({ + cwd: root, + config, + platform: "desktop", + transcoder: TRANSCODER, + concurrency: 1, + }); + expect(await readOutputs()).toEqual(web); + const options = { + cwd: root, + config, + platform, + runtimeDecoders: { ktx2: false, meshopt: false }, + concurrency: 1, + }; + const mobile = await compileAssets(options); + expect(mobile.written).toBe(2); + const outputs = await readOutputs(); + expect(outputs.map(({ entry }) => entry.format)).toEqual(["astc4x4", "astc4x4"]); + for (const { entry, bytes } of outputs) { + const raw = readKTX2(bytes); + expect(raw.vkFormat).toBe(entry.quality.codec === "etc1s" ? 158 : 157); + expect(raw.supercompressionScheme).toBe(0); + expect([raw.pixelWidth, raw.pixelHeight, raw.levelCount]).toEqual([32, 32, 6]); + expect(entry.transcodeTargets).toBeUndefined(); + } + await expect( + stat(path.join(root, "public", "basis", "basis_transcoder.wasm")), + ).rejects.toThrow(); + expect((await compileAssets(options)).written).toBe(0); + expect(await readOutputs()).toEqual(outputs); + await compileAssets({ + cwd: root, + config, + platform: "web", + transcoder: TRANSCODER, + concurrency: 1, + }); + expect(await readOutputs()).toEqual(web); + const apply = vi.fn(async (input: Buffer) => input); + const custom = await compileAssets({ + ...options, + passes: [{ name: "basis-only", appliesTo: ["texture"], needsRuntimeDecoder: true, apply }], + }); + expect(apply).not.toHaveBeenCalled(); + expect(custom.skippedCompression).toContainEqual( + expect.objectContaining({ pass: "basis-only", reason: "platform" }), + ); + }, + ); it("preserves pure noise when every compressed rung falls below the quality floor", async () => { const channel = (x: number, y: number, shift: number): number => { let value = Math.imul(x + 1, 0x45d9f3b) ^ Math.imul(y + 1, 0x27d4eb2d); @@ -499,12 +595,13 @@ describe("the decoder-free size cap", () => { await writeFile(path.join(root, "assets", "decal.png"), alpha); await writeFile(path.join(root, "assets", "small.png"), small); - // Android is the decoder-free case: no WebAssembly, so the KTX2 pass drops and the cap is - // applied by the resize pass. Read back the emitted bytes, not the resolved config. + // A decoder-free desktop runtime retains the PNG resize path. Mobile cooks raw ASTC. + // Read back the emitted bytes, not the resolved config. await compileAssets({ config: { textures: { maxSize: 1024 } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }); const manifest = JSON.parse( @@ -567,7 +664,8 @@ describe("the decoder-free size cap", () => { await compileAssets({ config: { textures: { maxSize: 64, overrides: [{ codec: "none", glob: "ui/**" }] } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }); const manifest = JSON.parse( @@ -594,7 +692,8 @@ describe("the decoder-free size cap", () => { compileAssets({ config: { textures: { maxSize: 1024 } }, cwd: root, - platform: "android", + platform: "desktop", + runtimeDecoders: { ktx2: false, meshopt: false }, }), ).rejects.toThrow(/TN_ASSETS_TEXTURE_UNDECODABLE.*broken\.png/su); }); diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index 2b4327026c..e24b6a1cf0 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -1644,7 +1644,13 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }), ...embeddedTexturesFor(configuredModels.textures, runtimeDecoderCapabilities.ktx2), }; - const textures = configuredTextures; + const mobileAstc = options.platform === "android" || options.platform === "ios"; + const textures = + configuredTextures === undefined + ? undefined + : mobileAstc + ? { ...configuredTextures, astc: true } + : configuredTextures; const lightmap = (models as (IModelPassOptions & { lightmap?: ILightmapPassOptions }) | undefined) ?.lightmap; // The built-in registry runs only when the caller did not replace it wholesale; each @@ -1669,13 +1675,14 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo if (textures !== undefined) { registerBuiltin(texturePass(textures), { kind: "texture", - needsRuntimeDecoder: true, + needsRuntimeDecoder: !mobileAstc, options: textures, }); } if ( textures !== undefined && !runtimeDecoderCapabilities.ktx2 && + !mobileAstc && textures.maxSize !== undefined ) { // The KTX2 pass dropped for this target, but the declared cap is still honoured: the @@ -1780,11 +1787,14 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo ? undefined : configuredTextures === undefined ? "config" - : runtimeDecoderCapabilities.ktx2 + : runtimeDecoderCapabilities.ktx2 || mobileAstc ? undefined : "platform", texturesActive: - textures !== undefined && options.passes === undefined && runtimeDecoderAvailable, + textures !== undefined && + options.passes === undefined && + runtimeDecoderAvailable && + !mobileAstc, }; } diff --git a/packages/assets/src/image-quality.ts b/packages/assets/src/image-quality.ts index bc20a621c6..63efb63c95 100644 --- a/packages/assets/src/image-quality.ts +++ b/packages/assets/src/image-quality.ts @@ -2,6 +2,15 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + KHR_DF_MODEL_ASTC, + KHR_DF_TRANSFER_SRGB, + KHR_SUPERCOMPRESSION_NONE, + VK_FORMAT_ASTC_4x4_SRGB_BLOCK, + VK_FORMAT_ASTC_4x4_UNORM_BLOCK, + read as readKTX2, + write as writeKTX2, +} from "ktx-parse"; import { resolveBasisTranscoder } from "./compile.js"; // The sculpt MCP math is not present in this repository. Minimal SSIM port: Rec.709 @@ -290,6 +299,7 @@ interface IBasisFile { startTranscoding(): boolean; getWidth(): number; getHeight(): number; + getLevels(): number; getImageTranscodedSizeInBytes(mip: number, layer: number, face: number, format: number): number; transcodeImage( dst: Uint8Array, @@ -305,7 +315,10 @@ interface IBasisFile { interface IBasisModule { readonly KTX2File: new (bytes: Uint8Array) => IBasisFile; initializeBasis(): void; - readonly transcoder_texture_format: { readonly cTFRGBA32: { readonly value: number } }; + readonly transcoder_texture_format: { + readonly cTFRGBA32: { readonly value: number }; + readonly cTFASTC_4x4_RGBA: { readonly value: number }; + }; } let basisPromise: Promise | undefined; @@ -331,6 +344,73 @@ async function basisModule(): Promise { return basisPromise; } +/** Cook the measured Basis result to decoder-free ASTC without re-encoding its pixels. */ +export async function transcodeKtx2ToAstc(encoded: Uint8Array): Promise { + const container = readKTX2(encoded); + const descriptor = container.dataFormatDescriptor[0]; + if ( + container.vkFormat !== 0 || + container.pixelWidth <= 0 || + container.pixelHeight <= 0 || + container.pixelDepth !== 0 || + container.layerCount !== 0 || + container.faceCount !== 1 || + container.levelCount !== + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1 || + descriptor === undefined + ) + throw new Error("TN_ASSETS_ASTC_INPUT: expected a 2D Basis KTX2 with its full mip chain."); + const basis = await basisModule(); + const file = new basis.KTX2File(encoded); + try { + if (!file.isValid() || !file.startTranscoding() || file.getLevels() !== container.levelCount) + throw new Error("TN_ASSETS_ASTC_INPUT: the Basis transcoder rejected the cooked texture."); + const format = basis.transcoder_texture_format.cTFASTC_4x4_RGBA.value; + container.levels = container.levels.map((_, mip) => { + const width = Math.max(1, container.pixelWidth >> mip); + const height = Math.max(1, container.pixelHeight >> mip); + const bytes = Math.ceil(width / 4) * Math.ceil(height / 4) * 16; + const levelData = new Uint8Array(bytes); + if ( + file.getImageTranscodedSizeInBytes(mip, 0, 0, format) !== bytes || + !file.transcodeImage(levelData, mip, 0, 0, format, 0, -1, -1) + ) + throw new Error( + `TN_ASSETS_ASTC_TRANSCODE: mip ${String(mip)} could not transcode to ASTC 4x4.`, + ); + return { levelData, uncompressedByteLength: bytes }; + }); + container.vkFormat = + descriptor.transferFunction === KHR_DF_TRANSFER_SRGB + ? VK_FORMAT_ASTC_4x4_SRGB_BLOCK + : VK_FORMAT_ASTC_4x4_UNORM_BLOCK; + container.supercompressionScheme = KHR_SUPERCOMPRESSION_NONE; + container.globalData = null; + container.dataFormatDescriptor = [ + { + ...descriptor, + colorModel: KHR_DF_MODEL_ASTC, + texelBlockDimension: [3, 3, 0, 0], + bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0], + samples: [ + { + bitOffset: 0, + bitLength: 127, + channelType: 0, + samplePosition: [0, 0, 0, 0], + sampleLower: 0, + sampleUpper: 0xffffffff, + }, + ], + }, + ]; + return writeKTX2(container); + } finally { + file.close(); + file.delete(); + } +} + /** Decode only mip zero to RGBA32 using the shipped Basis transcoder; never re-encode. */ export async function measureKtx2( source: Uint8Array, diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index 488b4152bd..e8ab99f8fb 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -7,6 +7,7 @@ import { IMAGE_QUALITY_IDENTITY, imageQuality, resolveImageQualityFloor, + transcodeKtx2ToAstc, } from "../image-quality.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../ktx2-encoder.js"; import { decodeImageBytes } from "./decode-image.js"; @@ -54,6 +55,8 @@ export interface ITextureOverride { } export interface ITexturePassOptions { + /** Compiler-selected mobile output; the quality ladder still measures Basis first. */ + readonly astc?: boolean; readonly floor?: Partial; /** Internal reporting control; selection still measures and enforces the floor. */ readonly measureQuality?: boolean; @@ -109,6 +112,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { appliesTo: ["texture"], configuration: { encoder: KTX2_ENCODER_VERSION, + ...(options.astc === true ? { gpuFormat: "astc4x4-v1" } : {}), instrument: IMAGE_QUALITY_IDENTITY, floor: resolveImageQualityFloor(options.floor), keepSmallerSource: true, @@ -211,10 +215,15 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { ); } return { - buffer: Buffer.from(encoded), + buffer: Buffer.from(options.astc === true ? await transcodeKtx2ToAstc(encoded) : encoded), entry: { - format: selected.codec, - transcodeTargets: TRANSCODE_TARGETS[selected.codec as Exclude], + ...(options.astc === true + ? { format: "astc4x4" } + : { + format: selected.codec, + transcodeTargets: + TRANSCODE_TARGETS[selected.codec as Exclude], + }), ...(options.measureQuality === false ? {} : { From b7295132cf053e92d1d9bcbb916a0c3b8a12049f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 12:44:27 -0700 Subject: [PATCH 02/57] docs(prd568): name the native bundle source owner --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index e3416c5689..997b3b55ca 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -72,7 +72,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo #### Phase 2: Load and check on the host **Status:** NOT STARTED -**Files:** `native-bundle.mjs` (generated native bundle: decoder-free raw KTX2 route), `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` +**Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing From a84e30051c1235f4722426d4afc5b7858caa46c5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 13:46:13 -0700 Subject: [PATCH 03/57] test(scaffold): require raw ASTC mobile texture output --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 1 + .../__tests__/scaffold.spec.ts | 43 +++++++++++++------ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 997b3b55ca..9b1a1b6f90 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -42,6 +42,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions - **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. +- **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. ## Acceptance Criteria diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index e1ef4417c9..1f2556cd6f 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -16,6 +16,7 @@ import { import path from "node:path"; import { promisify } from "node:util"; import { compileAssets } from "@threenative/assets"; +import { read as readKTX2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; @@ -816,16 +817,8 @@ describe("create-threenative", () => { ); it("keeps the starter's shipped assets mobile-shippable", async () => { - // Mobile has no WebAssembly, so neither Basis-decoded textures nor Meshopt-decoded geometry - // can ship there — the red hit on 2026-08-27: `build:android` on a machine with the Basis - // encoder refused TN_NATIVE_KTX2_UNSUPPORTED on a starter scaffold that had built clean the - // week before, purely because the encoder got installed in between. - // - // The template used to pin `models`/`textures` to `"none"` to prevent it, which held for - // android and followed the same game onto web: one shipped 2,003 MB of manifest output with - // no compressed texture in it. The build names its `--target` and the compile step now drops - // the passes that target cannot decode, so this asserts what a mobile bake *produces* rather - // than what the config file says — and the web bake of the same config stays compressed. + // Decoder-free mobile cooking may emit raw ASTC, while Basis and Meshopt still need decoders. + // Check the produced KTX2 headers rather than treating every KTX2 as Basis. const root = await makeTempDir("threenative-scaffold-mobile-"); try { await mkdir(path.join(root, "assets"), { recursive: true }); @@ -849,14 +842,38 @@ describe("create-threenative", () => { }), ); - await compileAssets({ cwd: root, platform: "android" }); + await compileAssets({ + cwd: root, + platform: "android", + runtimeDecoders: { ktx2: false, meshopt: false }, + }); const android = JSON.parse( await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), - ) as { entries: Record }; + ) as { entries: Record }; const mobileOutputs = Object.values(android.entries); expect(mobileOutputs.length).toBeGreaterThan(0); + expect( + mobileOutputs.some((entry) => /\.ktx2$/u.test(entry.output) && entry.format === "astc4x4"), + ).toBe(true); for (const entry of mobileOutputs) { - expect(entry.output).not.toMatch(/\.ktx2$/u); + if (/\.ktx2$/u.test(entry.output)) { + const raw = readKTX2(await readFile(path.join(root, "public", entry.output))); + expect([157, 158]).toContain(raw.vkFormat); + expect(raw.supercompressionScheme).toBe(0); + expect(raw.globalData).toBeNull(); + const levels = Math.floor(Math.log2(Math.max(raw.pixelWidth, raw.pixelHeight))) + 1; + expect(raw.levelCount).toBe(levels); + expect(raw.levels).toHaveLength(levels); + for (const [mip, level] of raw.levels.entries()) { + const bytes = + Math.ceil(Math.max(1, raw.pixelWidth >> mip) / 4) * + Math.ceil(Math.max(1, raw.pixelHeight >> mip) / 4) * + 16; + expect(level.levelData.byteLength).toBe(bytes); + expect(level.uncompressedByteLength).toBe(bytes); + } + expect(entry.format).toBe("astc4x4"); + } expect(entry.extensions ?? []).not.toContain("EXT_meshopt_compression"); } From 18f624cf76afd912162fd725bf70a9525c3d8bf4 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 15:20:08 -0700 Subject: [PATCH 04/57] fix(assets): detect new undeclared bake outputs by path --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 4 +- .../assets/__tests__/bake-receipt.spec.ts | 50 +++++++++++-------- packages/assets/src/compile.ts | 30 ++++++----- 3 files changed, 49 insertions(+), 35 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 9b1a1b6f90..34006ecc96 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -43,6 +43,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. - **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. +- **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. The full assets suite remains at its earlier failed result; it was not rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. +- **2026-10-10, open ASTC limits:** `maxBytes` currently gates the Basis candidate's bytes, not the emitted raw ASTC bytes; the quality floor measures the Basis decode, not the shipped ASTC decode. Mobile explicit `runtimeDecoders` overrides and `codec: "none"` behavior remain unverified. The initial 12 focused tests do not establish preservation of final ASTC size caps, shipped quality or those override contracts; these claims remain open alongside embedded/shared textures, lightmaps, admission, native and visual proof. ## Acceptance Criteria @@ -68,7 +70,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, preserving the quality ladder, caps, codec opt-outs and target cache identity. Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The full assets suite, native bundling, emulator, timing, GPU and visual judge were not run. + Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, retaining the Basis selection flow and target cache identity; final ASTC size caps, shipped quality and mobile override contracts remain open (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; its subsequent failure and the bounded receipt fix are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host diff --git a/packages/assets/__tests__/bake-receipt.spec.ts b/packages/assets/__tests__/bake-receipt.spec.ts index ad54e10dcb..ac40fcef08 100644 --- a/packages/assets/__tests__/bake-receipt.spec.ts +++ b/packages/assets/__tests__/bake-receipt.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdir, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; @@ -278,28 +278,34 @@ describe("bake receipt", () => { ); }); - it("should throw when a pass writes a file under the output root it did not declare", async () => { - const root = await makeTempDir("threenative-receipt-stray-"); - await mkdir(path.join(root, "assets")); - await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); - - await expect( - compileAssets({ - cwd: root, - transcoder: TRANSCODER, - passes: [ - { - name: "stray-writer", - apply: async (input: Buffer) => { - await mkdir(path.join(root, "public"), { recursive: true }); - await writeFile(path.join(root, "public", "stray.bin"), "undeclared"); - return input; + it.each([false, true])( + "should throw when a pass writes a file under the output root it did not declare (preserved old timestamp: %s)", + async (preserveTimestamp) => { + const root = await makeTempDir("threenative-receipt-stray-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ height: 32, width: 32 })); + + await expect( + compileAssets({ + cwd: root, + transcoder: TRANSCODER, + passes: [ + { + name: "stray-writer", + apply: async (input: Buffer) => { + await mkdir(path.join(root, "public"), { recursive: true }); + const strayPath = path.join(root, "public", "stray.bin"); + await writeFile(strayPath, "undeclared"); + // A copied output can retain its source's timestamp despite being new to public/. + if (preserveTimestamp) await utimes(strayPath, new Date(0), new Date(0)); + return input; + }, }, - }, - ], - }), - ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); - }); + ], + }), + ).rejects.toThrow(/TN_ASSETS_UNDECLARED_OUTPUT.*stray\.bin/su); + }, + ); it("should leave a static file the project authored alone", async () => { const root = await makeTempDir("threenative-receipt-static-"); diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index e24b6a1cf0..f6e51a95d9 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -2262,8 +2262,9 @@ async function walkOutputFiles(root: string, prefix = ""): Promise { let entries: Dirent[]; try { entries = await readdir(path.join(root, prefix), { withFileTypes: true }); - } catch { - return []; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; } const files: string[] = []; for (const entry of entries) { @@ -2279,14 +2280,16 @@ async function walkOutputFiles(root: string, prefix = ""): Promise { * * Without this, a pass that writes a file it does not report is invisible: the delete-test would * delete less than the bake produced, the second run would still find the undeleted file, and the - * gate would pass while proving nothing. Only files touched during this run are considered — a - * project's own hand-authored `public/icon.png` is not a bake output and is left alone. + * gate would pass while proving nothing. New paths are checked regardless of their timestamps; + * existing paths are checked when touched during this run. An untouched hand-authored + * `public/icon.png` is not a bake output and is left alone. */ async function assertNoUndeclaredOutputs( outputRoot: string, declared: ReadonlySet, since: number, writtenBefore: ReadonlySet, + existingBefore: ReadonlySet, ): Promise { const undeclared: string[] = []; for (const relative of await walkOutputFiles(outputRoot)) { @@ -2302,7 +2305,7 @@ async function assertNoUndeclaredOutputs( // millisecond apart, and the previous bake's now-stale output is not this bake's leak. if (writtenBefore.has(relative)) continue; const info = await stat(path.join(outputRoot, relative)); - if (info.mtimeMs + 1 >= since) undeclared.push(relative); + if (!existingBefore.has(relative) || info.mtimeMs + 1 >= since) undeclared.push(relative); } if (undeclared.length > 0) { throw new Error( @@ -2410,13 +2413,10 @@ export async function compileAssets( } const previous = await readExistingManifest(manifestPath); - // Read before the first write *and before the source walk*, so the undeclared-output guard can - // tell this run's files from the project's own static ones. Taking it after the walk shrinks the - // margin by however long the walk took, and `TN_ASSETS_UNDECLARED_OUTPUT` then misses a stray - // file whose mtime the filesystem truncated below it — which is exactly how - // `bake-receipt.spec.ts` went red on a loaded CI shard while passing locally. It never reaches - // the receipt: that stays deterministic. + // Snapshot before the first write and source walk: new outputs can retain old timestamps when + // copied, so modification time alone cannot distinguish them from authored static files. const runStart = Date.now(); + const existingBefore = new Set(await walkOutputFiles(layout.outputRoot)); const sources = await walkSources(layout.sourceRoot); const excluded = sources.filter((logical) => @@ -3038,7 +3038,13 @@ export async function compileAssets( ); } const currentReceiptPaths = new Set(receiptOutputs.map((output) => output.path)); - await assertNoUndeclaredOutputs(layout.outputRoot, currentReceiptPaths, runStart, writtenBefore); + await assertNoUndeclaredOutputs( + layout.outputRoot, + currentReceiptPaths, + runStart, + writtenBefore, + existingBefore, + ); const staleOutputs = await resolveStaleReceiptOutputs( layout.outputRoot, new Set([...writtenBefore, ...pendingPaths]), From 90383cb3de930f0cc2a848728c482d45405a2614 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 15:42:54 -0700 Subject: [PATCH 05/57] test(assets): capture compiler child exit diagnostics --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 1 + packages/assets/__tests__/worker-pool.spec.ts | 46 +++++++++++++++---- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 34006ecc96..ed31942ac5 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -45,6 +45,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. - **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. The full assets suite remains at its earlier failed result; it was not rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. - **2026-10-10, open ASTC limits:** `maxBytes` currently gates the Basis candidate's bytes, not the emitted raw ASTC bytes; the quality floor measures the Basis decode, not the shipped ASTC decode. Mobile explicit `runtimeDecoders` overrides and `codec: "none"` behavior remain unverified. The initial 12 focused tests do not establish preservation of final ASTC size caps, shipped quality or those override contracts; these claims remain open alongside embedded/shared textures, lightmaps, admission, native and visual proof. +- **2026-10-10, worker-exit diagnostics:** only the disabled-pass compiler test's child collector changed: it now waits for stdio `close`, records code/signal, whether its own timer fired, PID and monotonic elapsed time, and includes that data plus child startup/import/compile markers in assertion failure messages. Spawn errors reject and clear the timer. The existing 5 s child-kill and 10 s test budgets remain unchanged. On the original Debian laptop with Node 20.19.6 (status: 58 °C/load 0.28 before isolation; 57 °C/load 0.14 before the nearest file), one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts -t "exits the real compiler with all passes disabled and no workers" --silent` passed (1 passed, 5 skipped, exit 0, 509 ms), then one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts --silent` passed (6 passed, exit 0, 1.62 s). The first case asserted code 0, no signal and no timer firing in both runs. Assets typecheck (including tests), focused Biome and `git diff --check` passed. Neither run reproduced the earlier empty-output failure, so its cause and the separate unexpected Vitest worker exit remain unproven; other child collectors were not changed. No full assets suite, timeout relaxation, build or ASTC source change was performed. All phase and acceptance boxes remain open at 0%, including the final ASTC size, quality and override limits above. ## Acceptance Criteria diff --git a/packages/assets/__tests__/worker-pool.spec.ts b/packages/assets/__tests__/worker-pool.spec.ts index a271fd74e9..a82b35353f 100644 --- a/packages/assets/__tests__/worker-pool.spec.ts +++ b/packages/assets/__tests__/worker-pool.spec.ts @@ -14,12 +14,25 @@ describe("asset worker pool lifetime", () => { await mkdir(path.join(root, "assets")); await writeFile(path.join(root, "assets/proof.txt"), "unchanged"); const moduleUrl = pathToFileURL(path.resolve("packages/assets/src/compile.ts")).href; - const script = `const {compileAssets} = await import(${JSON.stringify(moduleUrl)}); + const script = `const started = performance.now(); + const mark = (phase) => console.error("TN_ASSETS_WORKER_STAGE " + JSON.stringify({phase, elapsedMs:performance.now()-started})); + mark("startup"); + const {compileAssets} = await import(${JSON.stringify(moduleUrl)}); + mark("imported"); const result = await compileAssets({cwd:${JSON.stringify(root)}, concurrency:2, config:{audio:"none",models:"none",textures:"none"}}); + mark("compiled"); if(result.concurrencyUsed!==1 || result.passCosts.length!==0) throw new Error("unexpected worker/pass"); console.log("empty-chain-exited");`; - const result = await new Promise<{ output: string; code: number | null }>((resolve, reject) => { + const result = await new Promise<{ + output: string; + code: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + pid: number | undefined; + elapsedMs: number; + }>((resolve, reject) => { + const started = performance.now(); const child = spawn(process.execPath, [ "--import", "tsx", @@ -28,21 +41,38 @@ describe("asset worker pool lifetime", () => { script, ]); let output = ""; + let timedOut = false; child.stdout.on("data", (chunk) => { output += String(chunk); }); child.stderr.on("data", (chunk) => { output += String(chunk); }); - child.once("error", reject); - const timer = setTimeout(() => child.kill("SIGKILL"), 5000); - child.once("exit", (code) => { + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, 5000); + child.once("close", (code, signal) => { clearTimeout(timer); - resolve({ output, code }); + resolve({ + output, + code, + signal, + timedOut, + pid: child.pid, + elapsedMs: performance.now() - started, + }); }); }); - expect(result.output).toContain("empty-chain-exited"); - expect(result.code).toBe(0); + const diagnostic = JSON.stringify(result); + expect(result.output, diagnostic).toContain("empty-chain-exited"); + expect(result.code, diagnostic).toBe(0); + expect(result.signal, diagnostic).toBeNull(); + expect(result.timedOut, diagnostic).toBe(false); }, 10000); it("terminates every worker when disposed", async () => { From ac870d5db43871724f1b62727cdd9f81a5c758ab Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 15:49:19 -0700 Subject: [PATCH 06/57] docs(prd568): record passing full assets rerun --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index ed31942ac5..051659b76a 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -43,9 +43,11 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. - **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. -- **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. The full assets suite remains at its earlier failed result; it was not rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. +- **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. At this receipt-only checkpoint, the full assets suite was still at its earlier failed result; it had not been rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. - **2026-10-10, open ASTC limits:** `maxBytes` currently gates the Basis candidate's bytes, not the emitted raw ASTC bytes; the quality floor measures the Basis decode, not the shipped ASTC decode. Mobile explicit `runtimeDecoders` overrides and `codec: "none"` behavior remain unverified. The initial 12 focused tests do not establish preservation of final ASTC size caps, shipped quality or those override contracts; these claims remain open alongside embedded/shared textures, lightmaps, admission, native and visual proof. -- **2026-10-10, worker-exit diagnostics:** only the disabled-pass compiler test's child collector changed: it now waits for stdio `close`, records code/signal, whether its own timer fired, PID and monotonic elapsed time, and includes that data plus child startup/import/compile markers in assertion failure messages. Spawn errors reject and clear the timer. The existing 5 s child-kill and 10 s test budgets remain unchanged. On the original Debian laptop with Node 20.19.6 (status: 58 °C/load 0.28 before isolation; 57 °C/load 0.14 before the nearest file), one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts -t "exits the real compiler with all passes disabled and no workers" --silent` passed (1 passed, 5 skipped, exit 0, 509 ms), then one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts --silent` passed (6 passed, exit 0, 1.62 s). The first case asserted code 0, no signal and no timer firing in both runs. Assets typecheck (including tests), focused Biome and `git diff --check` passed. Neither run reproduced the earlier empty-output failure, so its cause and the separate unexpected Vitest worker exit remain unproven; other child collectors were not changed. No full assets suite, timeout relaxation, build or ASTC source change was performed. All phase and acceptance boxes remain open at 0%, including the final ASTC size, quality and override limits above. +- **2026-10-10, worker-exit diagnostics:** only the disabled-pass compiler test's child collector changed: it now waits for stdio `close`, records code/signal, whether its own timer fired, PID and monotonic elapsed time, and includes that data plus child startup/import/compile markers in assertion failure messages. Spawn errors reject and clear the timer. The existing 5 s child-kill and 10 s test budgets remain unchanged. On the original Debian laptop with Node 20.19.6 (status: 58 °C/load 0.28 before isolation; 57 °C/load 0.14 before the nearest file), one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts -t "exits the real compiler with all passes disabled and no workers" --silent` passed (1 passed, 5 skipped, exit 0, 509 ms), then one `pnpm exec vitest run packages/assets/__tests__/worker-pool.spec.ts --silent` passed (6 passed, exit 0, 1.62 s). The first case asserted code 0, no signal and no timer firing in both runs. Assets typecheck (including tests), focused Biome and `git diff --check` passed. Neither run reproduced the earlier empty-output failure, so its cause and the separate unexpected Vitest worker exit remain unproven; other child collectors were not changed. This diagnostic component performed no full assets suite, timeout relaxation, build or ASTC source change. All phase and acceptance boxes remain open at 0%, including the final ASTC size, quality and override limits above. + + Full assets follow-up at committed source `90383cb3de930f0cc2a848728c482d45405a2614`: one `pnpm exec vitest run packages/assets/__tests__ --silent` on the original Debian laptop/Node 20.19.6 passed (39 files passed, 1 file skipped; 566 tests passed, 6 skipped; exit 0, 255.29 s). Existing test parallelism and budgets were unchanged; a 900 s supervisor deadline bounded the job, and inherited `NODE_OPTIONS` were preserved while appending `--report-on-fatalerror` and an ignored report directory. No unexpected Vitest worker exit or fatal-error report occurred; the instrumented compiler-child case passed its code-0/no-signal/no-own-timeout assertions. Saved before/after resources and cgroup memory events show `oom`/`oom_kill`/`oom_group_kill` stayed at 0. Read-only kernel captures were unavailable (`dmesg`: operation not permitted); no OOM or original-failure cause is inferred. Laptop status was 62 °C/load 0.51 before the job and 74 °C/load 49.24 after completion. Logs remain in ignored `artifacts/prd568-bootstrap/assets-rerun/` and the adjacent supervisor/status logs. This supersedes the earlier failed full-suite result; it does not establish the cause of that worker exit or qualify final ASTC byte caps, shipped quality, mobile overrides/codec-none, complete cook coverage, admission, native or visual behavior. All phase and acceptance boxes remain open at 0%. ## Acceptance Criteria @@ -71,7 +73,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, retaining the Basis selection flow and target cache identity; final ASTC size caps, shipped quality and mobile override contracts remain open (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; its subsequent failure and the bounded receipt fix are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. + Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, retaining the Basis selection flow and target cache identity; final ASTC size caps, shipped quality and mobile override contracts remain open (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host From fbb7c87d16e1488bb23415039ca07309e6cd959e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 15:54:50 -0700 Subject: [PATCH 07/57] docs(prd568): record draft delivery and compiled caller proof --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 051659b76a..6cc5b06da5 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -49,6 +49,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo Full assets follow-up at committed source `90383cb3de930f0cc2a848728c482d45405a2614`: one `pnpm exec vitest run packages/assets/__tests__ --silent` on the original Debian laptop/Node 20.19.6 passed (39 files passed, 1 file skipped; 566 tests passed, 6 skipped; exit 0, 255.29 s). Existing test parallelism and budgets were unchanged; a 900 s supervisor deadline bounded the job, and inherited `NODE_OPTIONS` were preserved while appending `--report-on-fatalerror` and an ignored report directory. No unexpected Vitest worker exit or fatal-error report occurred; the instrumented compiler-child case passed its code-0/no-signal/no-own-timeout assertions. Saved before/after resources and cgroup memory events show `oom`/`oom_kill`/`oom_group_kill` stayed at 0. Read-only kernel captures were unavailable (`dmesg`: operation not permitted); no OOM or original-failure cause is inferred. Laptop status was 62 °C/load 0.51 before the job and 74 °C/load 49.24 after completion. Logs remain in ignored `artifacts/prd568-bootstrap/assets-rerun/` and the adjacent supervisor/status logs. This supersedes the earlier failed full-suite result; it does not establish the cause of that worker exit or qualify final ASTC byte caps, shipped quality, mobile overrides/codec-none, complete cook coverage, admission, native or visual behavior. All phase and acceptance boxes remain open at 0%. + Draft delivery follow-up: at source `ac870d5db43871724f1b62727cdd9f81a5c758ab`, fresh `pnpm --filter @threenative/assets build` and `pnpm --filter create-threenative build` succeeded, followed by `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` (1 passed, 65 skipped, exit 0, 3.07 s), under one 240 s supervisor on the original Debian laptop. These exact-head compiled outputs were reused for the normal branch push; its unbypassed `pnpm ci:fast` passed agents/lint/docs/drift. Remote branch head is `ac870d5db43871724f1b62727cdd9f81a5c758ab`; [draft PR #488](https://github.com/ThreeNativeHQ/threenative-engine/pull/488) targets `develop` with `prd:0%`, after checking both the branch and existing PRD568 titles/bodies for duplicates. The PR body comes from `prd-manager` plus actual partial-scope validation and open limits. Ignored delivery logs retain the commands, source SHA and fresh bundle hashes. No CI, native or release proof is inferred from the draft or drift hook. Final ASTC byte caps, shipped quality, mobile overrides/codec-none, embedded/shared textures, lightmaps, admission, native and visual proof stay open; no phase or acceptance box is ticked. This delivery note is held locally for a future code push; it does not trigger a second docs-only push. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. From e2edca88f483128ff873d0385a26610f5f682914 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 17:46:05 -0700 Subject: [PATCH 08/57] fix(assets): admit final ASTC bytes and honor KTX2 capabilities --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 8 +- packages/assets/__tests__/compile.spec.ts | 107 ++++++++++++++++++ .../assets/__tests__/texture-pass.spec.ts | 48 ++++++++ packages/assets/src/compile.ts | 12 +- packages/assets/src/passes/model-textures.ts | 11 +- packages/assets/src/passes/texture.ts | 6 +- 6 files changed, 182 insertions(+), 10 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 6cc5b06da5..4efb436ff5 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -51,6 +51,12 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo Draft delivery follow-up: at source `ac870d5db43871724f1b62727cdd9f81a5c758ab`, fresh `pnpm --filter @threenative/assets build` and `pnpm --filter create-threenative build` succeeded, followed by `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` (1 passed, 65 skipped, exit 0, 3.07 s), under one 240 s supervisor on the original Debian laptop. These exact-head compiled outputs were reused for the normal branch push; its unbypassed `pnpm ci:fast` passed agents/lint/docs/drift. Remote branch head is `ac870d5db43871724f1b62727cdd9f81a5c758ab`; [draft PR #488](https://github.com/ThreeNativeHQ/threenative-engine/pull/488) targets `develop` with `prd:0%`, after checking both the branch and existing PRD568 titles/bodies for duplicates. The PR body comes from `prd-manager` plus actual partial-scope validation and open limits. Ignored delivery logs retain the commands, source SHA and fresh bundle hashes. No CI, native or release proof is inferred from the draft or drift hook. Final ASTC byte caps, shipped quality, mobile overrides/codec-none, embedded/shared textures, lightmaps, admission, native and visual proof stay open; no phase or acceptance box is ticked. This delivery note is held locally for a future code push; it does not trigger a second docs-only push. +- **2026-10-10, final ASTC byte admission only:** the shared texture ladder now transcodes each mobile candidate before comparing `maxBytes` or the smallest eligible candidate, and the standalone pass compares and writes that same final buffer without a second transcode. The ASTC cache identity advances to `astc4x4-v2`; non-ASTC callers retain their existing buffer and quality path. Two real-encoder regressions establish Basis < source < ASTC source retention and rejection when only the final ASTC exceeds `maxBytes`; the latter also checks admission above the final byte count and first-candidate retention when final ASTC sizes tie. `pnpm exec vitest run packages/assets/__tests__/texture-pass.spec.ts -t 'Basis saves bytes|ASTC exceeds maxBytes' --silent` first failed both cases (exit 1), then passed both (27 skipped, exit 0, 1.54 s). With the admission/tie assertions included, `pnpm exec vitest run packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/model-texture-pass.spec.ts --silent` passed (3 files, 87 tests passed, 1 skipped; exit 0, 113.83 s). Assets typecheck and focused Biome passed; Biome reported six complexity warnings. All phase and acceptance boxes remain open at 0%. + + **Still OPEN:** the quality floor continues measuring the Basis intermediate and does not qualify shipped ASTC quality; explicit mobile decoder-override semantics are unchanged and unverified. No codec-none behavior changed. Embedded/shared textures, lightmaps, packaging admission, native and visual proof remain open. This component ran no full build, install, GPU/browser lane, model retry, commit, push or GitHub mutation. + +- **2026-10-10, standalone decoder-map admission:** mobile selects raw ASTC only when KTX2 capability is absent; an explicit KTX2-capable map selects Basis independently of Meshopt. Only the built-in standalone texture registration gate and `texturesActive` use KTX2 capability; `needsRuntimeDecoder: !mobileAstc` remains truthful, while model, lightmap and decoder-dependent custom passes retain their existing combined KTX2/Meshopt gate. The same standalone gate now admits web/desktop `{ktx2: true, meshopt: false}` rather than silently omitting compression. Tests exercise all four maps on Android/iOS, the partial map on web/desktop, actual non-empty Basis JS/WASM outputs versus no transcoder for ASTC, all four custom-pass maps, and both mobile `codec: "none"`/`textures: "none"` controls retaining authored bytes with default and explicit capabilities. After narrowing an initial test assertion to exclude unrelated model-config reporting, `pnpm exec vitest run packages/assets/__tests__/compile.spec.ts -t 'standalone decoder capabilities|custom decoder contract|none kill switch' --silent` reproduced six format/admission failures (12 passed, 46 skipped; exit 1, 81.96 s), then passed all 18 cases (46 skipped; exit 0, 91.83 s). The complete nearest file, `pnpm exec vitest run packages/assets/__tests__/compile.spec.ts --silent`, passed all 64 tests (exit 0, 206.68 s), including existing cache, model, lightmap and custom-pass cases. Assets typecheck and focused Biome passed; Biome reported 14 warnings. All phase and acceptance boxes remain open at 0%; final ASTC decoded quality remains OPEN, and this component makes no native, visual or packaging-admission claim. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. @@ -75,7 +81,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone Android/iOS output now transcodes the selected Basis result to raw ASTC, retaining the Basis selection flow and target cache identity; final ASTC size caps, shipped quality and mobile override contracts remain open (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. + Partial: standalone Android/iOS byte admission now compares final raw ASTC buffers for maxBytes, smallest candidate and source retention, with a new ASTC cache identity (see Decisions). Quality still measures the Basis intermediate, so shipped ASTC quality remains OPEN. Explicit standalone decoder maps and mobile none controls now have focused compile coverage (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 7d2e272229..fc77ec1e19 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -243,6 +243,113 @@ describe("compileAssets", () => { expect(webManifest.entries["rock.png"]?.output).toMatch(/\.ktx2$/u); }); + it.each([ + ["android", true, true], + ["android", true, false], + ["android", false, true], + ["android", false, false], + ["ios", true, true], + ["ios", true, false], + ["ios", false, true], + ["ios", false, false], + ["web", true, false], + ["desktop", true, false], + ] as const)( + "honours standalone decoder capabilities on %s with ktx2=%s meshopt=%s", + async (platform, ktx2, meshopt) => { + const root = await makeTempDir("threenative-texture-decoder-map-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "rock.png"), rgbaPng({ width: 16, height: 16 })); + const result = await compileAssets({ + cwd: root, + platform, + runtimeDecoders: { ktx2, meshopt }, + transcoder: TRANSCODER, + config: { + models: "none", + textures: { + floor: { ssim: 0, meanDeltaE00: 100 }, + overrides: [{ glob: "rock.png", codec: "etc1s" }], + }, + }, + }); + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ) as { entries: Record }; + const entry = manifest.entries["rock.png"]; + if (entry === undefined) throw new Error("fixture texture is missing from the manifest"); + const container = readKtx2(await readFile(path.join(root, "public", entry.output))); + expect(container.vkFormat).toBe(ktx2 ? 0 : 158); + expect(entry.format).toBe(ktx2 ? "etc1s" : "astc4x4"); + expect(container.levelCount).toBeGreaterThan(1); + expect(result.skippedCompression.filter((row) => row.kind === "texture")).toEqual([]); + for (const name of ["basis_transcoder.js", "basis_transcoder.wasm"]) { + const output = path.join(root, "public", "basis", name); + if (ktx2) expect((await stat(output)).size).toBeGreaterThan(0); + else await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" }); + } + }, + ); + + it.each([ + [true, true], + [true, false], + [false, true], + [false, false], + ] as const)( + "retains the custom decoder contract with ktx2=%s meshopt=%s", + async (ktx2, meshopt) => { + const root = await makeTempDir("threenative-custom-decoder-map-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "rock.txt"), "asset"); + const apply = vi.fn((input: Buffer) => input); + const result = await compileAssets({ + cwd: root, + platform: "android", + runtimeDecoders: { ktx2, meshopt }, + passes: [{ name: "decoder-dependent", needsRuntimeDecoder: true, apply }], + }); + expect(apply).toHaveBeenCalledTimes(ktx2 && meshopt ? 1 : 0); + expect(result.skippedCompression).toEqual( + ktx2 && meshopt ? [] : [{ kind: "pass", pass: "decoder-dependent", reason: "platform" }], + ); + }, + ); + + it.each([ + ["android", "codec"], + ["android", "textures"], + ["ios", "codec"], + ["ios", "textures"], + ] as const)("keeps the mobile %s %s-none kill switch", async (platform, disabled) => { + const root = await makeTempDir("threenative-mobile-texture-none-"); + await mkdir(path.join(root, "assets")); + const source = rgbaPng({ width: 16, height: 16 }); + await writeFile(path.join(root, "assets", "rock.png"), source); + for (const runtimeDecoders of [undefined, { ktx2: true, meshopt: false }]) { + await compileAssets({ + cwd: root, + platform, + runtimeDecoders, + transcoder: TRANSCODER, + config: { + models: "none", + textures: + disabled === "textures" + ? "none" + : { maxSize: 4, overrides: [{ glob: "rock.png", codec: "none" }] }, + }, + }); + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ) as { entries: Record }; + const entry = manifest.entries["rock.png"]; + if (entry === undefined) throw new Error("fixture texture is missing from the manifest"); + expect(entry.output).toMatch(/\.png$/u); + expect(await readFile(path.join(root, "public", entry.output))).toEqual(source); + } + }); + it("should let a caller that probed the runtime drop decoders on desktop too", async () => { // A desktop host is V8 except on the Linux arm64 lane, which builds QuickJS over wgpu-native. // `platform: "desktop"` alone would keep meshopt/KTX2 and ship an asset the runtime cannot diff --git a/packages/assets/__tests__/texture-pass.spec.ts b/packages/assets/__tests__/texture-pass.spec.ts index c31e733904..a7f6679d12 100644 --- a/packages/assets/__tests__/texture-pass.spec.ts +++ b/packages/assets/__tests__/texture-pass.spec.ts @@ -6,8 +6,10 @@ import { describe, expect, it, vi } from "vitest"; import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; +import { transcodeKtx2ToAstc } from "../src/image-quality.js"; import { type IAssetSourceConfig, compileAssets, texturePass } from "../src/index.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../src/ktx2-encoder.js"; +import { encodeTextureLadder } from "../src/passes/model-textures.js"; import { parsePng } from "../src/png.js"; const TRANSCODER = basisTranscoderPaths(); @@ -59,6 +61,52 @@ function compressiblePng(alpha?: (x: number, y: number) => number): Buffer { } describe("the ktx2 texture pass", () => { + it("retains the source when Basis saves bytes but emitted ASTC would grow it", async () => { + const input = compressiblePng(); + const floor = { ssim: 0, meanDeltaE00: 100 }; + const basis = await texturePass({ + floor, + overrides: [{ glob: "colour.png", codec: "etc1s" }], + }).apply(input, "colour.png"); + if (Buffer.isBuffer(basis)) throw new Error("fixture was not encoded"); + const astc = await transcodeKtx2ToAstc(basis.buffer); + expect(basis.buffer.byteLength).toBeLessThan(input.byteLength); + expect(astc.byteLength).toBeGreaterThan(input.byteLength); + + const result = await texturePass({ astc: true, floor }).apply(input, "colour.png"); + if (Buffer.isBuffer(result)) throw new Error("fixture has no pass metadata"); + expect(result.buffer).toEqual(input); + expect(result.entry).toMatchObject({ format: "none", compressionSkipped: "not-smaller" }); + }); + + it("rejects ladder candidates whose emitted ASTC exceeds maxBytes", async () => { + const source = PNG.sync.read(compressiblePng()); + const options = { floor: { ssim: 0, meanDeltaE00: 100 }, srgb: true, quality: 150 }; + const basis = await encodeTextureLadder(source.data, source.width, source.height, options); + if (basis.encoded === undefined) throw new Error("fixture was not encoded"); + const astc = await transcodeKtx2ToAstc(basis.encoded); + const maxBytes = Math.floor((basis.encoded.byteLength + astc.byteLength) / 2); + expect(basis.encoded.byteLength).toBeLessThan(maxBytes); + expect(astc.byteLength).toBeGreaterThan(maxBytes); + + const admitted = await encodeTextureLadder(source.data, source.width, source.height, { + ...options, + astc: true, + maxBytes: astc.byteLength + 1, + }); + expect(admitted.encoded?.byteLength).toBe(astc.byteLength); + expect(admitted.codec).toBe("etc1s"); + expect(readKTX2(admitted.encoded as Uint8Array).vkFormat).toBe(158); + + const result = await encodeTextureLadder(source.data, source.width, source.height, { + ...options, + astc: true, + maxBytes, + }); + expect(result.encoded).toBeUndefined(); + expect(result.quality.compressionSkipped).toBe("not-smaller"); + }); + it.each(["android", "ios"] as const)( "cooks raw ASTC on %s without admitting decoder-backed custom passes", async (platform) => { diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index f6e51a95d9..d7216f1185 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -1644,7 +1644,9 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }), ...embeddedTexturesFor(configuredModels.textures, runtimeDecoderCapabilities.ktx2), }; - const mobileAstc = options.platform === "android" || options.platform === "ios"; + const mobileAstc = + (options.platform === "android" || options.platform === "ios") && + !runtimeDecoderCapabilities.ktx2; const textures = configuredTextures === undefined ? undefined @@ -1660,7 +1662,11 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo const builtinPasses: IAssetPass[] = []; const passSpecs: PassSpec[] = []; const registerBuiltin = (pass: IAssetPass, spec: PassSpec): void => { - if (spec.needsRuntimeDecoder === true && !runtimeDecoderAvailable) return; + if ( + spec.needsRuntimeDecoder === true && + !(spec.kind === "texture" ? runtimeDecoderCapabilities.ktx2 : runtimeDecoderAvailable) + ) + return; builtinPasses.push(pass); passSpecs.push(spec); }; @@ -1793,7 +1799,7 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo texturesActive: textures !== undefined && options.passes === undefined && - runtimeDecoderAvailable && + runtimeDecoderCapabilities.ktx2 && !mobileAstc, }; } diff --git a/packages/assets/src/passes/model-textures.ts b/packages/assets/src/passes/model-textures.ts index c87b17fe2c..1831461005 100644 --- a/packages/assets/src/passes/model-textures.ts +++ b/packages/assets/src/passes/model-textures.ts @@ -11,6 +11,7 @@ import { type ITextureQuality, imageQuality, measureKtx2, + transcodeKtx2ToAstc, } from "../image-quality.js"; import { encodeToKTX2 } from "../ktx2-encoder.js"; import { decodeImageBytes } from "./decode-image.js"; @@ -442,6 +443,7 @@ export async function encodeTextureLadder( width: number, height: number, options: IImageQualityOptions & { + readonly astc?: boolean; readonly forced?: TextureCodec; /** Automatic source retention: candidates that cannot save bytes need no quality score. */ readonly maxBytes?: number; @@ -466,7 +468,7 @@ export async function encodeTextureLadder( : candidate.rdoLambda === undefined ? candidate.codec : `uastc+rdo λ${candidate.rdoLambda} +zstd`; - const encoded = + const basis = candidate.codec === "none" ? undefined : await encodeToKTX2(new Uint8Array([0]), { @@ -480,6 +482,8 @@ export async function encodeTextureLadder( needSupercompression: true, ...(candidate.rdoLambda === undefined ? {} : { rdoLambda: candidate.rdoLambda }), }); + const encoded = + options.astc === true && basis !== undefined ? await transcodeKtx2ToAstc(basis) : basis; if (encoded !== undefined && options.maxBytes !== undefined) { if (encoded.byteLength >= options.maxBytes) { // ponytail: ETC1S is the ladder's lowest bitrate, so on a large image the UASTC rungs it @@ -498,10 +502,11 @@ export async function encodeTextureLadder( encoded.byteLength >= smallest.encoded.byteLength ) continue; + // Byte admission uses the emitted buffer; shipped ASTC quality still needs its own decoder. const score = - encoded === undefined + basis === undefined ? imageQuality(data, data, width, height, options) - : await measureKtx2(data, encoded, width, height, options); + : await measureKtx2(data, basis, width, height, options); if ( score.status !== "below-floor" && ((options.alphaThresholds?.length ?? 0) > 0 || score.alpha.meanAbsoluteError === 0) diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index e8ab99f8fb..0c288bb077 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -7,7 +7,6 @@ import { IMAGE_QUALITY_IDENTITY, imageQuality, resolveImageQualityFloor, - transcodeKtx2ToAstc, } from "../image-quality.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../ktx2-encoder.js"; import { decodeImageBytes } from "./decode-image.js"; @@ -112,7 +111,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { appliesTo: ["texture"], configuration: { encoder: KTX2_ENCODER_VERSION, - ...(options.astc === true ? { gpuFormat: "astc4x4-v1" } : {}), + ...(options.astc === true ? { gpuFormat: "astc4x4-v2" } : {}), instrument: IMAGE_QUALITY_IDENTITY, floor: resolveImageQualityFloor(options.floor), keepSmallerSource: true, @@ -169,6 +168,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { floor: options.floor, srgb: !choice.normalMap, quality: choice.quality, + ...(options.astc === true ? { astc: true } : {}), ...(!resized && !choice.explicit ? { maxBytes: input.byteLength } : {}), ...(choice.explicit ? { forced: choice.codec } : {}), }); @@ -215,7 +215,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { ); } return { - buffer: Buffer.from(options.astc === true ? await transcodeKtx2ToAstc(encoded) : encoded), + buffer: Buffer.from(encoded), entry: { ...(options.astc === true ? { format: "astc4x4" } From e49bb087fbcbe03e5f85e8f02a1e9247e29ee5d7 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 18:12:55 -0700 Subject: [PATCH 09/57] fix(assets): score final ASTC bytes before admission --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 9 +- .../assets/__tests__/image-quality.spec.ts | 76 +++++++++++- .../assets/__tests__/texture-pass.spec.ts | 35 +++++- packages/assets/src/image-quality.ts | 11 +- packages/assets/src/ktx2-encoder.ts | 112 ++++++++++++++++++ packages/assets/src/passes/model-textures.ts | 6 +- packages/assets/src/passes/texture.ts | 4 +- 7 files changed, 243 insertions(+), 10 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 4efb436ff5..615916a175 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -57,6 +57,13 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-10, standalone decoder-map admission:** mobile selects raw ASTC only when KTX2 capability is absent; an explicit KTX2-capable map selects Basis independently of Meshopt. Only the built-in standalone texture registration gate and `texturesActive` use KTX2 capability; `needsRuntimeDecoder: !mobileAstc` remains truthful, while model, lightmap and decoder-dependent custom passes retain their existing combined KTX2/Meshopt gate. The same standalone gate now admits web/desktop `{ktx2: true, meshopt: false}` rather than silently omitting compression. Tests exercise all four maps on Android/iOS, the partial map on web/desktop, actual non-empty Basis JS/WASM outputs versus no transcoder for ASTC, all four custom-pass maps, and both mobile `codec: "none"`/`textures: "none"` controls retaining authored bytes with default and explicit capabilities. After narrowing an initial test assertion to exclude unrelated model-config reporting, `pnpm exec vitest run packages/assets/__tests__/compile.spec.ts -t 'standalone decoder capabilities|custom decoder contract|none kill switch' --silent` reproduced six format/admission failures (12 passed, 46 skipped; exit 1, 81.96 s), then passed all 18 cases (46 skipped; exit 0, 91.83 s). The complete nearest file, `pnpm exec vitest run packages/assets/__tests__/compile.spec.ts --silent`, passed all 64 tests (exit 0, 206.68 s), including existing cache, model, lightmap and custom-pass cases. Assets typecheck and focused Biome passed; Biome reported 14 warnings. All phase and acceptance boxes remain open at 0%; final ASTC decoded quality remains OPEN, and this component makes no native, visual or packaging-admission claim. + +- **2026-10-10, final standalone ASTC quality admission:** reuse the installed encoder WASM singleton's existing `KTX2File` RGBA32 export; no decoder binding, vendor change or rebuild was required. The shared ladder now measures the exact final buffer after ASTC emission and byte admission. Raw ASTC validation requires a matching 2D ASTC 4×4 UNORM/sRGB container with no supercompression, consistent descriptor, full mip count and exact block/decoded lengths. Every mip must decode successfully; failed output is discarded and the file is closed/deleted in `finally`. Existing colour/slot, alpha and floor rules score decoded final mip 0. The standalone ASTC cache identity changed to prevent reusing prior intermediate-only observations; authored passthrough and final-byte source-retention rules remain unchanged. + + Meaningful red-green: `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts -t 'scores final ASTC|rejects final off-colour' --silent` failed all three cases before implementation (after correcting an initial PNG-fixture call), then passed all three (41 skipped; exit 0, 1.78 s). Real ETC1S/UASTC fixtures cover UNORM/sRGB, alpha and 5×3 → 2×1 → 1×1 edges; valid off-colour final ASTC fails the floor despite a good Basis intermediate, and corrupt mip 0 or upper-mip blocks reject. An additional malformed-container fixture hung while asking `ktx-parse` to serialize contradictory layer metadata; the first run and diagnostic rerun were terminated. The approved fixture correction mutates serialized header bytes directly, retaining production validation. `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/model-texture-pass.spec.ts --silent` then passed once: 91 passed, 1 skipped, three files, exit 0, 96.05 s. Assets typecheck, focused Biome and `git diff --check` passed; Biome reported 10 complexity warnings. + + **Still OPEN:** perceptual comparison of upper mips (they are decoded/validated, but only mip 0 is scored); normal/data slots remain explicitly unvalidated by the existing colour metrics. Embedded/shared ASTC cooking, lightmaps, packaging admission, native, GPU and visual proof remain open. All phase and acceptance boxes remain open at 0%. This component used no external model, build, install, dependency change, commit, push or GitHub mutation. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. @@ -81,7 +88,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone Android/iOS byte admission now compares final raw ASTC buffers for maxBytes, smallest candidate and source retention, with a new ASTC cache identity (see Decisions). Quality still measures the Basis intermediate, so shipped ASTC quality remains OPEN. Explicit standalone decoder maps and mobile none controls now have focused compile coverage (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. + Partial: standalone Android/iOS byte admission now compares final raw ASTC buffers for maxBytes, smallest candidate and source retention, with a new ASTC cache identity (see Decisions). Standalone quality now scores final emitted ASTC mip 0, with every emitted mip decoded and validated (see Decisions); upper-mip perceptual comparison and normal/data quality certification remain OPEN. Explicit standalone decoder maps and mobile none controls now have focused compile coverage (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` #### Phase 2: Load and check on the host diff --git a/packages/assets/__tests__/image-quality.spec.ts b/packages/assets/__tests__/image-quality.spec.ts index 5f21556aaf..1ee5a40f61 100644 --- a/packages/assets/__tests__/image-quality.spec.ts +++ b/packages/assets/__tests__/image-quality.spec.ts @@ -1,12 +1,12 @@ import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; -import { read as readKTX2 } from "ktx-parse"; +import { read as readKTX2, write as writeKTX2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import { deltaE00, imageQuality, ssim } from "../src/image-quality.js"; import { measureKtx2, transcodeKtx2ToAstc } from "../src/image-quality.js"; -import { encodeToKTX2 } from "../src/ktx2-encoder.js"; +import { decodeAstcKtx2, encodeToKTX2 } from "../src/ktx2-encoder.js"; import { decodeImageBytes } from "../src/passes/decode-image.js"; function pixels(noise = false): Uint8Array { @@ -98,6 +98,78 @@ describe("image quality instrument", () => { } }, ); + it.each([false, true])( + "scores final ASTC bytes, profiles and edge mips (UASTC=%s)", + async (isUASTC) => { + const data = new Uint8Array(5 * 3 * 4); + for (let i = 0; i < data.length; i += 4) data.set([240, 20, 40, 128], i); + for (const srgb of [false, true]) { + const basis = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 5, height: 3 }), + isUASTC, + isSetKTX2SRGBTransferFunc: srgb, + }); + expect((await measureKtx2(data, basis, 5, 3)).status).toBe("pass"); + const astc = await transcodeKtx2ToAstc(basis); + const good = await measureKtx2(data, astc, 5, 3); + expect(good.status).toBe("pass"); + expect(good.alpha.meanAbsoluteError).toBe(0); + expect(readKTX2(astc).levels.map((level) => level.levelData.length)).toEqual([32, 16, 16]); + const offColour = readKTX2(astc.slice()); + for (const level of offColour.levels) { + for (let block = 0; block < level.levelData.length; block += 16) + level.levelData.set( + [252, 253, 255, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 0, 128, 128], + block, + ); + } + expect((await measureKtx2(data, writeKTX2(offColour), 5, 3)).status).toBe("below-floor"); + for (const mip of [0, 1]) { + const corrupt = readKTX2(astc.slice()); + const level = corrupt.levels[mip]; + if (level === undefined) throw new Error("fixture mip missing"); + level.levelData.fill(0, 0, 16); + await expect(measureKtx2(data, writeKTX2(corrupt), 5, 3)).rejects.toThrow( + /TN_ASSETS_QUALITY_DECODE/, + ); + } + } + }, + ); + + it("rejects malformed final ASTC containers before quality scoring", async () => { + const data = new Uint8Array(5 * 3 * 4).fill(128); + const basis = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 5, height: 3 }), + }); + const astc = await transcodeKtx2ToAstc(basis); + await expect(decodeAstcKtx2(astc, 0, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); + await expect(decodeAstcKtx2(astc, 4, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); + // Mutate the serialized header: the writer expects a consistent valid container. + for (const [offset, value] of [ + [12, 159], + [16, 2], + [28, 1], + [32, 1], + [36, 6], + [40, 2], + [44, 2], + ]) { + const corrupt = astc.slice(); + new DataView(corrupt.buffer).setUint32(offset ?? 0, value ?? 0, true); + await expect(decodeAstcKtx2(corrupt, 5, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); + } + for (const offset of [88, 96]) { + const corrupt = astc.slice(); + new DataView(corrupt.buffer).setBigUint64(offset, 16n, true); + await expect(decodeAstcKtx2(corrupt, 5, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); + } + const corrupt = astc.slice(); + const view = new DataView(corrupt.buffer); + view.setUint8(view.getUint32(48, true) + 14, 1); + await expect(decodeAstcKtx2(corrupt, 5, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); + }); + it("rejects invalid or incomplete Basis input for the mobile cook", async () => { await expect(transcodeKtx2ToAstc(new Uint8Array([0]))).rejects.toThrow(); const data = new Uint8Array(8 * 8 * 4).fill(128); diff --git a/packages/assets/__tests__/texture-pass.spec.ts b/packages/assets/__tests__/texture-pass.spec.ts index a7f6679d12..ce62a400e5 100644 --- a/packages/assets/__tests__/texture-pass.spec.ts +++ b/packages/assets/__tests__/texture-pass.spec.ts @@ -1,11 +1,12 @@ import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; -import { read as readKTX2 } from "ktx-parse"; +import { read as readKTX2, write as writeKTX2 } from "ktx-parse"; import { PNG } from "pngjs"; import { describe, expect, it, vi } from "vitest"; import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; +import * as imageQualityModule from "../src/image-quality.js"; import { transcodeKtx2ToAstc } from "../src/image-quality.js"; import { type IAssetSourceConfig, compileAssets, texturePass } from "../src/index.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../src/ktx2-encoder.js"; @@ -61,6 +62,38 @@ function compressiblePng(alpha?: (x: number, y: number) => number): Buffer { } describe("the ktx2 texture pass", () => { + it("rejects final off-colour ASTC despite a passing Basis intermediate", async () => { + const emit = transcodeKtx2ToAstc; + const spy = vi + .spyOn(imageQualityModule, "transcodeKtx2ToAstc") + .mockImplementation(async (basis) => { + const astc = readKTX2(await emit(basis)); + for (const level of astc.levels) + for (let offset = 0; offset < level.levelData.length; offset += 16) + level.levelData.set( + [252, 253, 255, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 0, 255, 255], + offset, + ); + return writeKTX2(astc); + }); + try { + const input = rgbaPng({ + width: 8, + height: 8, + red: () => 240, + green: () => 20, + blue: () => 40, + }); + await expect( + texturePass({ astc: true, overrides: [{ glob: "colour.png", codec: "uastc" }] }).apply( + input, + "colour.png", + ), + ).rejects.toThrow(/TN_ASSETS_TEXTURE_FLOOR/); + } finally { + spy.mockRestore(); + } + }); it("retains the source when Basis saves bytes but emitted ASTC would grow it", async () => { const input = compressiblePng(); const floor = { ssim: 0, meanDeltaE00: 100 }; diff --git a/packages/assets/src/image-quality.ts b/packages/assets/src/image-quality.ts index 63efb63c95..895d42f4a7 100644 --- a/packages/assets/src/image-quality.ts +++ b/packages/assets/src/image-quality.ts @@ -12,6 +12,7 @@ import { write as writeKTX2, } from "ktx-parse"; import { resolveBasisTranscoder } from "./compile.js"; +import { decodeAstcKtx2 } from "./ktx2-encoder.js"; // The sculpt MCP math is not present in this repository. Minimal SSIM port: Rec.709 // encoded luma (0..255), non-overlapping 8x8 windows, population moments, equal-window mean. @@ -411,7 +412,7 @@ export async function transcodeKtx2ToAstc(encoded: Uint8Array): Promise { validatePixels(source, width, height); + if (readKTX2(encoded).vkFormat !== 0) + return imageQuality( + source, + await decodeAstcKtx2(encoded, width, height), + width, + height, + options, + ); const basis = await basisModule(); const file = new basis.KTX2File(encoded); try { diff --git a/packages/assets/src/ktx2-encoder.ts b/packages/assets/src/ktx2-encoder.ts index 586e1f33db..9539d2972c 100644 --- a/packages/assets/src/ktx2-encoder.ts +++ b/packages/assets/src/ktx2-encoder.ts @@ -1,3 +1,12 @@ +import { + KHR_DF_MODEL_ASTC, + KHR_DF_TRANSFER_LINEAR, + KHR_DF_TRANSFER_SRGB, + KHR_SUPERCOMPRESSION_NONE, + VK_FORMAT_ASTC_4x4_SRGB_BLOCK, + VK_FORMAT_ASTC_4x4_UNORM_BLOCK, + read as readKTX2, +} from "ktx-parse"; import BASIS from "../vendor/basis-encoder/basis_encoder.js"; /** Included in both pass and shared-image cache keys whenever encoded bytes can change. */ @@ -46,8 +55,31 @@ interface IBasisEncoder { setPackUASTCFlags(flags: number): void; } +interface IEncoderKtx2File { + close(): void; + delete(): void; + isValid(): boolean; + isASTC_LDR(): boolean; + startTranscoding(): boolean; + getWidth(): number; + getHeight(): number; + getLevels(): number; + getImageTranscodedSizeInBytes(mip: number, layer: number, face: number, format: number): number; + transcodeImage( + dst: Uint8Array, + mip: number, + layer: number, + face: number, + format: number, + alpha: number, + channel0: number, + channel1: number, + ): boolean; +} + interface IBasisModule { readonly BasisEncoder: new () => IBasisEncoder; + readonly KTX2File: new (bytes: Uint8Array) => IEncoderKtx2File; initializeBasis(): void; } @@ -137,3 +169,83 @@ export async function encodeToKTX2( encoder.delete(); } } + +/** Validate and decode every final ASTC mip; retain only tight RGBA8 mip zero for scoring. */ +export async function decodeAstcKtx2( + encoded: Uint8Array, + width: number, + height: number, +): Promise { + const container = readKTX2(encoded); + const descriptor = container.dataFormatDescriptor[0]; + if ( + !Number.isSafeInteger(width) || + !Number.isSafeInteger(height) || + width <= 0 || + height <= 0 || + width * height > MAX_SOURCE_TEXELS || + ![VK_FORMAT_ASTC_4x4_UNORM_BLOCK, VK_FORMAT_ASTC_4x4_SRGB_BLOCK].includes(container.vkFormat) || + container.pixelWidth !== width || + container.pixelHeight !== height || + container.typeSize !== 1 || + container.pixelDepth !== 0 || + container.layerCount !== 0 || + container.faceCount !== 1 || + container.supercompressionScheme !== KHR_SUPERCOMPRESSION_NONE || + container.globalData !== null || + container.dataFormatDescriptor.length !== 1 || + container.levelCount !== Math.floor(Math.log2(Math.max(width, height))) + 1 || + container.levels.length !== container.levelCount || + descriptor?.colorModel !== KHR_DF_MODEL_ASTC || + descriptor.transferFunction !== + (container.vkFormat === VK_FORMAT_ASTC_4x4_SRGB_BLOCK + ? KHR_DF_TRANSFER_SRGB + : KHR_DF_TRANSFER_LINEAR) || + descriptor.texelBlockDimension.join() !== "3,3,0,0" || + descriptor.bytesPlane.join() !== "16,0,0,0,0,0,0,0" + ) + throw new Error( + "TN_ASSETS_QUALITY_DECODE: expected a full 2D ASTC 4x4 mip chain matching the comparison.", + ); + const file = new (await basisModule()).KTX2File(encoded); + try { + if ( + !file.isValid() || + !file.isASTC_LDR() || + file.getWidth() !== width || + file.getHeight() !== height || + file.getLevels() !== container.levelCount || + !file.startTranscoding() + ) + throw new Error("TN_ASSETS_QUALITY_DECODE: the installed encoder rejected final ASTC."); + let mipZero: Uint8Array | undefined; + // cTFRGBA32 in the pinned Basis Universal transcoder_texture_format enum. + const format = 13; + for (const [mip, level] of container.levels.entries()) { + const mipWidth = Math.max(1, Math.floor(width / 2 ** mip)); + const mipHeight = Math.max(1, Math.floor(height / 2 ** mip)); + const blocks = Math.ceil(mipWidth / 4) * Math.ceil(mipHeight / 4) * 16; + const pixels = mipWidth * mipHeight * 4; + if ( + level.levelData.byteLength !== blocks || + level.uncompressedByteLength !== blocks || + file.getImageTranscodedSizeInBytes(mip, 0, 0, format) !== pixels + ) + throw new Error(`TN_ASSETS_QUALITY_DECODE: invalid ASTC mip ${String(mip)} size.`); + const decoded = new Uint8Array(pixels); + if (!file.transcodeImage(decoded, mip, 0, 0, format, 0, -1, -1)) + throw new Error( + `TN_ASSETS_QUALITY_DECODE: ASTC mip ${String(mip)} RGBA32 transcode failed.`, + ); + if (mip === 0) mipZero = decoded; + } + if (mipZero === undefined) throw new Error("TN_ASSETS_QUALITY_DECODE: missing ASTC mip zero."); + return mipZero; + } finally { + try { + file.close(); + } finally { + file.delete(); + } + } +} diff --git a/packages/assets/src/passes/model-textures.ts b/packages/assets/src/passes/model-textures.ts index 1831461005..6d39fa9a82 100644 --- a/packages/assets/src/passes/model-textures.ts +++ b/packages/assets/src/passes/model-textures.ts @@ -502,11 +502,11 @@ export async function encodeTextureLadder( encoded.byteLength >= smallest.encoded.byteLength ) continue; - // Byte admission uses the emitted buffer; shipped ASTC quality still needs its own decoder. + // Admission and quality both observe the exact buffer that will be written. const score = - basis === undefined + encoded === undefined ? imageQuality(data, data, width, height, options) - : await measureKtx2(data, basis, width, height, options); + : await measureKtx2(data, encoded, width, height, options); if ( score.status !== "below-floor" && ((options.alphaThresholds?.length ?? 0) > 0 || score.alpha.meanAbsoluteError === 0) diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index 0c288bb077..b443ef431c 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -54,7 +54,7 @@ export interface ITextureOverride { } export interface ITexturePassOptions { - /** Compiler-selected mobile output; the quality ladder still measures Basis first. */ + /** Compiler-selected mobile output; the quality ladder measures final ASTC bytes. */ readonly astc?: boolean; readonly floor?: Partial; /** Internal reporting control; selection still measures and enforces the floor. */ @@ -111,7 +111,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { appliesTo: ["texture"], configuration: { encoder: KTX2_ENCODER_VERSION, - ...(options.astc === true ? { gpuFormat: "astc4x4-v2" } : {}), + ...(options.astc === true ? { gpuFormat: "astc4x4-final-quality-v3" } : {}), instrument: IMAGE_QUALITY_IDENTITY, floor: resolveImageQualityFloor(options.floor), keepSmallerSource: true, From 90b7267ff2d88a608b4704f3e2eacf44cacdfdbe Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 18:28:45 -0700 Subject: [PATCH 10/57] fix(cli): admit validated raw ASTC on decoder-free mobile --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 8 + .../__tests__/native-ktx2.spec.ts | 156 ++++++++++++++++ packages/create-threenative/src/build.ts | 174 ++++++++++++++++-- 3 files changed, 326 insertions(+), 12 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 615916a175..092af963bd 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -64,6 +64,13 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Still OPEN:** perceptual comparison of upper mips (they are decoded/validated, but only mip 0 is scored); normal/data slots remain explicitly unvalidated by the existing colour metrics. Embedded/shared ASTC cooking, lightmaps, packaging admission, native, GPU and visual proof remain open. All phase and acceptance boxes remain open at 0%. This component used no external model, build, install, dependency change, commit, push or GitHub mutation. + +- **2026-10-10, structural mobile ASTC packaging admission:** `assertNativeAssetsCompatible` now inspects actual standalone and shared `.ktx2` outputs before granting a decoder-free Android/iOS exception. The installed direct `ktx-parse` dependency is reused; no dependency or public API was added. The exception requires 2D ASTC 4×4 UNORM/sRGB (157/158), matching DFD/profile/block layout, a complete mip chain, no supercompression/global data, exact compressed/uncompressed level lengths, and bounded, aligned, nonoverlapping image/metadata regions. Missing, malformed, truncated, unsupported, Basis or mixed outputs retain the named refusal. `KHR_texture_basisu` rows remain refused without a decoder, including extension-only embedded models. Decoder-capable/web behavior, decoder-free desktop refusal and independent Meshopt/Draco gates remain unchanged. + + Red-green: `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts -t 'admits actual raw ASTC|rejects invalid raw ASTC|keeps the mobile raw ASTC' --silent` first failed six cases (one passed, nine skipped; exit 1, 3.98 s), then passed all seven (nine skipped; exit 0, 3.56 s). Valid fixtures cover both profiles on Android/iOS, standalone/shared output, real Basis refusal, missing files, unsupported formats, dimensions, supercompression, level lengths/bounds/overlap, truncation, descriptor transfer mismatch, mixed shared content and the independent geometry refusal. Actual ASTC fixtures were captured from the installed encoder/emitter at `e49bb087f` and retained inline after a cross-package source import caused TS7016 for assets' Draco ambient declaration. The captured bytes are copied into plain Uint8Array storage before destructive test mutations, avoiding Buffer slice aliasing. Three declaration lint errors were corrected after the approved replan; production validation was not relaxed. Final nearest run: `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts --silent` — 16 passed, exit 0, 13.30 s. Scaffolder TypeScript, focused Biome, docs links and `git diff --check` passed; Biome reported eight complexity warnings. + + **Still OPEN:** this is structural file admission, not an ASTC block decoder or native GPU qualification. The cook's final-output decoder/quality checks remain authoritative for generated texture pixels. Embedded/shared model cooking, generated lightmaps, embedded-image inspection, adapter feature checks and actual QuickJS/JSC packaging/rendering remain unverified. All broad phase/acceptance boxes remain open at 0%; no native build, GPU/browser/device run, commit, push or GitHub mutation was performed. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. @@ -90,6 +97,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` Partial: standalone Android/iOS byte admission now compares final raw ASTC buffers for maxBytes, smallest candidate and source retention, with a new ASTC cache identity (see Decisions). Standalone quality now scores final emitted ASTC mip 0, with every emitted mip decoded and validated (see Decisions); upper-mip perceptual comparison and normal/data quality certification remain OPEN. Explicit standalone decoder maps and mobile none controls now have focused compile coverage (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. - [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` + Partial: the standalone/shared raw ASTC structural gate and Basis refusal now have local unit coverage (see Decisions). Embedded model inspection and actual native packaging/rendering remain unverified; this broad box stays open. #### Phase 2: Load and check on the host **Status:** NOT STARTED diff --git a/packages/create-threenative/__tests__/native-ktx2.spec.ts b/packages/create-threenative/__tests__/native-ktx2.spec.ts index 9974661871..5fb3fd5418 100644 --- a/packages/create-threenative/__tests__/native-ktx2.spec.ts +++ b/packages/create-threenative/__tests__/native-ktx2.spec.ts @@ -119,7 +119,163 @@ async function writeManifest( ); } +// Captured from the installed encodeToKTX2 + transcodeKtx2ToAstc at e49bb087f: +// separate UNORM/sRGB UASTC cooks of 8x8 RGBA [240,20,40,128], with all four mip levels. +const CAPTURED_ASTC = { + linear: + "q0tUWCAyMLsNChoKnQAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQEAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", + srgb: "q0tUWCAyMLsNChoKngAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", +} as const; + +async function mobileAstcFixture(srgb = true): Promise<{ basis: Uint8Array; astc: Uint8Array }> { + const data = new Uint8Array(8 * 8 * 4); + for (let i = 0; i < data.length; i += 4) data.set([240, 20, 40, 128], i); + const basis = await encodeToKTX2(new Uint8Array([0]), { + generateMipmap: true, + imageDecoder: async () => ({ data, height: 8, width: 8 }), + isSetKTX2SRGBTransferFunc: srgb, + isUASTC: true, + }); + return { + basis, + astc: Uint8Array.from(Buffer.from(srgb ? CAPTURED_ASTC.srgb : CAPTURED_ASTC.linear, "base64")), + }; +} + describe("compressed assets on native targets", () => { + it.each([ + ["android", false], + ["android", true], + ["ios", false], + ["ios", true], + ] as const)("admits actual raw ASTC but refuses Basis on %s (sRGB=%s)", async (target, srgb) => { + const project = await gameProject("threenative-raw-astc-admission-"); + const config = await loadConfig(project); + const capabilities = + target === "android" ? androidQuickJs() : resolveRuntimeAssetCapabilities("ios"); + const { basis, astc } = await mobileAstcFixture(srgb); + await mkdir(path.join(project, "public/textures"), { recursive: true }); + const output = "textures/colour.ktx2"; + for (const shared of [false, true]) { + await writeManifest(project, { + fixture: shared + ? { kind: "model", output: "model.glb", sharedImages: [{ output }] } + : { kind: "texture", output }, + }); + await writeFile(path.join(project, "public", output), astc); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).resolves.toBeUndefined(); + await writeFile(path.join(project, "public", output), basis); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + } + }); + + it.each(["android", "ios"] as const)( + "rejects invalid raw ASTC and mixed shared outputs on %s", + async (target) => { + const project = await gameProject("threenative-raw-astc-invalid-"); + const config = await loadConfig(project); + const capabilities = + target === "android" ? androidQuickJs() : resolveRuntimeAssetCapabilities("ios"); + const { basis, astc } = await mobileAstcFixture(); + await writeManifest(project, { colour: { kind: "texture", output: "colour.ktx2" } }); + const output = path.join(project, "public/colour.ktx2"); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + const corruptions = [ + (bytes: Uint8Array) => bytes.subarray(0, 79), + (bytes: Uint8Array) => bytes.subarray(0, bytes.length - 1), + ...[ + [12, 145], + [12, 165], + [16, 2], + [20, 0], + [28, 1], + [32, 1], + [36, 6], + [40, 1], + [44, 2], + ].map(([offset, value]) => (bytes: Uint8Array) => { + new DataView(bytes.buffer).setUint32(offset ?? 0, value ?? 0, true); + return bytes; + }), + ...[80, 88, 96].map((offset) => (bytes: Uint8Array) => { + new DataView(bytes.buffer).setBigUint64(offset, 1n, true); + return bytes; + }), + (bytes: Uint8Array) => { + const v = new DataView(bytes.buffer); + v.setBigUint64(104, v.getBigUint64(80, true), true); + return bytes; + }, + (bytes: Uint8Array) => { + const v = new DataView(bytes.buffer); + v.setUint8(v.getUint32(48, true) + 14, 1); + return bytes; + }, + ]; + for (const corrupt of corruptions) { + await writeFile(output, corrupt(astc.slice())); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + } + await writeFile(output, astc); + await writeFile(path.join(project, "public/basis.ktx2"), basis); + await writeManifest(project, { + colour: { + kind: "texture", + output: "colour.ktx2", + sharedImages: [{ output: "basis.ktx2" }], + }, + }); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + await writeManifest(project, { + colour: { + kind: "model", + output: "model.glb", + extensions: ["KHR_texture_basisu"], + sharedImages: [{ output: "colour.ktx2" }], + }, + }); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + await writeManifest(project, { + colour: { kind: "model", output: "model.glb", extensions: ["KHR_texture_basisu"] }, + }); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + await writeManifest(project, { + colour: { kind: "texture", output: "colour.ktx2", extensions: ["EXT_meshopt_compression"] }, + }); + await expect( + assertNativeAssetsCompatible(project, target, config, capabilities), + ).rejects.toThrow(/TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED/); + }, + ); + + it("keeps the mobile raw ASTC exception out of decoder-free desktop", async () => { + const project = await gameProject("threenative-raw-astc-desktop-"); + const { astc } = await mobileAstcFixture(); + await writeManifest(project, { colour: { kind: "texture", output: "colour.ktx2" } }); + await writeFile(path.join(project, "public/colour.ktx2"), astc); + const capabilities = resolveRuntimeAssetCapabilities("desktop", undefined, (() => ({ + status: 0, + stdout: "+ quickjs build", + })) as never); + await expect( + assertNativeAssetsCompatible(project, "desktop", await loadConfig(project), capabilities), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/); + }); + it("bundles a game that ships no compressed asset for mobile without WASM", async () => { const project = await gameProject("threenative-ktx2-mobile-"); diff --git a/packages/create-threenative/src/build.ts b/packages/create-threenative/src/build.ts index bd821fcc93..987ee83a28 100644 --- a/packages/create-threenative/src/build.ts +++ b/packages/create-threenative/src/build.ts @@ -25,6 +25,15 @@ import { traceSegment, } from "@jridgewell/trace-mapping"; import { compileAssets } from "@threenative/assets"; +import { + KHR_DF_MODEL_ASTC, + KHR_DF_TRANSFER_LINEAR, + KHR_DF_TRANSFER_SRGB, + KHR_SUPERCOMPRESSION_NONE, + VK_FORMAT_ASTC_4x4_SRGB_BLOCK, + VK_FORMAT_ASTC_4x4_UNORM_BLOCK, + read as readKTX2, +} from "ktx-parse"; import { BUILD_REPORT_SUFFIX, measureTreeBytes, writeBuildReport } from "./buildReport.js"; import { writeCompressionSidecars } from "./compress.js"; import { type IResolvedThreeNativeConfig, loadConfig } from "./config.js"; @@ -178,6 +187,117 @@ export function assetRoot(cwd: string, config: IResolvedThreeNativeConfig): stri return path.resolve(cwd, config.assets?.output ?? "public"); } +/** Structural package admission only; the cook owns decoded quality and the host checks GPU support. */ +function isMobileRawAstc(bytes: Uint8Array): boolean { + try { + if (bytes.byteLength < 80) return false; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getUint32(20, true); + const height = view.getUint32(24, true); + const count = view.getUint32(40, true); + const indexEnd = 80 + count * 24; + if ( + width === 0 || + height === 0 || + count !== Math.floor(Math.log2(Math.max(width, height))) + 1 || + indexEnd > bytes.byteLength + ) + return false; + // Bound descriptor parsing before handing untrusted bytes to the installed parser. + const dfd = view.getUint32(48, true); + const dfdLength = view.getUint32(52, true); + if ( + dfd < indexEnd || + dfd % 4 !== 0 || + dfdLength !== 44 || + dfd + dfdLength > bytes.byteLength || + view.getUint32(dfd, true) !== dfdLength || + view.getUint16(dfd + 10, true) !== 40 || + view.getBigUint64(64, true) !== 0n || + view.getBigUint64(72, true) !== 0n + ) + return false; + const regions: [number, number][] = [ + [0, indexEnd], + [dfd, dfd + dfdLength], + ]; + const kvd = view.getUint32(56, true); + const kvdLength = view.getUint32(60, true); + if (kvdLength > 0) { + if (kvd < indexEnd || kvd % 4 !== 0 || kvd + kvdLength > bytes.byteLength) return false; + regions.push([kvd, kvd + kvdLength]); + } + for (let mip = 0; mip < count; mip += 1) { + const offset = view.getBigUint64(80 + mip * 24, true); + const expected = + Math.ceil(Math.max(1, Math.floor(width / 2 ** mip)) / 4) * + Math.ceil(Math.max(1, Math.floor(height / 2 ** mip)) / 4) * + 16; + if ( + !Number.isSafeInteger(expected) || + view.getBigUint64(88 + mip * 24, true) !== BigInt(expected) || + view.getBigUint64(96 + mip * 24, true) !== BigInt(expected) || + offset % 16n !== 0n || + offset + BigInt(expected) > BigInt(bytes.byteLength) + ) + return false; + regions.push([Number(offset), Number(offset) + expected]); + } + regions.sort((a, b) => a[0] - b[0]); + if (regions.some(([start], i) => i > 0 && start < (regions[i - 1]?.[1] ?? 0))) return false; + const container = readKTX2(bytes); + const descriptor = container.dataFormatDescriptor[0]; + const sample = descriptor?.samples[0]; + return ( + [VK_FORMAT_ASTC_4x4_UNORM_BLOCK, VK_FORMAT_ASTC_4x4_SRGB_BLOCK].includes( + container.vkFormat, + ) && + container.typeSize === 1 && + container.pixelDepth === 0 && + container.layerCount === 0 && + container.faceCount === 1 && + container.supercompressionScheme === KHR_SUPERCOMPRESSION_NONE && + container.globalData === null && + container.levelCount === count && + container.levels.length === count && + container.dataFormatDescriptor.length === 1 && + descriptor?.vendorId === 0 && + descriptor.descriptorType === 0 && + descriptor.versionNumber === 2 && + descriptor.colorModel === KHR_DF_MODEL_ASTC && + (descriptor.flags === 0 || descriptor.flags === 1) && + descriptor.transferFunction === + (container.vkFormat === VK_FORMAT_ASTC_4x4_SRGB_BLOCK + ? KHR_DF_TRANSFER_SRGB + : KHR_DF_TRANSFER_LINEAR) && + descriptor.texelBlockDimension.join() === "3,3,0,0" && + descriptor.bytesPlane.join() === "16,0,0,0,0,0,0,0" && + descriptor.samples.length === 1 && + sample?.bitOffset === 0 && + sample.bitLength === 127 && + sample.channelType === 0 && + sample.samplePosition.join() === "0,0,0,0" && + sample.sampleLower === 0 && + sample.sampleUpper === 0xffffffff + ); + } catch { + return false; + } +} + +/** Only files in the compiled root can qualify for the mobile raw-ASTC exception. */ +async function isCompiledMobileAstc(outputRoot: string, output: string): Promise { + const resolved = path.resolve(outputRoot, output); + const relative = path.relative(outputRoot, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + return false; + try { + return isMobileRawAstc(await readFile(resolved)); + } catch { + return false; + } +} + /** Refuse compiled codecs the selected runtime/bundle cannot decode before packaging starts. */ export async function assertNativeAssetsCompatible( cwd: string, @@ -210,21 +330,51 @@ export async function assertNativeAssetsCompatible( 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)) || - (Array.isArray(entry.extensions) && entry.extensions.includes("KHR_texture_basisu")) || - (Array.isArray(entry.sharedImages) && - entry.sharedImages.some( - (image) => + const ktx2Rows = rows.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), + )), + ); + const ktx2: string[] = []; + for (const [logical, entry] of ktx2Rows) { + if ( + !capabilities.decoders.ktx2 && + (target === "android" || target === "ios") && + !(Array.isArray(entry.extensions) && entry.extensions.includes("KHR_texture_basisu")) + ) { + const outputs = [ + ...(typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output) + ? [entry.output] + : []), + ...(Array.isArray(entry.sharedImages) + ? entry.sharedImages.flatMap((image) => image !== null && typeof image === "object" && typeof image.output === "string" && - /\.ktx2$/iu.test(image.output), - )), - ) - .map(([logical]) => logical); + /\.ktx2$/iu.test(image.output) + ? [image.output as string] + : [], + ) + : []), + ]; + if ( + outputs.length > 0 && + ( + await Promise.all(outputs.map((output) => isCompiledMobileAstc(outputRoot, output))) + ).every(Boolean) + ) + continue; + } + ktx2.push(logical); + } if (!capabilities.decoders.ktx2 && ktx2.length > 0) { throw new Error( `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.`, From eccaabbda2356017b4e785a7d89fe30c2aec00f9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 19:46:56 -0700 Subject: [PATCH 11/57] feat(assets): cook mobile model textures to ASTC with runtime support guards --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 34 ++ packages/assets/__tests__/compile.spec.ts | 86 ++++- .../assets/__tests__/image-quality.spec.ts | 55 ++++ .../__tests__/model-texture-pass.spec.ts | 302 ++++++++++++++++-- packages/assets/src/compile.ts | 32 +- packages/assets/src/gltf-io.ts | 103 +++++- packages/assets/src/image-quality.ts | 14 +- packages/assets/src/passes/model-textures.ts | 27 +- packages/assets/src/passes/model.ts | 12 +- packages/core/__tests__/assets.spec.ts | 218 +++++++++++++ packages/core/src/assets.ts | 55 +++- 11 files changed, 889 insertions(+), 49 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 092af963bd..f43d0d3163 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -71,6 +71,40 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Still OPEN:** this is structural file admission, not an ASTC block decoder or native GPU qualification. The cook's final-output decoder/quality checks remain authoritative for generated texture pixels. Embedded/shared model cooking, generated lightmaps, embedded-image inspection, adapter feature checks and actual QuickJS/JSC packaging/rendering remain unverified. All broad phase/acceptance boxes remain open at 0%; no native build, GPU/browser/device run, commit, push or GitHub mutation was performed. +- **2026-10-10, installed embedded ASTC integration findings:** installed Three 0.185.1 `packages/core/node_modules/three/examples/jsm/loaders/KTX2Loader.js:375–430,471–499` requires `detectSupport()` before `load()`/`parse()`, but raw ASTC UNORM/sRGB (157/158) returns through `createRawTexture` before `init()` and the transcoder-worker branch. Its format mapping and NONE level-byte path (`:1007–1008,1097–1158`) therefore require no decoder WASM. Existing `createKtx2Loader({ basePath?, renderer })` (`packages/core/src/assets.ts:478–507`) performs support detection without calling `init()`; it accepts any supported compressed format. BC/ETC capability does not establish ASTC support: Three records the distinct `texture-compression-astc` feature (`KTX2Loader.js:231–239`), and an explicit ASTC guard before loading remains necessary. No unsupported-adapter upload behavior or native rendering was verified. + + Embedded raw ASTC must use the official **vendor-draft**, unratified [EXT_texture_astc specification](https://raw.githubusercontent.com/KhronosGroup/glTF/main/extensions/2.0/Vendor/EXT_texture_astc/README.md), with its source-reference, fallback/required, colour, orientation and alpha rules. It must not be labelled as [KHR_texture_basisu](https://raw.githubusercontent.com/KhronosGroup/glTF/main/extensions/2.0/Khronos/KHR_texture_basisu/README.md), whose ratified contract admits ETC1S/UASTC. Installed glTF-Transform 4.4.2 `packages/assets/node_modules/@gltf-transform/extensions/dist/index.js:6152–6189` supplies the borrowing pattern: an `Extension` prereads the extension source, then writes source references through `imageIndexMap`. Its Basis writer selects every `image/ktx2` texture and deletes the ordinary `textureDef.source`; ASTC-specific ownership must prevent that writer from claiming raw ASTC. Paired fallback preservation is not established by this pattern. Installed core writer `packages/assets/node_modules/@gltf-transform/core/dist/index.js:4686–4689,5200–5202` emits used/required extensions only when both registered for I/O and present on the document, then calls their writers. Correct registration, document membership and used/required cleanup are necessary, including removing stale Basis declarations when no Basis-owned textures remain. Three's existing Basis plugin delegates to `parser.loadTextureImage(textureIndex, extension.source, loader)` (`packages/core/node_modules/three/examples/jsm/loaders/GLTFLoader.js:1460–1489,3233–3262`); the proposed draft-extension plugin must fail for unsupported required ASTC and retain an ordinary fallback when optional. None of these serializer/plugin changes is implemented by this finding. + + The preceding single Opus 5.5 high diagnosis timed out at 300.216 s (wrapper exit 5, child 143; cleanup 0.215 s), with an empty final report and unchanged repository/installed-source guards. It supplied no architecture or feasibility conclusion and does not by itself block or decline this work. These findings come from subsequent bounded read-only installed-source extraction. All phase and acceptance boxes remain open at 0%; embedded/shared cooking and inspection, lightmaps, complete draft compliance, adapter handling, original all-target/web-desktop byte-identity requirements, native QuickJS/JSC proof and visual acceptance remain unverified. + +- **2026-10-10, draft ASTC runtime loader plugin only:** the existing core asset loader now registers an `EXT_texture_astc` GLTFLoader plugin and attaches the same support-detected KTX2 loader used by standalone textures. A private structural read of Three's existing `workerConfig.astcSupported === true` gates the raw ASTC handoff; no second detection, decoder, dependency or public capability API was added. Required ASTC rejects before image loading when the loader or ASTC support is absent. Unsupported optional ASTC returns to Three's ordinary fallback; missing/invalid fallback still fails. Only the named `TN_ASSETS_KTX2_UNSUPPORTED` capability refusal may select fallback for an ASTC-only model. Arbitrary loader failures continue through existing `TN_ASSETS_UNRESOLVED` wrapping, and Basis-bearing models retain their prior refusal. Custom `options.model`, Basis, Meshopt, Draco and non-ASTC paths remain covered by the nearest suite. + + Real GLTFLoader parsing regressions stub only image/texture I/O, not the plugin or parser. After correcting an initial manifest-fixture omission, `pnpm exec vitest run packages/core/__tests__/assets.spec.ts -t EXT_texture_astc --silent` reproduced seven behavior failures (six passed, 54 skipped; exit 1, 0.759 s). The first implementation run passed 11 of 13: the positive WebGL fixture lacked the real ASTC extension's `getSupportedProfiles()` surface, and the arbitrary-error test incorrectly expected an unwrapped error. The approved bounded installed-source read corrected that fixture and the assertion, without relaxing production policy. Final focused run: 13 passed, 54 skipped, exit 0, 1.44 s; cases cover WebGL/WebGPU support and one shared detection, BC/ETC-only refusal, optional fallback including no compressed support/no renderer, missing loader, arbitrary failures, invalid fallback and preservation of Basis refusal. `pnpm exec vitest run packages/core/__tests__/assets.spec.ts --silent` passed all 67 tests (exit 0, 1.27 s). Core typecheck and focused Biome passed; Biome reported three complexity warnings. + + **2026-10-10, real raw-parser follow-up:** two additional regressions call public `KTX2Loader.parse()` with the real captured ASTC UNORM/sRGB (157/158) bytes already used by `native-ktx2.spec.ts`; no encoder import, dependency or fixture file was added. Real ASTC support detection precedes parsing, and `init()` is forbidden. Neither `parse`, `_createTexture` nor raw texture construction is mocked. Both cases passed (two passed, 67 skipped; exit 0, 0.729 s), asserting the actual ASTC texture format, 8×8 → 4×4 → 2×2 → 1×1 dimensions, exact 64/16/16/16-byte mip lengths and every known captured block byte. This proves the installed raw parser returns compressed mip bytes without transcoder initialization; it does not prove GPU upload or pixel decoding. Core typecheck and focused Biome passed (three complexity warnings). Production loader code is unchanged by this verification addition. + + **Still OPEN:** this plugin supports the official vendor draft and does not implement the ASTC serializer, format-specific writer ownership, paired fallback storage, embedded/shared cooking or embedded packaging inspection. No raw-image decode, GPU upload, native performance or visual outcome is inferred from stubbed image I/O. All original all-target, web/desktop byte-identity, lightmap, QuickJS/JSC/native and visual acceptance requirements remain open; all phase and acceptance boxes remain at 0%. No build, install, external model retry, commit, push or GitHub mutation was performed. + +- **2026-10-10, explicit glTF ASTC metadata only:** `transcodeKtx2ToAstc` now accepts an internal optional glTF colour-primaries argument, applied while writing the final ASTC container before any final-buffer decode/score. Explicit 0 requires linear transfer for non-colour use; explicit 1 requires sRGB transfer for colour use. Unsupported values and mismatched pairs fail with `TN_ASSETS_ASTC_METADATA`. Omitting the argument retains the existing standalone output path; the captured sRGB fixture's exact pre-change SHA-256 remains unchanged. No WASM, dependency, cache identity or standalone caller changed. The integrated model ladder has not yet been wired to this argument. + + Real normal and metallic/roughness fixtures reproduced the incorrect inherited primaries; the focused metadata cases failed all three tests before implementation (including missing runtime validation), then passed all three (exit 0, 1.59 s). Tests assert final primaries/transfer/vkFormat, full mip lengths, unchanged block bytes, straight alpha and omitted orientation, decode/score the actual final buffer, and restore only primaries to establish exact byte equality with default output. Colour passes the existing floor; normal/data results remain honestly `unvalidated-slots`. An initial test-only TypeScript failure required explicit descriptor existence checks; assets typecheck and focused Biome then passed (two complexity warnings). The nearest focused metadata/final-ASTC/direct-transcode group passed seven tests (11 skipped, exit 0, 3.79 s). Broader embedded/shared serializer/cooking/cache wiring, native/GPU and visual acceptance remain open at 0%; no integrated serializer implementation, build, install, commit, push or GitHub mutation was performed. + +- **2026-10-10, bounded embedded/shared ASTC integration (partial):** the internal glTF-Transform `EXT_texture_astc` extension now registers on the shared reader and both model writers. Actual raw ASTC 4x4 vkFormat 157/158 with NONE supercompression owns ASTC references; sorted extension writing removes the ordinary source before the installed greedy Basis writer runs. Mixed Basis images retain their separate required declaration; stale Basis declarations disappear only when none remain, including shared-cache replay. Authored paired fallbacks fail explicitly with `TN_ASSETS_ASTC_FALLBACK` until preservation exists. The writer rejects invalid dimensions, incomplete mip chains, mismatched primaries/transfer, wrong ASTC block metadata, premultiplied alpha and unsupported orientation. Mobile embedded cooking threads the existing final ASTC ladder through explicit glTF primaries before final-buffer decode/score, with ASTC-only cache identity; explicit non-ASTC settings retain the same configuration identity. ASTC reporting uses its actual one-byte-per-pixel block format. Built-in model admission and skipped-compression reporting now evaluate Meshopt and KTX2 independently; custom/lightmap conjunction semantics remain unchanged. + + Real Android `{ ktx2: false, meshopt: true }` cooking first failed because the emitted GLB lacked `EXT_texture_astc` (one failed, 64 skipped, exit 1). After implementation and fixture corrections (explicit embedded output, required encoder image decoder, and comparing texture payload/reference round-trips rather than unrelated whole-GLB layout), the focused compile/model-texture group passed 13 tests (102 skipped, exit 0, 24.11 s). The final strengthened subset passed four tests (111 skipped, exit 0, 14.54 s): Android/iOS ASTC plus Meshopt and embedded `textures: "none"`, ASTC/Basis shared-cache isolation and warm replay, explicit `astc: false` configuration/output identity, mixed-image round-trip, paired-fallback refusal and invalid primaries refusal. Assets typecheck and focused Biome passed (25 complexity warnings); `git diff --check` passed. Existing core plugin/tests and standalone metadata fix/tests were not edited. + + **Still OPEN at 0%:** original pre-change web/desktop byte-identity proof, all-target cooking/packaging and embedded-image inspection, generated lightmaps, full native QuickJS/JSC game proof, GPU upload/performance and fresh visual-judge acceptance. Normal/data scores remain `unvalidated-slots`. Existing upstream Basis images are preserved rather than claimed decoder-free. No runtime game/playtest, GPU/native build, external model call, dependency/lockfile change, commit, push, PR or GitHub mutation was performed. This bounded component is retained for review; no original phase or acceptance box is ticked. + +- **2026-10-10, embedded serializer review fixes (partial):** ASTC writing now checks the actual levels array and both each level's payload length and uncompressed length against its 4x4 block count, including sub-block upper mips. The existing full-chain count and 2D checks remain; dimensions are bounded to safe signed-shift values before calculating mip sizes. This is structural validation, not proof of valid ASTC block pixels. Non-ASTC documents without ASTC images restore the original compression counter, including shared-store hits: an upstream optional Basis extension remains optional when no image was newly compressed. ASTC-selected or ASTC-bearing documents retain explicit format ownership and warm-cache cleanup. This corrects the preceding integration note's over-broad Basis membership statement. + + Focused real regressions reproduced all five failures before fixes: short base mip, short upper mip, mismatched uncompressed mip length despite a complete declared level count, and optional upstream Basis with absent/false ASTC settings. After fixes, `pnpm exec vitest run packages/assets/__tests__/model-texture-pass.spec.ts -t 'rejects ASTC mip payload|preserves optional upstream Basis|isolates ASTC|round-trips mixed|recalls quality from' --silent` passed eight tests (46 skipped, exit 0, 1.37 s), covering the five regressions plus mixed ownership and Basis/ASTC warm replay. The optional-Basis cases compare the exact before/after GLB bytes within the same unchanged document; the legacy behavior is also confirmed in HEAD's compression-counter branch. Assets typecheck, focused Biome (five complexity warnings, no errors) and `git diff --check` passed. Previously accepted core and metadata source/test SHA-256 guards remain unchanged. + + **Still OPEN at 0%:** this local upstream-Basis regression does not prove the original broad pre-change web/desktop byte-identity requirement; absent-versus-false ASTC settings alone do not establish that baseline. Upstream Basis-to-ASTC mobile conversion, embedded-image packaging inspection, generated lightmaps, native QuickJS/JSC game proof, GPU/performance and visual acceptance remain open. No dependency, decoder/parser framework, native run, model retry or Git/GitHub mutation was added. All original boxes remain unticked. + + **Test corrections follow-up (2026-10-10):** the full nearest run finished locally in 121.88 s (exit 1; 113 passed, seven failed) after laptop offload refused at 100°C. Registering the existing ASTC extension in the two test readers and correcting only the two mobile report expectations to Meshopt-only exposed four real downstream failures (three passed, four failed, exit 1, 45.92 s): three mobile cap cases still parsed ASTC as PNG, and the WebP case explicitly required lossless PNG. Mobile dimension-only assertions now use the existing KTX2/PNG branches without changing caps; the excluded `codec: "none"` base-colour image additionally equals its authored bytes exactly, and the direct `decoderFree: true` PNG reference is unchanged. A real emitted Android base-colour ASTC is independently decoded/scored against the existing sRGB-resampled source at the original SSIM 0.95 / ΔE00 3 floor; no quality or source-colour assertion was removed or loosened. The six corrected cases passed (114 skipped, exit 0, 49.76 s). Assets typecheck, focused Biome and diff checks passed. The lossless WebP test remains unchanged apart from reader registration and still fails `image/ktx2` versus required `image/png`; preserving it through the existing direct decoder-free seam is proposed for owner review, not implemented. No full rerun, production edit or broad/native/GPU/visual completion claim follows these test corrections; original boxes remain at 0%. + + **WebP contract split follow-up (2026-10-10):** the original mobile compile fixture remains an integration case, now asserting final raw ASTC sRGB vkFormat 158, NONE supercompression, a 4×4 2D image with all three 16-byte mip payloads and no Basis/WebP extension. Its actual final ASTC pixels pass the unchanged `expectColours(..., 4, 1)` helper. Independently decoding/scoring that final image against the original decoded 8×8 WebP resampled in sRGB passes the original SSIM 0.95 / ΔE00 3 production floor. The original lossless PNG dimension and colour assertions also remain unchanged in a separate direct `compressEmbeddedTextures(document, "prop.glb", { decoderFree: true, maxSize: 4 })` case; no helper tolerance, quality floor or source-colour assertion was loosened. Initial focused run preserved the direct case while reproducing the old mobile PNG mismatch (one passed, one failed; exit 1, 5.44 s); the corrected focused pair passed (exit 0, 4.40 s). The full nearest `compile.spec.ts` plus `model-texture-pass.spec.ts` run then passed all 121 tests (exit 0, 152.40 s). Assets typecheck, focused Biome and diff check passed. This resolves the preceding WebP test conflict; original broad baseline byte identity, native/GPU/game/visual and other unfinished production scope remain open at 0%. No production edit, dependency, commit or GitHub action was performed. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index fc77ec1e19..347822c948 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -221,13 +221,17 @@ describe("compileAssets", () => { expect(mobile.skippedCompression).toEqual([ { bytes: 0, - decoders: ["meshopt", "KTX2"], + decoders: ["meshopt"], files: 0, kind: "model", reason: "platform", }, ]); + expect( + mobile.skippedCompression.flatMap((row) => ("decoders" in row ? row.decoders : [])), + ).not.toContain("KTX2"); + // The same config, the target that can decode it: compression ships. await rm(path.join(root, "public"), { force: true, recursive: true }); await compileAssets({ @@ -243,6 +247,81 @@ describe("compileAssets", () => { expect(webManifest.entries["rock.png"]?.output).toMatch(/\.ktx2$/u); }); + it.each(["android", "ios"] as const)( + "cooks embedded ASTC on %s while retaining Meshopt", + async (platform) => { + const root = await makeTempDir("threenative-embedded-astc-"); + await mkdir(path.join(root, "assets")); + await writeFile(path.join(root, "assets", "character.glb"), await singleImageFixtureGlb()); + const result = await compileAssets({ + cwd: root, + platform, + runtimeDecoders: { ktx2: false, meshopt: true }, + config: { + budget: "none", + textures: "none", + lod: "none", + models: { + sharedImages: false, + compact: false, + virtual: "none", + textures: { overrides: [{ slot: "baseColorTexture", codec: "etc1s" }] }, + }, + }, + }); + expect(result.skippedCompression.filter((row) => row.kind === "model")).toEqual([]); + const manifest = JSON.parse( + await readFile(path.join(root, "public/assets.manifest.json"), "utf8"), + ); + const bytes = await readFile( + path.join(root, "public", manifest.entries["character.glb"].output), + ); + const { json, bin } = unpackGlb(bytes); + expect(json.extensionsRequired).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).not.toContain("KHR_texture_basisu"); + expect(json.extensionsRequired).toContain("EXT_meshopt_compression"); + const texture = json.textures?.[0]; + const source = (texture?.extensions?.EXT_texture_astc as { source: number }).source; + expect(texture?.source).toBeUndefined(); + const image = json.images?.[source]; + const view = json.bufferViews?.[image?.bufferView ?? -1]; + if (!view || !bin) throw new Error("embedded ASTC view missing"); + const astc = readKtx2( + bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength), + ); + expect(astc.vkFormat).toBe(158); + expect(astc.supercompressionScheme).toBe(0); + expect(astc.levelCount).toBe( + Math.floor(Math.log2(Math.max(astc.pixelWidth, astc.pixelHeight))) + 1, + ); + expect(astc.dataFormatDescriptor[0]?.colorPrimaries).toBe(1); + await compileAssets({ + cwd: root, + platform, + runtimeDecoders: { ktx2: false, meshopt: true }, + config: { + budget: "none", + textures: "none", + lod: "none", + models: { + sharedImages: false, + compact: false, + virtual: "none", + textures: "none", + }, + }, + }); + const disabled = JSON.parse( + await readFile(path.join(root, "public/assets.manifest.json"), "utf8"), + ); + const disabledJson = unpackGlb( + await readFile(path.join(root, "public", disabled.entries["character.glb"].output)), + ).json; + expect(disabledJson.extensionsUsed).not.toContain("EXT_texture_astc"); + expect(disabledJson.images?.map((image) => image.mimeType)).toEqual(["image/png"]); + }, + ); + it.each([ ["android", true, true], ["android", true, false], @@ -488,7 +567,10 @@ describe("compileAssets", () => { expect(shared).toHaveLength(1); expect(shared[0]).toMatch(/\.none\.png$/u); expect(lines.join("\n")).toContain("meshopt"); - expect(lines.join("\n")).toContain("KTX2"); + expect(lines.join("\n")).not.toContain("KTX2"); + expect(result.skippedCompression.find((row) => row.kind === "model")).toMatchObject({ + decoders: ["meshopt"], + }); expect(lines.join("\n")).not.toContain("dedupe"); } finally { log.mockRestore(); diff --git a/packages/assets/__tests__/image-quality.spec.ts b/packages/assets/__tests__/image-quality.spec.ts index 1ee5a40f61..beb17c9afa 100644 --- a/packages/assets/__tests__/image-quality.spec.ts +++ b/packages/assets/__tests__/image-quality.spec.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import path from "node:path"; @@ -23,6 +24,60 @@ function pixels(noise = false): Uint8Array { } describe("image quality instrument", () => { + it.each([ + { slot: "normalTexture", rgba: [128, 128, 255, 255], srgb: false, primaries: 0 as const }, + { + slot: "metallicRoughnessTexture", + rgba: [10, 128, 255, 255], + srgb: false, + primaries: 0 as const, + }, + { slot: "baseColorTexture", rgba: [240, 20, 40, 128], srgb: true, primaries: 1 as const }, + ])( + "glTF ASTC metadata matches $slot before final-buffer scoring", + async ({ slot, rgba, srgb, primaries }) => { + const data = new Uint8Array(8 * 8 * 4); + for (let i = 0; i < data.length; i += 4) data.set(rgba, i); + const basis = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 8, height: 8 }), + isUASTC: true, + isNormalMap: slot === "normalTexture", + isSetKTX2SRGBTransferFunc: srgb, + }); + const original = await transcodeKtx2ToAstc(basis); + // Exact default bytes captured before this change at e49bb087f (8x8 RGBA fixture). + if (srgb) + expect(createHash("sha256").update(original).digest("hex")).toBe( + "2e75ef39257d356a9c433626b70fbe7fa25528734c7342fc6deccfa83aedbaf4", + ); + const final = await transcodeKtx2ToAstc(basis, primaries); + const before = readKTX2(original); + const after = readKTX2(final); + const beforeDescriptor = before.dataFormatDescriptor[0]; + const afterDescriptor = after.dataFormatDescriptor[0]; + if (beforeDescriptor === undefined || afterDescriptor === undefined) + throw new Error("fixture descriptor missing"); + expect(afterDescriptor.colorPrimaries).toBe(primaries); + expect(after.vkFormat).toBe(srgb ? 158 : 157); + expect(afterDescriptor.transferFunction).toBe(srgb ? 2 : 1); + expect(afterDescriptor.flags).toBe(0); + expect(after.keyValue.KTXorientation).toBeUndefined(); + expect(after.levels.map((level) => level.levelData.byteLength)).toEqual([64, 16, 16, 16]); + expect(after.levels.map((level) => level.levelData)).toEqual( + before.levels.map((level) => level.levelData), + ); + const score = await measureKtx2(data, final, 8, 8, { slots: [slot] }); + expect(score.status).toBe(srgb ? "pass" : "unvalidated-slots"); + afterDescriptor.colorPrimaries = beforeDescriptor.colorPrimaries; + expect(writeKTX2(after)).toEqual(original); + expect(await transcodeKtx2ToAstc(basis)).toEqual(original); + await expect(transcodeKtx2ToAstc(basis, 2 as 0)).rejects.toThrow(/TN_ASSETS_ASTC_METADATA/u); + await expect(transcodeKtx2ToAstc(basis, srgb ? 0 : 1)).rejects.toThrow( + /TN_ASSETS_ASTC_METADATA/u, + ); + }, + ); + it.each([false, true])( "transcodes every ETC1S/UASTC mip directly to raw ASTC (UASTC=%s)", async (isUASTC) => { diff --git a/packages/assets/__tests__/model-texture-pass.spec.ts b/packages/assets/__tests__/model-texture-pass.spec.ts index b6d0943128..b7ebe69cae 100644 --- a/packages/assets/__tests__/model-texture-pass.spec.ts +++ b/packages/assets/__tests__/model-texture-pass.spec.ts @@ -2,8 +2,8 @@ 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, EXTTextureWebP } from "@gltf-transform/extensions"; -import { read as readKTX2 } from "ktx-parse"; +import { ALL_EXTENSIONS, EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions"; +import { read as readKTX2, write as writeKTX2 } from "ktx-parse"; import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer"; import { PNG } from "pngjs"; import { describe, expect, it, vi } from "vitest"; @@ -11,13 +11,16 @@ import { buildFixtureDocument } from "../../../test-support/generate-fixture-mod import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; +import { EXTTextureAstc, createGltfReader, readGltfDocument } from "../src/gltf-io.js"; import * as qualityInstrument from "../src/image-quality.js"; import { type IAssetSourceConfig, compileAssets } from "../src/index.js"; import * as encoder from "../src/ktx2-encoder.js"; +import { decodeImageBytes } from "../src/passes/decode-image.js"; import { codecLadder, compressEmbeddedTextures, encodeTextureLadder, + resampleRgba, } from "../src/passes/model-textures.js"; import { modelPass } from "../src/passes/model.js"; import { createSharedImageStore } from "../src/passes/shared-images.js"; @@ -33,7 +36,7 @@ import { formatTextureRungs } from "../src/report.js"; /** Re-reads pass output the way the runtime does: every extension registered. */ async function readOutput(buffer: Buffer): Promise { const io = new NodeIO() - .registerExtensions(ALL_EXTENSIONS) + .registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); return io.readJSON(await io.binaryToJSON(new Uint8Array(buffer))); } @@ -563,6 +566,166 @@ describe("embedded model textures", () => { } }); + it("isolates ASTC shared cache and replays its required extension", async () => { + const root = await makeTempDir("threenative-astc-shared-"); + const input = await fixtureWithTextures({ width: 32 }); + const textures = { + overrides: [ + { slot: "baseColorTexture", codec: "etc1s" as const }, + { slot: "normalTexture", codec: "none" as const }, + ], + }; + const basis = await compiled(input, { sharedImages: createSharedImageStore(root), textures }); + const nonAstc = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: { ...textures, astc: false }, + }); + expect(nonAstc.buffer).toEqual(basis.buffer); + expect(modelPass({ textures }).configuration).toEqual( + modelPass({ textures: { ...textures, astc: false } }).configuration, + ); + const cold = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: { ...textures, astc: true }, + }); + expect(cold.buffer).not.toEqual(basis.buffer); + const encode = vi.spyOn(encoder, "encodeToKTX2"); + try { + const warm = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: { ...textures, astc: true }, + }); + expect(warm.buffer).toEqual(cold.buffer); + expect(warm.entry.embeddedTextures).toEqual(cold.entry.embeddedTextures); + expect(warm.entry.extensions).toContain("EXT_texture_astc"); + expect(warm.entry.extensions).not.toContain("KHR_texture_basisu"); + expect(encode).not.toHaveBeenCalled(); + } finally { + encode.mockRestore(); + } + }); + + it.each([0, 1, "uncompressed-length"] as const)( + "rejects ASTC mip payload mismatch at %s despite a complete level count", + async (mip) => { + const document = buildFixtureDocument(); + await compressEmbeddedTextures(document, "short-mip.glb", { + astc: true, + overrides: [ + { slot: "baseColorTexture", codec: "etc1s" }, + { slot: "normalTexture", codec: "none" }, + ], + }); + const raw = document + .getRoot() + .listTextures() + .find((texture) => texture.getMimeType() === "image/ktx2"); + if (!raw) throw new Error("raw ASTC missing"); + const container = readKTX2(raw.getImage() ?? new Uint8Array()); + const level = container.levels[typeof mip === "number" ? mip : 1]; + if (!level) throw new Error("fixture mip missing"); + if (typeof mip === "number") + level.levelData = level.levelData.subarray(0, level.levelData.byteLength - 16); + level.uncompressedByteLength -= 16; + raw.setImage(writeKTX2(container)); + expect(readKTX2(raw.getImage() ?? new Uint8Array()).levelCount).toBe(container.levelCount); + await expect( + new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]).writeBinary(document), + ).rejects.toThrow("TN_ASSETS_ASTC_METADATA"); + }, + ); + + it.each([undefined, false])( + "preserves optional upstream Basis bytes with astc=%s and no new compression", + async (astc) => { + const document = buildFixtureDocument(); + const base = document.getRoot().listTextures()[0]; + if (!base) throw new Error("fixture base missing"); + base + .setImage( + await encoder.encodeToKTX2(base.getImage() ?? new Uint8Array(), { + generateMipmap: true, + imageDecoder: async (bytes) => PNG.sync.read(Buffer.from(bytes)), + }), + ) + .setMimeType("image/ktx2"); + const extension = document.createExtension(KHRTextureBasisu).setRequired(false); + const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); + const before = await io.writeBinary(document); + await compressEmbeddedTextures(document, "upstream.glb", { + ...(astc === undefined ? {} : { astc }), + overrides: [{ slot: "normalTexture", codec: "none" }], + }); + expect(extension.isRequired()).toBe(false); + expect(await io.writeBinary(document)).toEqual(before); + }, + ); + + it("round-trips mixed Basis and ASTC without either writer stealing an image", async () => { + const document = buildFixtureDocument(); + const normal = document.getRoot().listTextures()[1]; + if (!normal) throw new Error("fixture normal missing"); + const normalBytes = await encoder.encodeToKTX2(normal.getImage() ?? new Uint8Array(), { + generateMipmap: true, + imageDecoder: async (bytes) => PNG.sync.read(Buffer.from(bytes)), + isUASTC: true, + isPerceptual: false, + isSetKTX2SRGBTransferFunc: false, + }); + normal.setImage(normalBytes).setMimeType("image/ktx2"); + document.createExtension(KHRTextureBasisu).setRequired(true); + await compressEmbeddedTextures(document, "mixed.glb", { + astc: true, + overrides: [{ slot: "baseColorTexture", codec: "etc1s" }], + }); + const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); + const output = Buffer.from(await io.writeBinary(document)); + const json = (await io.binaryToJSON(output)).json; + expect(json.extensionsRequired).toEqual( + expect.arrayContaining(["EXT_texture_astc", "KHR_texture_basisu"]), + ); + expect(json.textures?.filter((t) => t.extensions?.EXT_texture_astc)).toHaveLength(1); + expect(json.textures?.filter((t) => t.extensions?.KHR_texture_basisu)).toHaveLength(1); + const reread = await readGltfDocument(await createGltfReader(output), output); + expect( + reread + .getRoot() + .listTextures() + .map((t) => readKTX2(t.getImage() ?? new Uint8Array()).vkFormat) + .sort(), + ).toEqual([0, 158]); + expect( + reread + .getRoot() + .listTextures() + .map((texture) => texture.getImage()), + ).toEqual( + document + .getRoot() + .listTextures() + .map((texture) => texture.getImage()), + ); + const second = (await io.binaryToJSON(await io.writeBinary(reread))).json; + expect(second.textures).toEqual(json.textures); + expect(second.extensionsRequired).toEqual(json.extensionsRequired); + const paired = await io.binaryToJSON(output); + const astc = paired.json.textures?.find((t) => t.extensions?.EXT_texture_astc); + if (!astc) throw new Error("ASTC definition missing"); + astc.source = (astc.extensions?.EXT_texture_astc as { source: number }).source; + await expect(io.readJSON(paired)).rejects.toThrow("TN_ASSETS_ASTC_FALLBACK"); + const raw = document + .getRoot() + .listTextures() + .find((texture) => readKTX2(texture.getImage() ?? new Uint8Array()).vkFormat === 158); + if (!raw) throw new Error("raw ASTC missing"); + const invalid = readKTX2(raw.getImage() ?? new Uint8Array()); + const descriptor = invalid.dataFormatDescriptor[0]; + if (!descriptor) throw new Error("DFD missing"); + descriptor.colorPrimaries = 0; + raw.setImage(writeKTX2(invalid)); + await expect(io.writeBinary(document)).rejects.toThrow("TN_ASSETS_ASTC_METADATA"); + }); + it("recalls quality from the shared-image key on a second pass and disk store", async () => { const root = await makeTempDir("threenative-quality-cache-"); const input = await fixtureWithTextures({ width: 32 }); @@ -764,8 +927,14 @@ describe("embedded model textures", () => { .getRoot() .listTextures() .map((texture) => { - const png = PNG.sync.read(Buffer.from(texture.getImage() ?? [])); - return [png.width, png.height]; + const image = texture.getImage() ?? new Uint8Array(); + const shape = + texture.getMimeType() === "image/ktx2" + ? readKTX2(image) + : PNG.sync.read(Buffer.from(image)); + return "pixelWidth" in shape + ? [shape.pixelWidth, shape.pixelHeight] + : [shape.width, shape.height]; }); expect(sizes).toEqual([ [2048, 2048], @@ -812,10 +981,31 @@ describe("embedded model textures", () => { await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), ); const output = await new NodeIO() - .registerExtensions(ALL_EXTENSIONS) + .registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }) .read(path.join(root, "public", manifest.entries["caps.glb"].output)); const material = output.getRoot().listMaterials()[0]; + if (platform === "android") { + const source = await new NodeIO().readBinary( + await readFile(path.join(root, "assets", "caps.glb")), + ); + const pixels = PNG.sync.read( + Buffer.from(source.getRoot().listTextures()[0]?.getImage() ?? []), + ); + const final = material?.getBaseColorTexture()?.getImage() ?? new Uint8Array(); + expect(readKTX2(final).vkFormat).toBe(158); + const score = await qualityInstrument.measureKtx2( + resampleRgba(pixels.data, pixels.width, pixels.height, 256, 256, true), + final, + 256, + 256, + { slots: ["baseColorTexture"] }, + ); + expect(score.status).toBe("pass"); + expect(score.floor).toEqual({ ssim: 0.95, meanDeltaE00: 3 }); + expect(score.ssim).toBeGreaterThanOrEqual(0.95); + expect(score.meanDeltaE00).toBeLessThanOrEqual(3); + } for (const [texture, expected] of [ [material?.getBaseColorTexture(), 256], [material?.getNormalTexture(), 128], @@ -1039,11 +1229,27 @@ describe("embedded EXT_texture_webp", () => { expectColours(await transcodeFirstLevel(image), 4, 1); }); - it("should cap an over-cap WebP image to PNG on a target with no transcoder", async () => { + it("keeps an over-cap WebP lossless in the direct decoder-free pass", async () => { + const document = await new NodeIO() + .registerExtensions(ALL_EXTENSIONS) + .readBinary(await fixtureWithWebp(8)); + await compressEmbeddedTextures(document, "prop.glb", { decoderFree: true, maxSize: 4 }); + const cooked = document + .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("cooks an over-cap WebP to final ASTC on mobile without a runtime decoder", 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. + // this over-cap source must reach the final mobile image at 4x4. await writeFile(path.join(root, "assets", "prop.glb"), await fixtureWithWebp(8)); await compileAssets({ @@ -1060,16 +1266,56 @@ describe("embedded EXT_texture_webp", () => { throw new Error("no manifest entry for 'prop.glb'"); } expect(entry.extensions ?? []).not.toContain("EXT_texture_webp"); + expect(entry.extensions ?? []).not.toContain("KHR_texture_basisu"); + expect(entry.extensions ?? []).toContain("EXT_texture_astc"); 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); + expect(cooked?.getMimeType()).toBe("image/ktx2"); + const image = cooked?.getImage() ?? new Uint8Array(); + const container = readKTX2(image); + expect(container).toMatchObject({ + vkFormat: 158, + supercompressionScheme: 0, + pixelWidth: 4, + pixelHeight: 4, + pixelDepth: 0, + layerCount: 0, + faceCount: 1, + levelCount: 3, + }); + expect( + container.levels.map((level) => [level.levelData.byteLength, level.uncompressedByteLength]), + ).toEqual([ + [16, 16], + [16, 16], + [16, 16], + ]); + const authored = await readOutput(await readFile(path.join(root, "assets", "prop.glb"))); + const source = await decodeImageBytes( + Buffer.from( + authored + .getRoot() + .listTextures() + .find((texture) => texture.getName() === "checker") + ?.getImage() ?? [], + ), + "prop.glb#checker", + ); + const score = await qualityInstrument.measureKtx2( + resampleRgba(source.data, source.width, source.height, 4, 4, true), + image, + 4, + 4, + { slots: ["baseColorTexture"] }, + ); + expect(score.status).toBe("pass"); + expect(score.floor).toEqual({ ssim: 0.95, meanDeltaE00: 3 }); + expect(score.ssim).toBeGreaterThanOrEqual(0.95); + expect(score.meanDeltaE00).toBeLessThanOrEqual(3); + expectColours(await encoder.decodeAstcKtx2(image, 4, 4), 4, 1); }); it("should convert a WebP even when its KTX2 is larger", async () => { @@ -1314,10 +1560,7 @@ describe("embedded textures through the compile step", () => { expect(manifest.entries["prop.glb"]?.extensions).not.toContain("KHR_texture_basisu"); }); - it("should cap an embedded image on a decoder-free target by resampling it to PNG", async () => { - // Android has no Basis transcoder, so the 4096-square image cannot become KTX2. The declared - // cap still has to reach the artifact: the pass resamples and re-emits a PNG, updating the - // image mime type, and never declares KHR_texture_basisu. + it("should cap embedded images on mobile without a runtime Basis decoder", async () => { const root = await makeTempDir("threenative-model-textures-decoder-free-"); await mkdir(path.join(root, "assets")); await writeFile( @@ -1345,9 +1588,14 @@ describe("embedded textures through the compile step", () => { await readOutput(await readFile(path.join(root, "public", entry.output))) ).getRoot(); for (const texture of root2.listTextures()) { - expect(texture.getMimeType()).toBe("image/png"); const image = Buffer.from(texture.getImage() ?? new Uint8Array()); - expect(parsePng(image)).toMatchObject({ height: 1024, width: 1024 }); + const dimensions = + texture.getMimeType() === "image/ktx2" ? readKTX2(image) : PNG.sync.read(image); + expect( + "pixelWidth" in dimensions + ? [dimensions.pixelWidth, dimensions.pixelHeight] + : [dimensions.width, dimensions.height], + ).toEqual([1024, 1024]); } }, 180_000); @@ -1392,10 +1640,18 @@ describe("embedded textures through the compile step", () => { height: 512, width: 512, }); - expect(parsePng(Buffer.from(capped?.getImage() ?? new Uint8Array()))).toMatchObject({ - height: 128, - width: 128, - }); + const source = await new NodeIO().readBinary( + await readFile(path.join(root, "assets", "prop.glb")), + ); + expect(excluded?.getImage()).toEqual(source.getRoot().listTextures()[0]?.getImage()); + const image = capped?.getImage() ?? new Uint8Array(); + const dimensions = + capped?.getMimeType() === "image/ktx2" ? readKTX2(image) : PNG.sync.read(Buffer.from(image)); + expect( + "pixelWidth" in dimensions + ? [dimensions.pixelWidth, dimensions.pixelHeight] + : [dimensions.width, dimensions.height], + ).toEqual([128, 128]); }); it("should reject malformed embedded-texture and simplify config", async () => { diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index d7216f1185..fb52be2094 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -1561,7 +1561,9 @@ function parseModelQuantize(raw: unknown): IModelQuantizeOptions { function embeddedTexturesFor( configured: IModelTexturesOptions | "none" | undefined, ktx2: boolean, + astc = false, ): { readonly textures?: IModelTexturesOptions | "none" } { + if (astc && configured !== "none") return { textures: { ...configured, astc: true } }; if (ktx2) return {}; if (configured !== undefined && configured !== "none" && configured.maxSize !== undefined) { return { @@ -1617,6 +1619,9 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }; const runtimeDecoderAvailable = runtimeDecoderCapabilities.ktx2 && runtimeDecoderCapabilities.meshopt; + const mobileAstc = + (options.platform === "android" || options.platform === "ios") && + !runtimeDecoderCapabilities.ktx2; const audio = parseAudioConfig(config.audio); const configuredTextures = parseTexturesConfig(config.textures); const configuredModels = parseModelsConfig(config.models); @@ -1630,7 +1635,7 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo ? [] : [ (configuredModels.passes?.meshopt ?? true) ? "meshopt" : undefined, - configuredModels.textures === "none" ? undefined : "KTX2", + configuredModels.textures === "none" || mobileAstc ? undefined : "KTX2", ].filter((decoder): decoder is "meshopt" | "KTX2" => decoder !== undefined); const models = configuredModels === undefined @@ -1642,11 +1647,12 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo : { passes: { ...(configuredModels.passes ?? {}), meshopt: false }, }), - ...embeddedTexturesFor(configuredModels.textures, runtimeDecoderCapabilities.ktx2), + ...embeddedTexturesFor( + configuredModels.textures, + runtimeDecoderCapabilities.ktx2, + mobileAstc, + ), }; - const mobileAstc = - (options.platform === "android" || options.platform === "ios") && - !runtimeDecoderCapabilities.ktx2; const textures = configuredTextures === undefined ? undefined @@ -1664,7 +1670,15 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo const registerBuiltin = (pass: IAssetPass, spec: PassSpec): void => { if ( spec.needsRuntimeDecoder === true && - !(spec.kind === "texture" ? runtimeDecoderCapabilities.ktx2 : runtimeDecoderAvailable) + !(spec.kind === "texture" + ? runtimeDecoderCapabilities.ktx2 + : spec.kind === "model" + ? (!(spec.options.passes?.meshopt ?? true) || runtimeDecoderCapabilities.meshopt) && + (spec.options.textures === "none" || + spec.options.textures?.decoderFree === true || + spec.options.textures?.astc === true || + runtimeDecoderCapabilities.ktx2) + : runtimeDecoderAvailable) ) return; builtinPasses.push(pass); @@ -1777,7 +1791,11 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo ? "config" : modelCompressionDecoders.length === 0 ? undefined - : runtimeDecoderAvailable + : modelCompressionDecoders.every((decoder) => + decoder === "KTX2" + ? runtimeDecoderCapabilities.ktx2 + : runtimeDecoderCapabilities.meshopt, + ) ? undefined : "platform", modelCompressionDecoders, diff --git a/packages/assets/src/gltf-io.ts b/packages/assets/src/gltf-io.ts index fbef447d47..520a83b1ca 100644 --- a/packages/assets/src/gltf-io.ts +++ b/packages/assets/src/gltf-io.ts @@ -1,9 +1,108 @@ -import { type Document, type GLTF, NodeIO } from "@gltf-transform/core"; +import { + type Document, + Extension, + type GLTF, + NodeIO, + PropertyType, + type ReaderContext, + type Texture, + type WriterContext, +} from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { KHR_DF_MODEL_ASTC, read as readKTX2 } from "ktx-parse"; import { MeshoptDecoder } from "meshoptimizer"; import { TNDiscreteLod } from "./lod/extension.js"; import { TNVirtualGeometry } from "./virtual/extension.js"; +/** Raw ASTC owns this vendor-draft extension; Basis KTX2 owns KHR_texture_basisu. */ +export function isRawAstc(texture: Texture): boolean { + const image = texture.getImage(); + if (texture.getMimeType() !== "image/ktx2" || image === null) return false; + const container = readKTX2(image); + return ( + (container.vkFormat === 157 || container.vkFormat === 158) && + container.supercompressionScheme === 0 + ); +} + +export class EXTTextureAstc extends Extension { + static override EXTENSION_NAME = "EXT_texture_astc"; + override extensionName = EXTTextureAstc.EXTENSION_NAME; + override prereadTypes = [PropertyType.TEXTURE]; + override preread(context: ReaderContext): this { + for (const texture of context.jsonDoc.json.textures ?? []) { + const astc = texture.extensions?.[this.extensionName] as { source?: number } | undefined; + if (astc === undefined) continue; + if (texture.source !== undefined || texture.extensions?.KHR_texture_basisu !== undefined) + throw new Error( + "TN_ASSETS_ASTC_FALLBACK: paired authored ASTC fallbacks cannot yet be preserved.", + ); + if (!Number.isInteger(astc.source) || !context.jsonDoc.json.images?.[astc.source ?? -1]) + throw new Error("TN_ASSETS_ASTC_SOURCE: missing ASTC image source."); + texture.source = astc.source; + } + return this; + } + read(context: ReaderContext): this { + for (const definition of context.jsonDoc.json.textures ?? []) { + const astc = definition.extensions?.[this.extensionName] as { source: number } | undefined; + if (astc === undefined) continue; + const texture = context.textures[astc.source]; + if (texture === undefined || !isRawAstc(texture)) + throw new Error("TN_ASSETS_ASTC_SOURCE: extension source must be raw ASTC 4x4 KTX2."); + } + this.setRequired(true); + return this; + } + write(context: WriterContext): this { + for (const texture of this.document.getRoot().listTextures()) { + if (!isRawAstc(texture)) continue; + const image = texture.getImage(); + if (image === null) throw new Error("TN_ASSETS_ASTC_SOURCE: missing image bytes."); + const container = readKTX2(image); + const dfd = container.dataFormatDescriptor[0]; + if ( + container.pixelWidth <= 0 || + container.pixelHeight <= 0 || + container.pixelWidth > 0x7fffffff || + container.pixelHeight > 0x7fffffff || + container.pixelDepth !== 0 || + container.layerCount !== 0 || + container.faceCount !== 1 || + container.levelCount !== + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1 || + container.levels.length !== container.levelCount || + container.levels.some((level, mip) => { + const bytes = + 16 * + Math.ceil(Math.max(1, container.pixelWidth >> mip) / 4) * + Math.ceil(Math.max(1, container.pixelHeight >> mip) / 4); + return level.levelData.byteLength !== bytes || level.uncompressedByteLength !== bytes; + }) || + dfd?.colorModel !== KHR_DF_MODEL_ASTC || + dfd.texelBlockDimension.join() !== "3,3,0,0" || + dfd.bytesPlane.join() !== "16,0,0,0,0,0,0,0" || + dfd.colorPrimaries !== (container.vkFormat === 158 ? 1 : 0) || + dfd.transferFunction !== (container.vkFormat === 158 ? 2 : 1) || + (dfd.flags & 1) !== 0 || + (container.keyValue.KTXorientation !== undefined && + container.keyValue.KTXorientation !== "rd") + ) + throw new Error( + "TN_ASSETS_ASTC_METADATA: raw glTF ASTC must carry full mips, matching primaries/transfer, straight alpha and rd orientation.", + ); + const imageIndex = context.imageIndexMap.get(texture); + for (const definition of context.jsonDoc.json.textures ?? []) { + if (definition.source !== imageIndex) continue; + definition.extensions ??= {}; + definition.extensions[this.extensionName] = { source: imageIndex }; + definition.source = undefined; + } + } + return this; + } +} + const DRACO_EXTENSION = "KHR_draco_mesh_compression"; /** Reads the glTF JSON header out of a `.glb`/`.gltf` buffer without full parsing. */ @@ -40,7 +139,7 @@ export function peekExtensions(input: Buffer): ReadonlySet { export async function createGltfReader(input: Buffer): Promise { await MeshoptDecoder.ready; let io = new NodeIO() - .registerExtensions([...ALL_EXTENSIONS, TNVirtualGeometry, TNDiscreteLod]) + .registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc, TNVirtualGeometry, TNDiscreteLod]) .registerDependencies({ "meshopt.decoder": MeshoptDecoder }); if (peekExtensions(input).has(DRACO_EXTENSION)) { const { createDecoderModule } = await import("draco3dgltf"); diff --git a/packages/assets/src/image-quality.ts b/packages/assets/src/image-quality.ts index 895d42f4a7..54a0ca5248 100644 --- a/packages/assets/src/image-quality.ts +++ b/packages/assets/src/image-quality.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { KHR_DF_MODEL_ASTC, + KHR_DF_TRANSFER_LINEAR, KHR_DF_TRANSFER_SRGB, KHR_SUPERCOMPRESSION_NONE, VK_FORMAT_ASTC_4x4_SRGB_BLOCK, @@ -346,9 +347,19 @@ async function basisModule(): Promise { } /** Cook the measured Basis result to decoder-free ASTC without re-encoding its pixels. */ -export async function transcodeKtx2ToAstc(encoded: Uint8Array): Promise { +export async function transcodeKtx2ToAstc( + encoded: Uint8Array, + gltfColorPrimaries?: 0 | 1, +): Promise { const container = readKTX2(encoded); const descriptor = container.dataFormatDescriptor[0]; + if ( + gltfColorPrimaries !== undefined && + ((gltfColorPrimaries !== 0 && gltfColorPrimaries !== 1) || + descriptor?.transferFunction !== + (gltfColorPrimaries === 0 ? KHR_DF_TRANSFER_LINEAR : KHR_DF_TRANSFER_SRGB)) + ) + throw new Error("TN_ASSETS_ASTC_METADATA: explicit glTF primaries must be 0/linear or 1/sRGB."); if ( container.vkFormat !== 0 || container.pixelWidth <= 0 || @@ -390,6 +401,7 @@ export async function transcodeKtx2ToAstc(encoded: Uint8Array): Promise>; export interface IModelTexturesOptions { + /** Internal target-selected raw ASTC output. */ + readonly astc?: boolean; readonly floor?: Partial; /** Internal reporting switch; selection still measures and enforces the floor. */ readonly measureQuality?: boolean; @@ -111,6 +114,7 @@ const MIP_FACTOR = 4 / 3; /** Bytes per pixel of the format each codec transcodes to on a desktop GPU. */ const GPU_BYTES_PER_PIXEL: Readonly> = { // BC1 from ETC1S, BC7 from UASTC, RGBA8 for anything left uncompressed. + astc4x4: 1, etc1s: 0.5, none: 4, uastc: 1, @@ -444,6 +448,7 @@ export async function encodeTextureLadder( height: number, options: IImageQualityOptions & { readonly astc?: boolean; + readonly gltfColorPrimaries?: 0 | 1; readonly forced?: TextureCodec; /** Automatic source retention: candidates that cannot save bytes need no quality score. */ readonly maxBytes?: number; @@ -483,7 +488,9 @@ export async function encodeTextureLadder( ...(candidate.rdoLambda === undefined ? {} : { rdoLambda: candidate.rdoLambda }), }); const encoded = - options.astc === true && basis !== undefined ? await transcodeKtx2ToAstc(basis) : basis; + options.astc === true && basis !== undefined + ? await transcodeKtx2ToAstc(basis, options.gltfColorPrimaries) + : basis; if (encoded !== undefined && options.maxBytes !== undefined) { if (encoded.byteLength >= options.maxBytes) { // ponytail: ETC1S is the ladder's lowest bitrate, so on a large image the UASTC rungs it @@ -737,6 +744,9 @@ export async function compressEmbeddedTextures( floor: options.floor, srgb, quality, + ...(options.astc === true + ? { astc: true, gltfColorPrimaries: srgb ? (1 as const) : (0 as const) } + : {}), ...(!explicit && target.width === decoded.width && target.height === decoded.height && @@ -800,15 +810,20 @@ export async function compressEmbeddedTextures( }; } texture.setImage(encoded).setMimeType("image/ktx2"); - formats[key] = selected.codec; + formats[key] = options.astc === true ? "astc4x4" : selected.codec; bytesAfter += encoded.byteLength; - gpuBytesAfter += gpuBytes(target.width, target.height, selected.codec); + gpuBytesAfter += gpuBytes(target.width, target.height, formats[key] ?? selected.codec); compressed += 1; } - if (compressed > 0) { - // Required, not merely used: a reader with no Basis transcoder cannot draw this model, - // and a glTF that pretends otherwise fails at the first frame instead of at load. + const hasAstc = textures.some(isRawAstc); + if (hasAstc) document.createExtension(EXTTextureAstc).setRequired(true); + else document.disposeExtension(EXTTextureAstc.EXTENSION_NAME); + if (options.astc === true || hasAstc) { + if (textures.some((texture) => texture.getMimeType() === "image/ktx2" && !isRawAstc(texture))) + document.createExtension(KHRTextureBasisu).setRequired(true); + else document.disposeExtension(KHRTextureBasisu.EXTENSION_NAME); + } else if (compressed > 0) { document.createExtension(KHRTextureBasisu).setRequired(true); } // A glTF writer emits every extension still attached, so a model authored with WebP images diff --git a/packages/assets/src/passes/model.ts b/packages/assets/src/passes/model.ts index 5f2af3d87b..cf170207ed 100644 --- a/packages/assets/src/passes/model.ts +++ b/packages/assets/src/passes/model.ts @@ -29,7 +29,7 @@ import { classify, } from "../compile.js"; import { type IMaterialMergeSummary, mergeIdenticalMaterials } from "../foliage.js"; -import { createGltfReader, readGltfDocument } from "../gltf-io.js"; +import { EXTTextureAstc, createGltfReader, readGltfDocument } from "../gltf-io.js"; import { IMAGE_QUALITY_IDENTITY, type ITextureQuality, @@ -322,7 +322,7 @@ async function writeDocument( await MeshoptEncoder.ready; const io = new NodeIO() .setVertexLayout(vertexLayout === "separate" ? VertexLayout.SEPARATE : VertexLayout.INTERLEAVED) - .registerExtensions([...ALL_EXTENSIONS, TNVirtualGeometry, TNDiscreteLod]) + .registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc, TNVirtualGeometry, TNDiscreteLod]) .registerDependencies({ "meshopt.encoder": MeshoptEncoder }); try { const buffer = Buffer.from(await io.writeBinary(document)); @@ -871,6 +871,7 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass { options.textures === "none" ? "none" : { + ...(options.textures?.astc === true ? { astc: "raw4x4-gltf-primaries-v1" } : {}), decoderFree: options.textures?.decoderFree ?? false, encoder: KTX2_ENCODER_VERSION, instrument: IMAGE_QUALITY_IDENTITY, @@ -889,7 +890,9 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass { // geometry rewrites and shared-image emission remain plain glTF and stay enabled there. needsRuntimeDecoder: (options.passes?.meshopt ?? true) || - (options.textures !== "none" && options.textures?.decoderFree !== true), + (options.textures !== "none" && + options.textures?.decoderFree !== true && + options.textures?.astc !== true), apply: async (input: Buffer, logicalPath: string): Promise => { if (classify(logicalPath) !== "model") return input; const passes = options.passes ?? {}; @@ -1152,6 +1155,7 @@ function sharedSettings( textureOptions === undefined ? "none" : { + ...(textureOptions.astc === true ? { astc: "raw4x4-gltf-primaries-v1" } : {}), decoderFree: textureOptions.decoderFree ?? false, encoder: KTX2_ENCODER_VERSION, instrument: IMAGE_QUALITY_IDENTITY, @@ -1293,7 +1297,7 @@ async function writeAndVerifyShared( await MeshoptEncoder.ready; const io = new NodeIO() .setVertexLayout(vertexLayout === "separate" ? VertexLayout.SEPARATE : VertexLayout.INTERLEAVED) - .registerExtensions([...ALL_EXTENSIONS, TNVirtualGeometry, TNDiscreteLod]) + .registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc, TNVirtualGeometry, TNDiscreteLod]) .registerDependencies({ "meshopt.encoder": MeshoptEncoder }); // Encoded bytes → every store key they were filed under. Equal encoded bytes can come from // distinct source keys, so each writer callback consumes one deterministic candidate rather diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 168eb9fa8f..efa4c8a3ab 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -5,6 +5,7 @@ import { BufferGeometry, type CompressedTexture, Group, + ImageBitmapLoader, InterleavedBuffer, InterleavedBufferAttribute, Mesh, @@ -12,6 +13,7 @@ import { MeshStandardMaterial, MirroredRepeatWrapping, NoColorSpace, + RGBA_ASTC_4x4_Format, RepeatWrapping, SRGBColorSpace, Texture, @@ -806,6 +808,222 @@ describe("IAssetLoader compressed textures", () => { const S3TC = { WEBGL_compressed_texture_s3tc: true }; + // Actual encoder/emitter capture at e49bb087f, shared with native-ktx2.spec.ts: + // separate UNORM/sRGB 8x8 RGBA [240,20,40,128] UASTC cooks, all four mip levels. + it.each([ + { + vkFormat: 157, + base64: + "q0tUWCAyMLsNChoKnQAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQEAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", + }, + { + vkFormat: 158, + base64: + "q0tUWCAyMLsNChoKngAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", + }, + ])( + "EXT_texture_astc raw vkFormat $vkFormat parses without transcoder init", + async ({ vkFormat, base64 }) => { + const bytes = Uint8Array.from(Buffer.from(base64, "base64")); + expect(new DataView(bytes.buffer).getUint32(12, true)).toBe(vkFormat); + const loader = new KTX2Loader(); + loader.detectSupport({ + isWebGPURenderer: true, + hasFeature: (name: string) => name === "texture-compression-astc", + } as Parameters[0]); + const init = vi.spyOn(loader, "init").mockImplementation(() => { + throw new Error("unexpected transcoder init"); + }); + const texture = await new Promise((resolve, reject) => + loader.parse(bytes.buffer, resolve, reject), + ); + expect(texture.format).toBe(RGBA_ASTC_4x4_Format); + expect(texture.image).toMatchObject({ width: 8, height: 8 }); + expect(texture.mipmaps.map((mip) => [mip.width, mip.height, mip.data.byteLength])).toEqual([ + [8, 8, 64], + [4, 4, 16], + [2, 2, 16], + [1, 1, 16], + ]); + for (const mip of texture.mipmaps) { + expect(Buffer.from(mip.data).toString("hex")).toBe( + "fcfdfffffffffffff0f0141428288080".repeat(mip.data.byteLength / 16), + ); + } + expect(init).not.toHaveBeenCalled(); + texture.dispose(); + loader.dispose(); + }, + ); + + function astcFixture(required: boolean, fallback = true): string { + return JSON.stringify({ + asset: { version: "2.0" }, + extensionsUsed: ["EXT_texture_astc"], + ...(required ? { extensionsRequired: ["EXT_texture_astc"] } : {}), + images: [ + { uri: "raw.ktx2", mimeType: "image/ktx2" }, + { uri: "fallback.png", mimeType: "image/png" }, + ], + textures: [ + { ...(fallback ? { source: 1 } : {}), extensions: { EXT_texture_astc: { source: 0 } } }, + ], + materials: [{ pbrMetallicRoughness: { baseColorTexture: { index: 0 } } }], + meshes: [{ primitives: [{ attributes: {}, material: 0 }] }], + nodes: [{ mesh: 0 }], + scenes: [{ nodes: [0] }], + scene: 0, + }); + } + + function astcImageIo(json: string) { + vi.stubGlobal("self", globalThis); + vi.stubGlobal("createImageBitmap", vi.fn()); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string | URL | RequestInfo) => + String(url).endsWith(".gltf") + ? new Response(json) + : manifestResponse({ + version: 1, + entries: { + "astc.gltf": { output: "astc.gltf", kind: "model", bytes: json.length, passes: [] }, + }, + }), + ), + ); + const texture = new Texture(); + const bitmap = { width: 4, height: 4 } as ImageBitmap; + const astc = vi.spyOn(KTX2Loader.prototype, "load").mockImplementation((_url, onLoad) => { + onLoad?.(texture as CompressedTexture); + return undefined; + }); + const fallback = vi + .spyOn(ImageBitmapLoader.prototype, "load") + .mockImplementation((_url, onLoad) => { + onLoad?.(bitmap); + }); + const detect = vi.spyOn(KTX2Loader.prototype, "detectSupport"); + const init = vi.spyOn(KTX2Loader.prototype, "init").mockImplementation(() => { + throw new Error("unexpected transcoder init"); + }); + return { texture, bitmap, astc, fallback, detect, init }; + } + + it.each(["webgl", "webgpu"])( + "EXT_texture_astc uses the shared loader on %s with one support detection", + async (backend) => { + const io = astcImageIo(astcFixture(true)); + const attach = vi.spyOn(GLTFLoader.prototype, "setKTX2Loader"); + const renderer = + backend === "webgpu" + ? { + isWebGPURenderer: true, + hasFeature: (name: string) => name === "texture-compression-astc", + } + : { + extensions: { + has: (name: string) => name === "WEBGL_compressed_texture_astc", + get: () => ({ getSupportedProfiles: () => ["ldr"] }), + }, + }; + const assets = createAssetLoader({ basePath: "/assets", renderer }); + const shared = await assets.compressedTextures?.loader; + const value = await assets.model<{ scene: Group }>("astc.gltf"); + expect(((value.scene.children[0] as Mesh).material as MeshStandardMaterial).map).toBe( + io.texture, + ); + expect(io.astc).toHaveBeenCalledOnce(); + expect(io.astc.mock.contexts[0]).toBe(shared); + expect(attach).toHaveBeenCalledWith(shared); + expect(io.fallback).not.toHaveBeenCalled(); + expect(io.detect).toHaveBeenCalledOnce(); + expect(io.init).not.toHaveBeenCalled(); + }, + ); + + it.each([S3TC, { WEBGL_compressed_texture_etc: true }])( + "EXT_texture_astc required rejects other compressed-format support %#", + async (formats) => { + const io = astcImageIo(astcFixture(true)); + const assets = createAssetLoader({ basePath: "/assets", renderer: webglRenderer(formats) }); + await expect(assets.model("astc.gltf")).rejects.toThrow(/TN_ASSETS_ASTC_UNSUPPORTED/u); + expect(io.astc).not.toHaveBeenCalled(); + expect(io.fallback).not.toHaveBeenCalled(); + }, + ); + + it.each([S3TC, {}, undefined])( + "EXT_texture_astc optional uses ordinary fallback without ASTC support %#", + async (formats) => { + const io = astcImageIo(astcFixture(false)); + const assets = createAssetLoader({ + basePath: "/assets", + ...(formats === undefined ? {} : { renderer: webglRenderer(formats) }), + }); + const value = await assets.model<{ scene: Group }>("astc.gltf"); + expect(((value.scene.children[0] as Mesh).material as MeshStandardMaterial).map?.image).toBe( + io.bitmap, + ); + expect(io.astc).not.toHaveBeenCalled(); + expect(io.fallback).toHaveBeenCalledOnce(); + expect(io.init).not.toHaveBeenCalled(); + if (assets.compressedTextures !== undefined) + await expect(assets.compressedTextures.ready).resolves.toBeUndefined(); + }, + ); + + it.each([undefined, {}])( + "EXT_texture_astc required rejects a missing loader %#", + async (renderer) => { + const io = astcImageIo(astcFixture(true)); + const assets = createAssetLoader({ + basePath: "/assets", + ...(renderer === undefined ? {} : { renderer }), + }); + await expect(assets.model("astc.gltf")).rejects.toThrow(/TN_ASSETS_ASTC_UNSUPPORTED/u); + expect(io.astc).not.toHaveBeenCalled(); + expect(io.fallback).not.toHaveBeenCalled(); + }, + ); + + it("EXT_texture_astc optional propagates arbitrary shared-loader failure", async () => { + const io = astcImageIo(astcFixture(false)); + const failure = new Error("loader setup failed"); + vi.spyOn(KTX2Loader.prototype, "setWorkerLimit").mockImplementation(() => { + throw failure; + }); + const assets = createAssetLoader({ basePath: "/assets", renderer: webglRenderer(S3TC) }); + await expect(assets.model("astc.gltf")).rejects.toThrow( + /TN_ASSETS_UNRESOLVED:.*loader setup failed/u, + ); + expect(io.astc).not.toHaveBeenCalled(); + expect(io.fallback).not.toHaveBeenCalled(); + }); + + it.each([undefined, 99])( + "EXT_texture_astc optional cannot invent an absent or invalid fallback %#", + async (source) => { + const json = JSON.parse(astcFixture(false, false)); + if (source !== undefined) json.textures[0].source = source; + const io = astcImageIo(JSON.stringify(json)); + const assets = createAssetLoader({ basePath: "/assets", renderer: webglRenderer(S3TC) }); + await expect(assets.model("astc.gltf")).rejects.toThrow(); + expect(io.astc).not.toHaveBeenCalled(); + expect(io.fallback).not.toHaveBeenCalled(); + }, + ); + + it("EXT_texture_astc optional cannot swallow required Basis loader refusal", async () => { + const json = JSON.parse(astcFixture(false)); + json.extensionsUsed.push("KHR_texture_basisu"); + json.extensionsRequired = ["KHR_texture_basisu"]; + const io = astcImageIo(JSON.stringify(json)); + const assets = createAssetLoader({ basePath: "/assets", renderer: webglRenderer({}) }); + await expect(assets.model("astc.gltf")).rejects.toThrow(/TN_ASSETS_KTX2_UNSUPPORTED/u); + expect(io.fallback).not.toHaveBeenCalled(); + }); + it("should not reject an unsupported renderer when the manifest has no KTX2 output", async () => { vi.stubGlobal( "fetch", diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index 9dc681c13e..f52686cd4f 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -947,14 +947,61 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad const extensions = declaredExtensions(data); const { GLTFLoader } = await import("three/addons/loaders/GLTFLoader.js"); const loader = new GLTFLoader(); - // Models carrying KHR_texture_basisu textures transcode through the same shared, - // support-detected instance `texture()` uses — never a second detection pass. - if (compressedTextures !== undefined && extensions.has("KHR_texture_basisu")) { - const shared = await compressedTextures.loader; + // Both glTF texture extensions use the same support-detected instance as texture(). + const astcExtension = "EXT_texture_astc"; + const hasBasis = extensions.has("KHR_texture_basisu"); + const hasAstc = extensions.has(astcExtension); + let shared: IKTX2LoaderLike | undefined; + if (compressedTextures !== undefined && (hasBasis || hasAstc)) { + try { + shared = await compressedTextures.loader; + } catch (error) { + // Only ASTC-only models may select their ordinary fallback on capability refusal. + // Import/setup failures and the existing Basis contract still reject unchanged. + if ( + hasBasis || + !(error instanceof Error) || + !error.message.startsWith("TN_ASSETS_KTX2_UNSUPPORTED:") + ) + throw error; + } if (shared !== undefined) { loader.setKTX2Loader(shared as unknown as Parameters[0]); } } + if (hasAstc) { + const astcLoader = shared; + const astcSupported = + ( + astcLoader as + | (IKTX2LoaderLike & { + workerConfig?: { astcSupported?: boolean }; + }) + | undefined + )?.workerConfig?.astcSupported === true; + // EXT_texture_astc is an official vendor draft. Borrow Three's Basis plugin handoff, + // but raw ASTC cannot use Basis's format fallback and needs this explicit GPU guard. + loader.register((parser) => { + if (!astcSupported && parser.json.extensionsRequired?.includes(astcExtension)) { + throw new Error( + "TN_ASSETS_ASTC_UNSUPPORTED: required EXT_texture_astc needs a renderer with ASTC texture-compression support.", + ); + } + return { + name: astcExtension, + loadTexture(textureIndex) { + const extension = parser.json.textures[textureIndex].extensions?.[astcExtension]; + if (extension === undefined || !astcSupported || astcLoader === undefined) + return null; + return parser.loadTextureImage( + textureIndex, + extension.source, + astcLoader as unknown as Parameters[2], + ); + }, + }; + }); + } // three's own decoder embeds its WASM; it compiles only once a compressed model is // actually loaded. The native bundle inlines this branch (no runtime import remains), // and the host shims WebAssembly. From 47ca7829484bf93d0a0c254f5ec3e8975339a17c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 20:03:08 -0700 Subject: [PATCH 12/57] Inspect embedded and shared ASTC model payloads during native packaging --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 8 + .../__tests__/native-ktx2.spec.ts | 291 ++++++++++++++++++ packages/create-threenative/src/build.ts | 151 ++++++++- 3 files changed, 448 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index f43d0d3163..b08b684182 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -105,6 +105,14 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **WebP contract split follow-up (2026-10-10):** the original mobile compile fixture remains an integration case, now asserting final raw ASTC sRGB vkFormat 158, NONE supercompression, a 4×4 2D image with all three 16-byte mip payloads and no Basis/WebP extension. Its actual final ASTC pixels pass the unchanged `expectColours(..., 4, 1)` helper. Independently decoding/scoring that final image against the original decoded 8×8 WebP resampled in sRGB passes the original SSIM 0.95 / ΔE00 3 production floor. The original lossless PNG dimension and colour assertions also remain unchanged in a separate direct `compressEmbeddedTextures(document, "prop.glb", { decoderFree: true, maxSize: 4 })` case; no helper tolerance, quality floor or source-colour assertion was loosened. Initial focused run preserved the direct case while reproducing the old mobile PNG mismatch (one passed, one failed; exit 1, 5.44 s); the corrected focused pair passed (exit 0, 4.40 s). The full nearest `compile.spec.ts` plus `model-texture-pass.spec.ts` run then passed all 121 tests (exit 0, 152.40 s). Assets typecheck, focused Biome and diff check passed. This resolves the preceding WebP test conflict; original broad baseline byte identity, native/GPU/game/visual and other unfinished production scope remain open at 0%. No production edit, dependency, commit or GitHub action was performed. + **Embedded/shared packaging inspection (2026-10-10, partial):** decoder-free Android/iOS packaging now reads each actual compiled glTF/GLB with the declared glTF Transform `NodeIO.readAsJSON` resource resolver. Its internal filesystem override disables networks and checks lexical plus realpath containment before reading the model or an external image/buffer; standalone/shared manifest image admission also rejects symlink escapes. Embedded image bufferView indices, offsets and lengths are bounded against declared and actual buffer bytes. Actual raw KTX2 images must be owned by required/used `EXT_texture_astc`, use complete 2D raw ASTC blocks and the glTF profile (non-colour primaries 0/linear, colour primaries 1/sRGB, straight alpha, absent or `rd` orientation). Invalid or missing payloads, ordinary-source ASTC, misleading extension/source metadata, Basis ownership/images and unsupported paired fallbacks fail closed. Actual Meshopt/Draco declarations feed the existing independent geometry capability checks; web, desktop and decoder-capable texture policy remain unchanged. This inspects structure and metadata only; it does not certify ASTC pixel blocks or prove GPU/native rendering. + + Focused RED reproduced four failures (the truncated embedded sRGB/linear GLBs, false source ownership and shared URI escape were previously admitted; exit 1, 1.35 s). The corresponding focused cases passed after implementation (four passed, 16 skipped; exit 0, 1.16 s), then the cases were expanded to cover missing resources, actual mixed Basis, primaries/alpha/orientation/mip defects, unsafe buffer bounds, data/shared URIs and independent Meshopt/Draco requirements. Final full nearest `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts --silent` passed all 20 tests, including all 16 existing cases (exit 0, 7.59 s). The formerly nonexistent shared-model fixture now uses the existing valid untextured GLB builder. Create-threenative typecheck passed after replacing a cross-package test import that pulled in an unrelated Draco declaration; focused Biome passed with ten complexity warnings and no errors. No new dependency, parser, decoder, public API, commit, GitHub action, native/GPU/game run or visual verdict was added. Original broad baseline byte identity, upstream Basis mobile conversion, generated lightmaps and full native acceptance remain OPEN; every original box remains unticked at 0%. + + **Packaging integration review follow-up (2026-10-10):** the linear captured-container fixture now binds its primaries-0 texture to a normal slot; the sRGB fixture keeps its base-colour slot. Packaging validates the container profile and ownership, not every glTF material extension's colour-space semantics. Two new public `compileAssets` integrations cook the existing textured GLB fixture into an Android embedded model and an iOS shared-image model, with Meshopt explicitly admitted independently of absent KTX2 decoding. Source PNGs use the existing lossless uncompressed-byte pattern and an explicit base-colour ETC1S rung, preserving the production quality floor and the normal-slot `none` contract. Both integrations inspect actual emitted required/used ASTC ownership, raw sRGB vkFormat 158/NONE/full mip payloads and successful packaging admission; neither can pass by retaining PNG or omitting shared images. A separate regression exercises standalone raw-ASTC symlink refusal. + + The first integration attempt failed twice because this package's public assets import resolved an older `packages/assets/dist/index.js` (Android lacked ASTC; iOS lacked shared images). The shipped focused assets build completed successfully, including declarations and publint; the unchanged integration inputs then passed. Fresh focused proof passed five cases (18 skipped, exit 0, 7.83 s), and the full nearest native-KTX2 suite passed all 23 cases (exit 0, 17.99 s). A subsequent fixture type-narrowing correction preserves the same source image bytes. Biome retains the formatter's readability through a named `hasAstcImages` boolean rather than stripping requested comparison parentheses. No production policy change, new dependency, native/GPU/visual proof, commit or GitHub action follows this review; original acceptance remains 0%. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. diff --git a/packages/create-threenative/__tests__/native-ktx2.spec.ts b/packages/create-threenative/__tests__/native-ktx2.spec.ts index 5fb3fd5418..f6a88501a1 100644 --- a/packages/create-threenative/__tests__/native-ktx2.spec.ts +++ b/packages/create-threenative/__tests__/native-ktx2.spec.ts @@ -1,9 +1,13 @@ +import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; +import { Document, GLB_BUFFER, NodeIO } from "@gltf-transform/core"; import { compileAssets, modelPass } from "@threenative/assets"; +import { read as readKTX2, write as writeKTX2 } from "ktx-parse"; import { encodeToKTX2 } from "ktx2-encoder"; +import { PNG } from "pngjs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; @@ -142,7 +146,290 @@ async function mobileAstcFixture(srgb = true): Promise<{ basis: Uint8Array; astc }; } +async function packagedAstcModel(project: string, srgb = true): Promise { + const astc = Uint8Array.from( + Buffer.from(srgb ? CAPTURED_ASTC.srgb : CAPTURED_ASTC.linear, "base64"), + ); + const container = readKTX2(astc); + const descriptor = container.dataFormatDescriptor[0]; + assert(descriptor); + descriptor.colorPrimaries = srgb ? 1 : 0; + const document = new Document(); + document.createBuffer(); + const texture = document.createTexture().setImage(writeKTX2(container)).setMimeType("image/ktx2"); + const material = document.createMaterial(); + if (srgb) material.setBaseColorTexture(texture); + else material.setNormalTexture(texture); + const io = new (class extends NodeIO { + override async writeJSON( + ...args: Parameters + ): ReturnType { + const output = await super.writeJSON(...args); + output.json.extensionsUsed = ["EXT_texture_astc"]; + output.json.extensionsRequired = ["EXT_texture_astc"]; + output.json.textures = [{ extensions: { EXT_texture_astc: { source: 0 } } }]; + return output; + } + })(); + await mkdir(path.join(project, "public"), { recursive: true }); + await writeFile(path.join(project, "public/model.glb"), await io.writeBinary(document)); + await writeManifest(project, { model: { kind: "model", output: "model.glb" } }); + return path.join(project, "public/model.glb"); +} + describe("compressed assets on native targets", () => { + it.each([ + ["android", false], + ["ios", true], + ] as const)( + "admits actual compiled ASTC models on %s (shared=%s)", + async (target, sharedImages) => { + const project = await gameProject("threenative-cooked-model-admission-"); + const document = await new NodeIO().readBinary(await buildFixtureGlb()); + const base = document.getRoot().listTextures()[0]; + const sourceImage = base?.getImage(); + assert(base && sourceImage); + base.setImage(PNG.sync.write(PNG.sync.read(Buffer.from(sourceImage)), { deflateLevel: 0 })); + await mkdir(path.join(project, "assets"), { recursive: true }); + await writeFile( + path.join(project, "assets/model.glb"), + await new NodeIO().writeBinary(document), + ); + const decoders = { ktx2: false, meshopt: true, draco: false }; + await compileAssets({ + cwd: project, + platform: target, + runtimeDecoders: decoders, + config: { + budget: "none", + models: { + compact: false, + virtual: "none", + sharedImages, + textures: { + overrides: [ + { slot: "baseColorTexture", codec: "etc1s" }, + { slot: "normalTexture", codec: "none" }, + ], + }, + }, + }, + }); + const manifest = JSON.parse( + await readFile(path.join(project, "public/assets.manifest.json"), "utf8"), + ) as { entries: Record }; + const entry = manifest.entries["model.glb"]; + assert(entry); + if (sharedImages) expect(entry.sharedImages?.length).toBeGreaterThan(0); + else expect(entry.sharedImages ?? []).toHaveLength(0); + const { json, resources } = await new NodeIO().readAsJSON( + path.join(project, "public", entry.output), + ); + expect(json.extensionsUsed).toContain("EXT_texture_astc"); + expect(json.extensionsRequired).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).not.toContain("KHR_texture_basisu"); + const owner = json.textures?.find((texture) => texture.extensions?.EXT_texture_astc); + assert(owner); + const source = (owner.extensions?.EXT_texture_astc as { source: number }).source; + const image = json.images?.[source]; + assert(image); + expect(image.mimeType).toBe("image/ktx2"); + const view = json.bufferViews?.[image.bufferView ?? -1]; + const resource = image.uri ? resources[image.uri] : resources[GLB_BUFFER]; + assert(resource); + const bytes = image.uri + ? resource + : resource.subarray( + view?.byteOffset ?? 0, + (view?.byteOffset ?? 0) + (view?.byteLength ?? 0), + ); + const container = readKTX2(bytes); + expect(container.vkFormat).toBe(158); + expect(container.supercompressionScheme).toBe(0); + expect(container.levels).toHaveLength( + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1, + ); + const capabilities = + target === "android" ? androidQuickJs() : resolveRuntimeAssetCapabilities("ios"); + await expect( + assertNativeAssetsCompatible(project, target, await loadConfig(project), { + ...capabilities, + decoders, + }), + ).resolves.toBeUndefined(); + }, + 60_000, + ); + + it("rejects standalone ASTC symlinks outside the compiled root", async () => { + const project = await gameProject("threenative-standalone-astc-symlink-"); + await writeManifest(project, { texture: { kind: "texture", output: "escape.ktx2" } }); + await writeFile(path.join(project, "outside.ktx2"), Buffer.from(CAPTURED_ASTC.srgb, "base64")); + await symlink(path.join(project, "outside.ktx2"), path.join(project, "public/escape.ktx2")); + await expect( + assertNativeAssetsCompatible(project, "android", await loadConfig(project), androidQuickJs()), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/u); + }); + + it.each([true, false])( + "inspects embedded glTF ASTC bytes and primaries (sRGB=%s)", + async (srgb) => { + const project = await gameProject("threenative-embedded-astc-"); + const file = await packagedAstcModel(project, srgb); + const config = await loadConfig(project); + for (const target of ["android", "ios"] as const) { + const caps = + target === "android" ? androidQuickJs() : resolveRuntimeAssetCapabilities("ios"); + await expect( + assertNativeAssetsCompatible(project, target, config, caps), + ).resolves.toBeUndefined(); + const original = await readFile(file); + await writeFile(file, original.subarray(0, original.length - 32)); + await expect(assertNativeAssetsCompatible(project, target, config, caps)).rejects.toThrow( + /TN_NATIVE_KTX2_UNSUPPORTED/u, + ); + await writeFile(file, original); + } + }, + ); + + it("rejects false ASTC ownership, missing payloads, mixed Basis and independent geometry", async () => { + const project = await gameProject("threenative-astc-ownership-"); + const file = await packagedAstcModel(project); + const config = await loadConfig(project); + const original = await new NodeIO().readAsJSON(file); + const image = original.json.images?.[0]; + assert(image?.bufferView !== undefined); + const bufferView = original.json.bufferViews?.[image.bufferView]; + const resource = original.resources[GLB_BUFFER]; + assert(bufferView && resource); + const bytes = resource.subarray( + bufferView.byteOffset ?? 0, + (bufferView.byteOffset ?? 0) + bufferView.byteLength, + ); + const { basis } = await mobileAstcFixture(); + for (const defect of [ + "source", + "required", + "ordinary", + "basis-owner", + "missing", + "used", + "basis", + "mixed", + "mime", + "primaries", + "alpha", + "orientation", + "mip", + "bounds", + ]) { + const json = structuredClone(original.json); + json.buffers = []; + json.bufferViews = []; + const image = { + mimeType: "image/ktx2", + uri: `data:image/ktx2;base64,${Buffer.from(bytes).toString("base64")}`, + }; + json.images = [image]; + const texture = json.textures?.[0]; + assert(texture?.extensions); + if (defect === "source") texture.extensions.EXT_texture_astc = { source: 20 }; + if (defect === "required") json.extensionsRequired = []; + if (defect === "ordinary") { + texture.source = 0; + texture.extensions.EXT_texture_astc = undefined; + } + if (defect === "basis-owner") texture.extensions.KHR_texture_basisu = { source: 0 }; + if (defect === "missing") image.uri = "missing.ktx2"; + if (defect === "used") json.extensionsUsed = []; + if (defect === "basis") + image.uri = `data:image/ktx2;base64,${Buffer.from(basis).toString("base64")}`; + if (defect === "mixed") + json.images.push({ + mimeType: "image/ktx2", + uri: `data:image/ktx2;base64,${Buffer.from(basis).toString("base64")}`, + }); + if (defect === "mime") image.mimeType = "image/png"; + if (defect === "bounds") { + json.images = [{ mimeType: "image/ktx2", bufferView: 0 }]; + json.buffers = [ + { + byteLength: resource.byteLength, + uri: `data:application/octet-stream;base64,${Buffer.from(resource).toString("base64")}`, + }, + ]; + json.bufferViews = [{ buffer: 0, byteOffset: Number.MAX_SAFE_INTEGER, byteLength: 16 }]; + } + if (["primaries", "alpha", "orientation", "mip"].includes(defect)) { + const container = readKTX2(bytes); + const descriptor = container.dataFormatDescriptor[0]; + const mip = container.levels[1]; + assert(descriptor && mip); + if (defect === "primaries") descriptor.colorPrimaries = 0; + if (defect === "alpha") descriptor.flags = 1; + if (defect === "orientation") container.keyValue.KTXorientation = "ru"; + if (defect === "mip") mip.levelData = new Uint8Array(1); + image.uri = `data:image/ktx2;base64,${Buffer.from(writeKTX2(container)).toString("base64")}`; + } + await writeFile(path.join(project, "public/model.gltf"), JSON.stringify(json)); + await writeManifest(project, { + model: { kind: "model", output: "model.gltf", extensions: ["EXT_texture_astc"] }, + }); + await expect( + assertNativeAssetsCompatible(project, "android", config, androidQuickJs()), + ).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/u); + } + for (const extension of ["EXT_meshopt_compression", "KHR_draco_mesh_compression"] as const) { + const json = structuredClone(original.json); + const buffer = json.buffers?.[0]; + assert(buffer && json.extensionsUsed); + json.extensionsUsed.push(extension); + buffer.uri = `data:application/octet-stream;base64,${Buffer.from(resource).toString("base64")}`; + await writeFile(path.join(project, "public/model.gltf"), JSON.stringify(json)); + await expect( + assertNativeAssetsCompatible(project, "android", config, androidQuickJs()), + ).rejects.toThrow(/TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED/u); + await expect( + assertNativeAssetsCompatible(project, "android", config, { + ...androidQuickJs(), + decoders: { ktx2: false, meshopt: true, draco: true }, + }), + ).resolves.toBeUndefined(); + } + }); + + it("confines shared glTF resources lexically and through symlinks", async () => { + const project = await gameProject("threenative-shared-astc-"); + const file = await packagedAstcModel(project); + const original = await new NodeIO().readAsJSON(file); + const image = original.json.images?.[0]; + assert(image?.bufferView !== undefined); + const view = original.json.bufferViews?.[image.bufferView]; + const resource = original.resources[GLB_BUFFER]; + assert(view && resource); + const bytes = resource.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength); + const json = original.json; + json.buffers = []; + json.bufferViews = []; + await writeFile(path.join(project, "outside.ktx2"), bytes); + await writeFile(path.join(project, "public/inside.ktx2"), bytes); + await symlink(path.join(project, "outside.ktx2"), path.join(project, "public/escape.ktx2")); + await writeManifest(project, { model: { kind: "model", output: "model.gltf" } }); + for (const uri of [ + "inside.ktx2", + "../outside.ktx2", + "escape.ktx2", + "https://example.invalid/a.ktx2", + ]) { + json.images = [{ mimeType: "image/ktx2", uri }]; + await writeFile(path.join(project, "public/model.gltf"), JSON.stringify(json)); + const check = assertNativeAssetsCompatible(project, "ios", await loadConfig(project)); + if (uri === "inside.ktx2") await expect(check).resolves.toBeUndefined(); + else await expect(check).rejects.toThrow(/TN_NATIVE_KTX2_UNSUPPORTED/u); + } + }); + it.each([ ["android", false], ["android", true], @@ -156,6 +443,10 @@ describe("compressed assets on native targets", () => { const { basis, astc } = await mobileAstcFixture(srgb); await mkdir(path.join(project, "public/textures"), { recursive: true }); const output = "textures/colour.ktx2"; + await writeFile( + path.join(project, "public/model.glb"), + await buildFixtureGlb({ textured: false }), + ); for (const shared of [false, true]) { await writeManifest(project, { fixture: shared diff --git a/packages/create-threenative/src/build.ts b/packages/create-threenative/src/build.ts index 987ee83a28..09d3e0f23b 100644 --- a/packages/create-threenative/src/build.ts +++ b/packages/create-threenative/src/build.ts @@ -16,6 +16,7 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { GLB_BUFFER, NodeIO } from "@gltf-transform/core"; import { TraceMap, decodedMappings, @@ -188,7 +189,7 @@ export function assetRoot(cwd: string, config: IResolvedThreeNativeConfig): stri } /** Structural package admission only; the cook owns decoded quality and the host checks GPU support. */ -function isMobileRawAstc(bytes: Uint8Array): boolean { +function isMobileRawAstc(bytes: Uint8Array, gltf = false): boolean { try { if (bytes.byteLength < 80) return false; const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); @@ -266,6 +267,12 @@ function isMobileRawAstc(bytes: Uint8Array): boolean { descriptor.versionNumber === 2 && descriptor.colorModel === KHR_DF_MODEL_ASTC && (descriptor.flags === 0 || descriptor.flags === 1) && + (!gltf || + (descriptor.flags === 0 && + descriptor.colorPrimaries === + (container.vkFormat === VK_FORMAT_ASTC_4x4_SRGB_BLOCK ? 1 : 0) && + (container.keyValue.KTXorientation === undefined || + container.keyValue.KTXorientation === "rd"))) && descriptor.transferFunction === (container.vkFormat === VK_FORMAT_ASTC_4x4_SRGB_BLOCK ? KHR_DF_TRANSFER_SRGB @@ -285,6 +292,118 @@ function isMobileRawAstc(bytes: Uint8Array): boolean { } } +/** NodeIO resolves GLB, external and data resources; every filesystem read stays in the cook root. */ +class CompiledModelIO extends NodeIO { + constructor( + private readonly outputRoot: string, + private readonly canonicalRoot: string, + ) { + super(); + this.setAllowNetwork(false); + } + protected override readURI(uri: string, type: "view"): Promise>; + protected override readURI(uri: string, type: "text"): Promise; + protected override async readURI( + uri: string, + type: "view" | "text", + ): Promise | string> { + if (/^[a-z][a-z0-9+.-]*:/iu.test(uri)) + throw new Error("External resource protocols are forbidden."); + const resolved = path.resolve(uri); + const relative = path.relative(this.outputRoot, resolved); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) + throw new Error("Model resource escapes the compiled root."); + const canonical = await realpath(resolved); + const realRelative = path.relative(this.canonicalRoot, canonical); + if ( + realRelative === ".." || + realRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(realRelative) + ) + throw new Error("Model resource escapes the compiled root through a symlink."); + return type === "text" ? readFile(canonical, "utf8") : readFile(canonical); + } +} + +/** Inspect actual model images and extension ownership without loading a decoder or scene graph. */ +async function inspectMobileModel(outputRoot: string, output: string): Promise { + const io = new CompiledModelIO(outputRoot, await realpath(outputRoot)); + const { json, resources } = await io.readAsJSON(path.resolve(outputRoot, output)); + const extensions = new Set([...(json.extensionsUsed ?? []), ...(json.extensionsRequired ?? [])]); + for (const view of json.bufferViews ?? []) + for (const name of Object.keys(view.extensions ?? {})) extensions.add(name); + for (const mesh of json.meshes ?? []) + for (const primitive of mesh.primitives) + for (const name of Object.keys(primitive.extensions ?? {})) extensions.add(name); + if (extensions.has("KHR_texture_basisu")) + throw new Error("Basis model textures require a decoder."); + const astcImages = new Set(); + for (const texture of json.textures ?? []) { + if (texture.extensions?.KHR_texture_basisu !== undefined) + throw new Error("Basis texture ownership is forbidden."); + const astc = texture.extensions?.EXT_texture_astc as { source?: number } | undefined; + if (astc === undefined) continue; + if ( + !Number.isSafeInteger(astc.source) || + !json.images?.[astc.source ?? -1] || + texture.source !== undefined + ) + throw new Error("Invalid ASTC source or unsupported paired fallback."); + astcImages.add(astc.source as number); + } + const hasAstcImages = astcImages.size > 0; + if ( + extensions.has("EXT_texture_astc") !== hasAstcImages || + (hasAstcImages && + (!(json.extensionsUsed ?? []).includes("EXT_texture_astc") || + !(json.extensionsRequired ?? []).includes("EXT_texture_astc"))) + ) + throw new Error("ASTC model extension membership is inconsistent."); + for (const [index, image] of (json.images ?? []).entries()) { + let bytes: Uint8Array | undefined; + if (typeof image.uri === "string") bytes = resources[image.uri]; + else { + if (!Number.isSafeInteger(image.bufferView) || (image.bufferView ?? -1) < 0) + throw new Error("Invalid model image bufferView index."); + const view = json.bufferViews?.[image.bufferView ?? -1]; + const buffer = json.buffers?.[view?.buffer ?? -1]; + const resource = buffer === undefined ? undefined : resources[buffer.uri ?? GLB_BUFFER]; + const offset = view?.byteOffset ?? 0; + const length = view?.byteLength; + if ( + !view || + !buffer || + !resource || + !Number.isSafeInteger(view.buffer) || + (buffer.uri === undefined && view.buffer !== 0) || + !Number.isSafeInteger(offset) || + offset < 0 || + !Number.isSafeInteger(length) || + (length ?? 0) <= 0 || + !Number.isSafeInteger(buffer.byteLength) || + offset + (length ?? 0) > buffer.byteLength || + offset + (length ?? 0) > resource.byteLength + ) + throw new Error("Invalid model image buffer bounds."); + bytes = resource.subarray(offset, offset + (length as number)); + } + if (!bytes || bytes.byteLength === 0) throw new Error("Missing model image payload."); + const isKtx = + image.mimeType === "image/ktx2" || + Buffer.from(bytes.subarray(0, 12)).equals(Buffer.from("ab4b5458203230bb0d0a1a0a", "hex")); + if (astcImages.has(index) || isKtx) { + if ( + !astcImages.has(index) || + image.mimeType !== "image/ktx2" || + !isMobileRawAstc(bytes, true) || + (json.textures ?? []).some((texture) => texture.source === index) + ) + throw new Error("Model KTX2 must be valid raw ASTC owned by EXT_texture_astc."); + } + } + return [...extensions]; +} + /** Only files in the compiled root can qualify for the mobile raw-ASTC exception. */ async function isCompiledMobileAstc(outputRoot: string, output: string): Promise { const resolved = path.resolve(outputRoot, output); @@ -292,7 +411,16 @@ async function isCompiledMobileAstc(outputRoot: string, output: string): Promise if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return false; try { - return isMobileRawAstc(await readFile(resolved)); + const canonicalRoot = await realpath(outputRoot); + const canonical = await realpath(resolved); + const realRelative = path.relative(canonicalRoot, canonical); + if ( + realRelative === ".." || + realRelative.startsWith(`..${path.sep}`) || + path.isAbsolute(realRelative) + ) + return false; + return isMobileRawAstc(await readFile(canonical)); } catch { return false; } @@ -330,6 +458,25 @@ export async function assertNativeAssetsCompatible( const rows = Object.entries(entries as Record).map( ([logical, entry]) => [logical, (entry ?? {}) as Record] as const, ); + if (!capabilities.decoders.ktx2 && (target === "android" || target === "ios")) { + for (const [logical, entry] of rows) { + if ( + typeof entry.output !== "string" || + !(entry.kind === "model" || /\.gl(?:b|tf)$/iu.test(entry.output)) + ) + continue; + try { + const actual = await inspectMobileModel(outputRoot, entry.output); + entry.extensions = [ + ...new Set([...(Array.isArray(entry.extensions) ? entry.extensions : []), ...actual]), + ]; + } catch (error) { + throw new Error( + `TN_NATIVE_KTX2_UNSUPPORTED: ${target} cannot admit model '${logical}': ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } const ktx2Rows = rows.filter( ([, entry]) => (typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output)) || From 4b7e82e48c415cf14ed17941bfb7efa6ac82c43e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 20:13:29 -0700 Subject: [PATCH 13/57] fix(assets): convert authored Basis to ASTC and preserve QuickJS proof environment --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 5 + .../assets/__tests__/image-quality.spec.ts | 14 ++ .../__tests__/model-texture-pass.spec.ts | 178 +++++++++++++++++- packages/assets/src/image-quality.ts | 45 ++++- packages/assets/src/passes/model-textures.ts | 121 +++++++++--- packages/assets/src/passes/model.ts | 4 +- scripts/vq01-android-proof.sh | 9 +- 7 files changed, 337 insertions(+), 39 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index b08b684182..f3487b6e32 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,11 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, final authored-Basis guard verification:** a focused final-source run passed 23 tests (63 skipped, exit 0, 3.54 s), including real ETC1S/UASTC 1×1 full-pyramid conversion and serialized required `EXT_texture_astc`, legacy one-level 8×8 Basis quality measurement without a full-chain requirement, existing final-buffer measurements, malformed/missing upper mips, conversion policies, cache replay and final off-colour quality refusal. Full-chain structural/source-mip validation applies only when authored conversion requests it; ordinary legacy measurement retains its prior admission. Direct authored alpha admission is the exact complement of the existing ladder acceptance rule: both require a score above the floor and either configured alpha thresholds or zero mean alpha error. No production source changed during this verification. Assets TypeScript, focused Biome and diff checks passed; the nearest suites' prior 82-pass result remains recorded in this PRD. Broader native/visual and normal/data proof remains open at 0%. +- **2026-10-10, Android proof runner environment:** the coordinator accepted the separate runner fix that honors caller-selected QuickJS and Android ABI while retaining the default V8/x86 configuration. The reviewed runner patch reports three stub RED/GREEN cases, five Android capability tests and shell syntax checks passing. This is runner/capability coverage only; it does not prove a device launch, native rendering or visual quality. The runner script was not changed by the authored-Basis verification component. + +- **2026-10-10, authored Basis conversion component:** ASTC-selected model cooking now converts upstream full-mip 2D ETC1S/UASTC KTX2 instead of returning those images unchanged. It decodes the authored Basis pixels with the installed RGBA32 transcoder, validates every source mip, directly transcodes unchanged dimensions or resamples through the existing final-ASTC ladder for a resolution cap, and measures the final raw ASTC against that decoded baseline before admission. Conversion or quality failure throws `TN_ASSETS_MODEL_BASIS_ASTC`; it cannot silently retain Basis. Original pre-Basis pixels are unavailable, and normal/data slots remain `unvalidated-slots`. Explicit slot `codec: none`, non-ASTC cooking and direct decoder-free handling preserve authored Basis bytes. Both ASTC-only model/shared cache identities changed; non-ASTC identities did not. Focused real-profile RED failed both new conversion cases (2 failed, 55 skipped, exit 1, 1.48 s); GREEN passed both (exit 0, 1.04 s). Expanded focused policy coverage passed 10 cases (55 skipped, exit 0, 3.38 s), including cap resizing, authored-byte controls, malformed/missing upper mip rejection, a valid off-colour final ASTC quality refusal, and cold Basis-to-ASTC shared-cache isolation followed by warm replay. The nearest image-quality/model-texture suites passed 82 tests (exit 0, 75.73 s); that run preceded the last compatibility refinement limiting full-chain structural checks to authored conversion and allowing a valid one-level authored ASTC pyramid. Final assets typecheck and Biome passed (13 complexity warnings); no subsequent test rerun was performed at coordinator request. Required EXT ownership, mixed retained Basis ownership and ASTC GPU/report formats have focused coverage. Native/GPU/visual rendering, upper-mip perceptual quality and normal/data semantic certification remain open; all phase/acceptance boxes remain unticked at 0%. + - **2026-10-10, implementation lane:** reuse the installed Three Basis transcoder directly, with no extra encoder or dependency. The local two-colour 8×8 ETC1S probe reported `isETC1S=true`, `cTFASTC_4x4_RGBA=10` and successful full-mip ASTC lengths `[64,16,16,16]` (exit 0). `packages/assets/__tests__/image-quality.spec.ts` retains that invocation as an asserted ETC1S/UASTC test, comparing every output mip to the shipped transcoder. This proves block production, not rendering, VQ-01 completion or visual quality. - **2026-10-10, mobile caller contract:** the starter scaffold test now requires at least one emitted ASTC KTX2 before validating every KTX2's raw ASTC format and full mip chain, while retaining Meshopt refusal. `pnpm exec vitest run packages/create-threenative/__tests__/scaffold.spec.ts -t "keeps the starter's shipped assets mobile-shippable" --silent` passed (1 passed, 65 skipped, exit 0, 6.12 s); focused Biome and `git diff --check` passed. The earlier full assets suite failed (551 passed, 2 failed, 6 skipped, exit 1, 294.32 s), with an unexpected worker exit whose code/signal was not reported: `bake-receipt.spec.ts:301` resolved instead of rejecting undeclared `stray.bin`, and `worker-pool.spec.ts:44` produced empty output instead of `empty-chain-exited`. Isolating the bake-receipt case on the same Node 20.19.6 repeated the failure (1 failed, 17 skipped, exit 1, 616 ms). Work on these failures stopped pending Opus diagnosis; their cause remains unknown, and the worker-pool case has not been isolated. All phase and acceptance boxes remain open at 0%; no native or visual claim is added. - **2026-10-10, receipt guard regression:** the engine guard used only `mtimeMs + 1 >= runStart`, so a newly copied undeclared file retaining an old timestamp could escape it. The existing stray-writer test now also preserves an old timestamp with `utimes`. On local Node 20.19.6, `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts -t 'preserved old timestamp: true' --silent` failed before the fix (1 failed, 18 skipped, exit 1, 10.00 s: resolved instead of rejecting), then passed (1 passed, 18 skipped, exit 0, 4.40 s). The compiler now snapshots output paths before its first write, rejects every new undeclared path regardless of timestamp, retains existing modification-time and receipt/journal exemptions, and propagates output-walk errors except `ENOENT`. `pnpm exec vitest run packages/assets/__tests__/bake-receipt.spec.ts --silent` passed all 19 tests (exit 0, 59.98 s), including authored-static, cache, auxiliary, stale-output and failed-bake ownership cases. Assets typecheck, focused Biome, focused doc links (12 passed, exit 0, 1.15 s) and `git diff --check` passed. At this receipt-only checkpoint, the full assets suite was still at its earlier failed result; it had not been rerun. The worker-pool failure and unexpected worker exit remain undiagnosed, with no established exit signal, OOM or timer cause. No filesystem/kernel timing claim is made. diff --git a/packages/assets/__tests__/image-quality.spec.ts b/packages/assets/__tests__/image-quality.spec.ts index beb17c9afa..7a44436be1 100644 --- a/packages/assets/__tests__/image-quality.spec.ts +++ b/packages/assets/__tests__/image-quality.spec.ts @@ -225,6 +225,20 @@ describe("image quality instrument", () => { await expect(decodeAstcKtx2(corrupt, 5, 3)).rejects.toThrow(/TN_ASSETS_QUALITY_DECODE/); }); + it("measures legacy one-level Basis without requiring a full pyramid", async () => { + const data = new Uint8Array(8 * 8 * 4).fill(128); + for (let i = 3; i < data.length; i += 4) data[i] = 255; + const encoded = await encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 8, height: 8 }), + isUASTC: true, + generateMipmap: false, + isSetKTX2SRGBTransferFunc: true, + }); + expect(readKTX2(encoded).levelCount).toBe(1); + expect((await measureKtx2(data, encoded, 8, 8)).status).toBe("pass"); + await expect(transcodeKtx2ToAstc(encoded, 1)).rejects.toThrow("TN_ASSETS_ASTC_INPUT"); + }); + it("rejects invalid or incomplete Basis input for the mobile cook", async () => { await expect(transcodeKtx2ToAstc(new Uint8Array([0]))).rejects.toThrow(); const data = new Uint8Array(8 * 8 * 4).fill(128); diff --git a/packages/assets/__tests__/model-texture-pass.spec.ts b/packages/assets/__tests__/model-texture-pass.spec.ts index b7ebe69cae..f59e636b4f 100644 --- a/packages/assets/__tests__/model-texture-pass.spec.ts +++ b/packages/assets/__tests__/model-texture-pass.spec.ts @@ -661,6 +661,179 @@ describe("embedded model textures", () => { }, ); + it.each([false, true])( + "serializes the complete 1x1 authored Basis ASTC pyramid (UASTC=%s)", + async (isUASTC) => { + const document = buildFixtureDocument(); + const base = document.getRoot().listTextures()[0]; + if (!base) throw new Error("fixture base missing"); + const data = new Uint8Array([200, 130, 40, 255]); + const basis = await encoder.encodeToKTX2(data, { + imageDecoder: async () => ({ data, width: 1, height: 1 }), + isUASTC, + generateMipmap: true, + isSetKTX2SRGBTransferFunc: true, + }); + expect(readKTX2(basis).levelCount).toBe(1); + base.setImage(basis).setMimeType("image/ktx2"); + document.createExtension(KHRTextureBasisu).setRequired(true); + await compressEmbeddedTextures(document, "one-pixel.glb", { + astc: true, + overrides: [{ slot: "normalTexture", codec: "none" }], + }); + const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); + const output = await io.writeBinary(document); + const json = (await io.binaryToJSON(output)).json; + expect(json.extensionsRequired).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).not.toContain("KHR_texture_basisu"); + const reread = await readOutput(Buffer.from(output)); + const raw = readKTX2(reread.getRoot().listTextures()[0]?.getImage() ?? new Uint8Array()); + expect([raw.pixelWidth, raw.pixelHeight, raw.levelCount, raw.vkFormat]).toEqual([ + 1, 1, 1, 158, + ]); + expect(raw.levels.map((level) => level.levelData.byteLength)).toEqual([16]); + }, + ); + + it.each([false, true])("converts authored Basis to final ASTC (UASTC=%s)", async (isUASTC) => { + const document = buildFixtureDocument(); + for (const [index, texture] of document.getRoot().listTextures().entries()) { + texture + .setImage( + await encoder.encodeToKTX2(texture.getImage() ?? new Uint8Array(), { + generateMipmap: true, + imageDecoder: async (bytes) => PNG.sync.read(Buffer.from(bytes)), + isUASTC, + isPerceptual: index === 0, + isSetKTX2SRGBTransferFunc: index === 0, + }), + ) + .setMimeType("image/ktx2"); + } + document.createExtension(KHRTextureBasisu).setRequired(true); + const summary = await compressEmbeddedTextures(document, "authored.glb", { astc: true }); + const containers = document + .getRoot() + .listTextures() + .map((t) => readKTX2(t.getImage() ?? new Uint8Array())); + expect(containers.map((c) => c.vkFormat)).toEqual([158, 157]); + expect(containers.map((c) => c.dataFormatDescriptor[0]?.colorPrimaries)).toEqual([1, 0]); + expect( + containers.every( + (c) => c.levelCount === Math.floor(Math.log2(Math.max(c.pixelWidth, c.pixelHeight))) + 1, + ), + ).toBe(true); + expect(Object.values(summary?.formats ?? {})).toEqual(["astc4x4", "astc4x4"]); + expect(summary?.quality["cloth-normal"]?.status).toBe("unvalidated-slots"); + const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); + const json = (await io.binaryToJSON(await io.writeBinary(document))).json; + expect(json.extensionsRequired).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).not.toContain("KHR_texture_basisu"); + }); + + it.each([ + "resize", + "none", + "nonASTC", + "decoderFree", + "missing-mip", + "bad-upper", + "quality-refusal", + "warm", + ])("authored Basis conversion preserves policy: %s", async (policy) => { + const document = await readOutput(await fixtureWithTextures({ width: 32 })); + const base = document.getRoot().listTextures()[0]; + if (!base) throw new Error("fixture base missing"); + const basis = await encoder.encodeToKTX2(base.getImage() ?? new Uint8Array(), { + generateMipmap: true, + imageDecoder: async (bytes) => PNG.sync.read(Buffer.from(bytes)), + isUASTC: true, + isSetKTX2SRGBTransferFunc: true, + }); + base.setImage(basis).setMimeType("image/ktx2"); + document.createExtension(KHRTextureBasisu).setRequired(true); + const overrides = [ + { slot: "normalTexture", codec: "none" as const }, + ...(policy === "none" ? [{ slot: "baseColorTexture", codec: "none" as const }] : []), + ]; + const options = { + astc: policy !== "nonASTC", + decoderFree: policy === "decoderFree", + overrides, + ...(policy === "resize" ? { maxSize: 16 } : {}), + }; + if (policy === "missing-mip" || policy === "bad-upper") { + const malformed = basis.slice(); + const header = new DataView(malformed.buffer, malformed.byteOffset); + if (policy === "missing-mip") header.setUint32(40, 1, true); + else header.setBigUint64(104, BigInt(malformed.byteLength + 8), true); + base.setImage(malformed); + await expect(compressEmbeddedTextures(document, "bad.glb", options)).rejects.toThrow( + "TN_ASSETS_MODEL_BASIS_ASTC", + ); + return; + } + if (policy === "quality-refusal") { + const wrong = await encoder.encodeToKTX2(new Uint8Array([0]), { + generateMipmap: true, + isUASTC: true, + isSetKTX2SRGBTransferFunc: true, + imageDecoder: async () => ({ + data: new Uint8Array(32 * 32 * 4).fill(255), + width: 32, + height: 32, + }), + }); + const raw = await qualityInstrument.transcodeKtx2ToAstc(wrong, 1); + const transcode = vi.spyOn(qualityInstrument, "transcodeKtx2ToAstc").mockResolvedValue(raw); + try { + await expect( + compressEmbeddedTextures(document, "bad-quality.glb", options), + ).rejects.toThrow("TN_ASSETS_TEXTURE_FLOOR"); + } finally { + transcode.mockRestore(); + } + expect(base.getImage()).toEqual(basis); + return; + } + if (policy === "warm") { + const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); + const input = Buffer.from(await io.writeBinary(document)); + const root = await makeTempDir("threenative-authored-shared-"); + const priorBasis = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: { ...options, astc: false }, + }); + const cold = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: options, + }); + expect(cold.buffer).not.toEqual(priorBasis.buffer); + const decode = vi.spyOn(qualityInstrument, "decodeBasisKtx2"); + try { + const warm = await compiled(input, { + sharedImages: createSharedImageStore(root), + textures: options, + }); + expect(warm.buffer).toEqual(cold.buffer); + expect(warm.entry.extensions).toContain("EXT_texture_astc"); + expect(warm.entry.extensions).not.toContain("KHR_texture_basisu"); + expect(decode).not.toHaveBeenCalled(); + } finally { + decode.mockRestore(); + } + return; + } + await compressEmbeddedTextures(document, "policy.glb", options); + if (["none", "nonASTC", "decoderFree"].includes(policy)) expect(base.getImage()).toEqual(basis); + else { + const raw = readKTX2(base.getImage() ?? new Uint8Array()); + expect(raw.vkFormat).toBe(158); + expect(raw.pixelWidth).toBe(16); + expect(raw.levelCount).toBe(5); + } + }); + it("round-trips mixed Basis and ASTC without either writer stealing an image", async () => { const document = buildFixtureDocument(); const normal = document.getRoot().listTextures()[1]; @@ -676,7 +849,10 @@ describe("embedded model textures", () => { document.createExtension(KHRTextureBasisu).setRequired(true); await compressEmbeddedTextures(document, "mixed.glb", { astc: true, - overrides: [{ slot: "baseColorTexture", codec: "etc1s" }], + overrides: [ + { slot: "baseColorTexture", codec: "etc1s" }, + { slot: "normalTexture", codec: "none" }, + ], }); const io = new NodeIO().registerExtensions([...ALL_EXTENSIONS, EXTTextureAstc]); const output = Buffer.from(await io.writeBinary(document)); diff --git a/packages/assets/src/image-quality.ts b/packages/assets/src/image-quality.ts index 54a0ca5248..666a9bcedc 100644 --- a/packages/assets/src/image-quality.ts +++ b/packages/assets/src/image-quality.ts @@ -441,6 +441,32 @@ export async function measureKtx2( height, options, ); + return imageQuality( + source, + await decodeBasisKtx2(encoded, width, height), + width, + height, + options, + ); +} + +/** Internal authored-Basis baseline; the pre-Basis source pixels are unavailable. */ +export async function decodeBasisKtx2( + encoded: Uint8Array, + width: number, + height: number, + fullMipChain = false, +): Promise { + const container = readKTX2(encoded); + if ( + fullMipChain && + (container.vkFormat !== 0 || + container.pixelDepth !== 0 || + container.layerCount !== 0 || + container.faceCount !== 1 || + container.levelCount !== Math.floor(Math.log2(Math.max(width, height))) + 1) + ) + throw new Error("TN_ASSETS_QUALITY_DECODE: expected full-mip 2D Basis KTX2."); const basis = await basisModule(); const file = new basis.KTX2File(encoded); try { @@ -448,15 +474,28 @@ export async function measureKtx2( !file.isValid() || file.getWidth() !== width || file.getHeight() !== height || - !file.startTranscoding() + !file.startTranscoding() || + (fullMipChain && file.getLevels() !== container.levelCount) ) { throw new Error("TN_ASSETS_QUALITY_DECODE: invalid KTX2 or comparison dimensions."); } const format = basis.transcoder_texture_format.cTFRGBA32.value; - const decoded = new Uint8Array(file.getImageTranscodedSizeInBytes(0, 0, 0, format)); + if (file.getImageTranscodedSizeInBytes(0, 0, 0, format) !== width * height * 4) + throw new Error("TN_ASSETS_QUALITY_DECODE: invalid RGBA32 byte length."); + const decoded = new Uint8Array(width * height * 4); if (!file.transcodeImage(decoded, 0, 0, 0, format, 0, -1, -1)) throw new Error("TN_ASSETS_QUALITY_DECODE: mip zero RGBA32 transcode failed."); - return imageQuality(source, decoded, width, height, options); + if (fullMipChain) { + for (let mip = 1; mip < file.getLevels(); mip += 1) { + const bytes = Math.max(1, width >> mip) * Math.max(1, height >> mip) * 4; + if ( + file.getImageTranscodedSizeInBytes(mip, 0, 0, format) !== bytes || + !file.transcodeImage(new Uint8Array(bytes), mip, 0, 0, format, 0, -1, -1) + ) + throw new Error("TN_ASSETS_QUALITY_DECODE: authored Basis mip RGBA32 transcode failed."); + } + } + return decoded; } finally { file.close(); file.delete(); diff --git a/packages/assets/src/passes/model-textures.ts b/packages/assets/src/passes/model-textures.ts index a34305012b..6fe345edb3 100644 --- a/packages/assets/src/passes/model-textures.ts +++ b/packages/assets/src/passes/model-textures.ts @@ -1,7 +1,7 @@ import type { Document, Texture } from "@gltf-transform/core"; import { EXTTextureWebP, KHRTextureBasisu } from "@gltf-transform/extensions"; import { getTextureColorSpace, listTextureInfo, listTextureSlots } from "@gltf-transform/functions"; -import { read as readKTX2 } from "ktx-parse"; +import { KHR_DF_MODEL_UASTC, read as readKTX2 } from "ktx-parse"; import { PNG } from "pngjs"; import { EXTTextureAstc, isRawAstc } from "../gltf-io.js"; import { textureStats } from "../health.js"; @@ -10,6 +10,7 @@ import { type IImageQualityFloor, type IImageQualityOptions, type ITextureQuality, + decodeBasisKtx2, imageQuality, measureKtx2, transcodeKtx2ToAstc, @@ -615,9 +616,26 @@ export async function compressEmbeddedTextures( if (fromStore.codec !== "none") compressed += 1; continue; } + const slots = listTextureSlots(texture); + const forced = options.overrides?.find((override) => slots.includes(override.slot))?.codec; + let authoredBasis = false; + if ( + texture.getMimeType() === "image/ktx2" && + options.astc === true && + !decoderFree && + forced !== "none" + ) { + try { + authoredBasis = readKTX2(image).vkFormat === 0; + } catch (error) { + throw new Error( + `TN_ASSETS_MODEL_BASIS_ASTC: invalid authored KTX2 '${key}': ${messageOf(error)}`, + ); + } + } // Already compressed upstream: left exactly as authored, and still counted so the // reported GPU total is the whole model rather than only the part this stage touched. - if (texture.getMimeType() === "image/ktx2") { + if (texture.getMimeType() === "image/ktx2" && !authoredBasis) { const shape = imageShape(image, "image/ktx2"); bytesBefore += image.byteLength; bytesAfter += image.byteLength; @@ -627,24 +645,35 @@ export async function compressEmbeddedTextures( continue; } - const slots = listTextureSlots(texture); + const authoredCodec = + authoredBasis && readKTX2(image).dataFormatDescriptor[0]?.colorModel === KHR_DF_MODEL_UASTC + ? "uastc" + : "etc1s"; const maxSize = resolveTextureMaxSize(options.maxSize, slots); let decoded: { data: Uint8Array; height: number; width: number }; try { - decoded = await decodeImageBytes( - Buffer.from(image.buffer, image.byteOffset, image.byteLength), - `${logicalPath}#${key}`, - ); + if (authoredBasis) { + const shape = imageShape(image, "image/ktx2"); + decoded = { ...shape, data: await decodeBasisKtx2(image, shape.width, shape.height, true) }; + } else { + decoded = await decodeImageBytes( + Buffer.from(image.buffer, image.byteOffset, image.byteLength), + `${logicalPath}#${key}`, + ); + } } catch (error) { throw new Error( - `TN_ASSETS_MODEL_TEXTURE_UNDECODABLE: could not decode embedded texture '${key}' of '${logicalPath}': ${messageOf(error)}`, + `TN_ASSETS_MODEL_TEXTURE_UNDECODABLE: ${authoredBasis ? "TN_ASSETS_MODEL_BASIS_ASTC: " : ""}could not decode embedded texture '${key}' of '${logicalPath}': ${messageOf(error)}`, ); } const explicit = (options.overrides ?? []).some((override) => slots.includes(override.slot)); - const forced = options.overrides?.find((override) => slots.includes(override.slot))?.codec; const codec = codecLadder(slots, forced)[0]?.codec ?? "none"; bytesBefore += image.byteLength; - gpuBytesBefore += gpuBytes(decoded.width, decoded.height, "none"); + gpuBytesBefore += gpuBytes( + decoded.width, + decoded.height, + authoredBasis ? authoredCodec : "none", + ); if (codec === "none") { bytesAfter += image.byteLength; gpuBytesAfter += gpuBytes(decoded.width, decoded.height, "none"); @@ -710,7 +739,7 @@ export async function compressEmbeddedTextures( ); resized += 1; } - if (target.width % BLOCK_SIZE !== 0 || target.height % BLOCK_SIZE !== 0) { + if (!authoredBasis && (target.width % BLOCK_SIZE !== 0 || target.height % BLOCK_SIZE !== 0)) { // Same split as the standalone pass: a codec named per slot is a request no block format // can satisfy, while an automatic choice retains the image untouched. `cappedSize` only // ever returns multiples of four, so an unaligned target means nothing was resampled and @@ -739,22 +768,58 @@ export async function compressEmbeddedTextures( continue; } - const selected = await encodeTextureLadder(data, target.width, target.height, { - ...textureQualitySemantics(texture), - floor: options.floor, - srgb, - quality, - ...(options.astc === true - ? { astc: true, gltfColorPrimaries: srgb ? (1 as const) : (0 as const) } - : {}), - ...(!explicit && - target.width === decoded.width && - target.height === decoded.height && - UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) - ? { maxBytes: image.byteLength } - : {}), - ...(forced === undefined ? {} : { forced }), - }); + let selected: Awaited>; + try { + if (authoredBasis && target.width === decoded.width && target.height === decoded.height) { + const encoded = await transcodeKtx2ToAstc(image, srgb ? 1 : 0); + const score = await measureKtx2(data, encoded, target.width, target.height, { + ...textureQualitySemantics(texture), + floor: options.floor, + }); + if ( + score.status === "below-floor" || + ((textureQualitySemantics(texture).alphaThresholds?.length ?? 0) === 0 && + score.alpha.meanAbsoluteError !== 0) + ) + throw new Error( + "TN_ASSETS_TEXTURE_FLOOR: authored Basis to ASTC fails its configured floor.", + ); + selected = { + encoded, + codec: authoredCodec, + quality: { + ...score, + codec: authoredCodec, + sourceWidth: decoded.width, + sourceHeight: decoded.height, + }, + }; + } else { + selected = await encodeTextureLadder(data, target.width, target.height, { + ...textureQualitySemantics(texture), + floor: options.floor, + srgb, + quality, + ...(options.astc === true + ? { astc: true, gltfColorPrimaries: srgb ? (1 as const) : (0 as const) } + : {}), + ...(!explicit && + target.width === decoded.width && + target.height === decoded.height && + UNIVERSAL_IMAGE_CONTAINERS.has(texture.getMimeType()) + ? { maxBytes: image.byteLength } + : {}), + ...(forced === undefined ? {} : { forced }), + }); + } + if (authoredBasis && selected.encoded === undefined) + throw new Error( + "TN_ASSETS_TEXTURE_FLOOR: authored Basis cannot retain a decoder-dependent fallback.", + ); + } catch (error) { + if (!authoredBasis) throw error; + throw new Error(`TN_ASSETS_MODEL_BASIS_ASTC: '${logicalPath}#${key}': ${messageOf(error)}`); + } const encoded = selected.encoded; // A tiny source can cost less than the KTX2 container alone. Keep its exact bytes when // encoding cannot save download bytes *and* the authored container needs no extension to be @@ -797,7 +862,7 @@ export async function compressEmbeddedTextures( continue; } const container = readKTX2(encoded); - if (container.levelCount < 2) { + if (container.levelCount < 2 && !authoredBasis) { throw new Error( `TN_ASSETS_MIP_CHAIN_INCOMPLETE: embedded texture '${key}' of '${logicalPath}' encoded without a mip chain (${String(container.levelCount)} level(s)).`, ); diff --git a/packages/assets/src/passes/model.ts b/packages/assets/src/passes/model.ts index cf170207ed..3aa9259f7d 100644 --- a/packages/assets/src/passes/model.ts +++ b/packages/assets/src/passes/model.ts @@ -871,7 +871,7 @@ export function modelPass(options: IModelPassOptions = {}): IAssetPass { options.textures === "none" ? "none" : { - ...(options.textures?.astc === true ? { astc: "raw4x4-gltf-primaries-v1" } : {}), + ...(options.textures?.astc === true ? { astc: "raw4x4-gltf-authored-basis-v2" } : {}), decoderFree: options.textures?.decoderFree ?? false, encoder: KTX2_ENCODER_VERSION, instrument: IMAGE_QUALITY_IDENTITY, @@ -1155,7 +1155,7 @@ function sharedSettings( textureOptions === undefined ? "none" : { - ...(textureOptions.astc === true ? { astc: "raw4x4-gltf-primaries-v1" } : {}), + ...(textureOptions.astc === true ? { astc: "raw4x4-gltf-authored-basis-v2" } : {}), decoderFree: textureOptions.decoderFree ?? false, encoder: KTX2_ENCODER_VERSION, instrument: IMAGE_QUALITY_IDENTITY, diff --git a/scripts/vq01-android-proof.sh b/scripts/vq01-android-proof.sh index 809756d02e..b144cb0bac 100755 --- a/scripts/vq01-android-proof.sh +++ b/scripts/vq01-android-proof.sh @@ -1,6 +1,6 @@ #!/bin/sh # PRD-VQ-01 Phase 3, Android box: cook the unchanged examples/abyss-framework/vq-assets fixture for -# the Android V8 target, install that APK on one named emulator, and run the shared scenario. +# the selected Android target (V8 by default), install its APK on one named emulator, and run the shared scenario. # # sh scripts/vq01-android-proof.sh # @@ -29,13 +29,12 @@ const { generateNativeAssetFixture } = await import("./examples/abyss-framework/ await generateNativeAssetFixture("examples/abyss-framework/vq-assets/.generated/assets"); ' -# The maintainer source build. `resolveRuntimeAssetCapabilities` reports `android:unresolved` -# (engine unknown), so this cook is the decoder-free one: the log names meshopt and KTX2 as -# unqualified for the target and ships normalized GLB/PNG instead. +# The maintainer source build cooks for the selected Gradle engine. Default to V8/x86_64; +# append caller flags after the default ABI so an explicit engine or ABI remains authoritative. ( cd examples/abyss-framework/vq-assets THREENATIVE_RUNTIME_SOURCE="$root/packages/runtime-native" \ - THREENATIVE_GRADLE_ARGS="-PthreenativeJsEngine=v8 -PthreenativeAbis=x86_64" \ + THREENATIVE_GRADLE_ARGS="-PthreenativeAbis=x86_64 ${THREENATIVE_GRADLE_ARGS:--PthreenativeJsEngine=v8}" \ node "$root/packages/create-threenative/dist/threenative.js" build --target android --allow-source-build ) From dad0e26fd320adf71e837c0cb40fbda656129d23 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 20:19:38 -0700 Subject: [PATCH 14/57] Guard standalone raw ASTC with the detected GPU feature --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 4 + packages/core/__tests__/assets.spec.ts | 111 +++++++++++++++++- packages/core/src/assets.ts | 26 ++-- 3 files changed, 129 insertions(+), 12 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index f3487b6e32..d625c32afd 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -118,6 +118,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo The first integration attempt failed twice because this package's public assets import resolved an older `packages/assets/dist/index.js` (Android lacked ASTC; iOS lacked shared images). The shipped focused assets build completed successfully, including declarations and publint; the unchanged integration inputs then passed. Fresh focused proof passed five cases (18 skipped, exit 0, 7.83 s), and the full nearest native-KTX2 suite passed all 23 cases (exit 0, 17.99 s). A subsequent fixture type-narrowing correction preserves the same source image bytes. Biome retains the formatter's readability through a named `hasAstcImages` boolean rather than stripping requested comparison parentheses. No production policy change, new dependency, native/GPU/visual proof, commit or GitHub action follows this review; original acceptance remains 0%. + **Standalone raw-ASTC runtime guard (2026-10-10, partial):** the shared compiled-KTX2 load path now checks the actual loaded texture's `RGBA_ASTC_4x4_Format` before returning it to the asset cache, texture configuration or lightmap attachment. A missing detected `workerConfig.astcSupported === true` disposes that owned texture and throws `TN_ASSETS_ASTC_UNSUPPORTED` naming its URL. The same tiny private feature check serves the existing required glTF ASTC guard; no public interface or additional renderer probe was added. Admission still accepts other compressed formats, custom texture loaders retain their existing contract, and a BC-format decoded KTX2 result remains admissible without ASTC support. This scope covers the shipped raw ASTC 4x4 profile; it does not claim other ASTC block formats or validate GPU uploads. + + The public `assets.texture()` regression uses the existing real vk157/vk158 captures and the installed `KTX2Loader.parse`, with only network callback plumbing replaced. BC-only/ETC-only WebGPU/WebGL cases first reproduced eight missing rejections while five controls passed (exit 1, 1.82 s); an earlier test assertion captured the parsed object before its callback completed and was corrected before this behavioral RED. After the fix all 13 focused cases passed (69 skipped, exit 0, 0.86 s). Unsupported cases assert one disposal; supported ASTC cases retain all four actual mip payloads, forbidden transcoder initialization and one support probe. The full nearest core assets suite passed all 82 tests. Core typecheck, focused Biome (three complexity warnings, no errors) and diff checks passed. No native/GPU/game/visual run, dependency, public API, commit or GitHub action was added; original acceptance remains 0% and native visual proof stays OPEN. + ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index efa4c8a3ab..706488c913 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -3,7 +3,7 @@ import { BoxGeometry, BufferAttribute, BufferGeometry, - type CompressedTexture, + CompressedTexture, Group, ImageBitmapLoader, InterleavedBuffer, @@ -14,6 +14,7 @@ import { MirroredRepeatWrapping, NoColorSpace, RGBA_ASTC_4x4_Format, + RGBA_S3TC_DXT1_Format, RepeatWrapping, SRGBColorSpace, Texture, @@ -810,7 +811,7 @@ describe("IAssetLoader compressed textures", () => { // Actual encoder/emitter capture at e49bb087f, shared with native-ktx2.spec.ts: // separate UNORM/sRGB 8x8 RGBA [240,20,40,128] UASTC cooks, all four mip levels. - it.each([ + const rawAstcFixtures = [ { vkFormat: 157, base64: @@ -821,7 +822,8 @@ describe("IAssetLoader compressed textures", () => { base64: "q0tUWCAyMLsNChoKngAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", }, - ])( + ]; + it.each(rawAstcFixtures)( "EXT_texture_astc raw vkFormat $vkFormat parses without transcoder init", async ({ vkFormat, base64 }) => { const bytes = Uint8Array.from(Buffer.from(base64, "base64")); @@ -856,6 +858,109 @@ describe("IAssetLoader compressed textures", () => { }, ); + it.each( + rawAstcFixtures.flatMap((fixture) => + [false, true].flatMap((webgpu) => + ["bc", "etc2", "astc"].map((format) => ({ ...fixture, webgpu, format })), + ), + ), + )( + "standalone raw ASTC vk$vkFormat requires ASTC support ($webgpu/$format)", + async ({ base64, webgpu, format }) => { + const bytes = Uint8Array.from(Buffer.from(base64, "base64")); + vi.stubGlobal( + "fetch", + vi.fn(async () => + manifestResponse({ version: 1, entries: { "raw.png": { output: "raw.ktx2" } } }), + ), + ); + const renderer = webgpu + ? { + isWebGPURenderer: true, + hasFeature: (name: string) => name === `texture-compression-${format}`, + } + : { + extensions: { + has: (name: string) => + name === + { + bc: "WEBGL_compressed_texture_s3tc", + etc2: "WEBGL_compressed_texture_etc", + astc: "WEBGL_compressed_texture_astc", + }[format], + get: () => ({ getSupportedProfiles: () => ["ldr"] }), + }, + }; + const disposed = vi.fn(); + let parsed: CompressedTexture | undefined; + vi.spyOn(KTX2Loader.prototype, "load").mockImplementation( + function (_url, onLoad, _onProgress, onError) { + this.parse( + bytes.buffer, + (texture) => { + parsed = texture; + texture.addEventListener("dispose", disposed); + onLoad?.(texture); + }, + onError, + ); + return undefined; + }, + ); + const init = vi.spyOn(KTX2Loader.prototype, "init").mockImplementation(() => { + throw new Error("unexpected transcoder init"); + }); + const detect = vi.spyOn(KTX2Loader.prototype, "detectSupport"); + const assets = createAssetLoader({ basePath: "/assets", renderer }); + await expect(assets.compressedTextures?.ready).resolves.toBeUndefined(); + const request = assets.texture("raw.png"); + if (format === "astc") { + const texture = await request; + expect(texture).toBe(parsed); + expect(parsed?.mipmaps.map((mip) => [mip.width, mip.height, mip.data.byteLength])).toEqual([ + [8, 8, 64], + [4, 4, 16], + [2, 2, 16], + [1, 1, 16], + ]); + expect(disposed).not.toHaveBeenCalled(); + assets.release("texture", "raw.png"); + } else { + await expect(request).rejects.toThrow(/TN_ASSETS_ASTC_UNSUPPORTED:.*raw\.ktx2/u); + expect(disposed).toHaveBeenCalledOnce(); + } + expect(parsed?.format).toBe(RGBA_ASTC_4x4_Format); + expect(init).not.toHaveBeenCalled(); + expect(detect).toHaveBeenCalledOnce(); + }, + ); + + it("keeps BC-format decoded KTX2 results admissible without ASTC support", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + manifestResponse({ version: 1, entries: { "basis.png": { output: "basis.ktx2" } } }), + ), + ); + const texture = new CompressedTexture( + [{ width: 4, height: 4, data: new Uint8Array(8) }], + 4, + 4, + RGBA_S3TC_DXT1_Format, + ); + const disposed = vi.fn(); + texture.addEventListener("dispose", disposed); + vi.spyOn(KTX2Loader.prototype, "load").mockImplementation((_url, onLoad) => { + onLoad?.(texture); + return undefined; + }); + const assets = createAssetLoader({ basePath: "/assets", renderer: webglRenderer(S3TC) }); + await expect(assets.texture("basis.png")).resolves.toBe(texture); + expect(disposed).not.toHaveBeenCalled(); + assets.release("texture", "basis.png"); + expect(disposed).toHaveBeenCalledOnce(); + }); + function astcFixture(required: boolean, fallback = true): string { return JSON.stringify({ asset: { version: "2.0" }, diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index f52686cd4f..121904ceb2 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -7,6 +7,7 @@ import { Mesh, NoColorSpace, Object3D, + RGBA_ASTC_4x4_Format, SRGBColorSpace, Texture, type TextureLoader, @@ -507,6 +508,13 @@ export async function createKtx2Loader(options: { return loader as unknown as IKTX2LoaderLike; } +function hasAstcSupport(loader: IKTX2LoaderLike | undefined): boolean { + return ( + (loader as (IKTX2LoaderLike & { workerConfig?: { astcSupported?: boolean } }) | undefined) + ?.workerConfig?.astcSupported === true + ); +} + /** * Widen every quantized `POSITION` a loaded model carries back to float32. * @@ -714,7 +722,14 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad `TN_ASSETS_KTX2_UNPROBED: '${url}' is compiled compressed output but the provided renderer exposes no support-detection surface (neither WebGPU features nor WebGL extensions).`, ); } - return loadWith(shared, url); + const texture = await loadWith(shared, url); + if (texture.format === RGBA_ASTC_4x4_Format && !hasAstcSupport(shared)) { + texture.dispose(); + throw new Error( + `TN_ASSETS_ASTC_UNSUPPORTED: '${url}' contains raw ASTC 4x4 but the renderer lacks ASTC texture-compression support.`, + ); + } + return texture; }; const attachCompiledLightmaps = async (logicalPath: string, value: unknown): Promise => { @@ -971,14 +986,7 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad } if (hasAstc) { const astcLoader = shared; - const astcSupported = - ( - astcLoader as - | (IKTX2LoaderLike & { - workerConfig?: { astcSupported?: boolean }; - }) - | undefined - )?.workerConfig?.astcSupported === true; + const astcSupported = hasAstcSupport(astcLoader); // EXT_texture_astc is an official vendor draft. Borrow Three's Basis plugin handoff, // but raw ASTC cannot use Basis's format fallback and needs this explicit GPU guard. loader.register((parser) => { From e6ec66ab25d81a5423e2b7f400c101152031824c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 22:02:27 -0700 Subject: [PATCH 15/57] docs(prd568): reconcile verified ASTC packaging progress --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index d625c32afd..1fafb23996 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Standalone mobile ASTC cooking is implemented; embedded/lightmap cooking, admission and native proof remain open. +**Priority:** P2 — Standalone and embedded/shared mobile ASTC cooking and packaging admission are implemented; generated mobile lightmaps and native startup/rendering proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, current progress reconciliation at `dad0e26fd320adf71e837c0cb40fbda656129d23`:** the Phase 1 packaging box is verified by the 23-pass native-KTX2 suite recorded below and the actual QuickJS APK build/install in `artifacts/prd568-quickjs-20261011T0323Z/run.log` (`BUILD SUCCESSFUL`, install `Success`). The runner exited 2 (`terminal.txt`): the adapter advertised `texture-compression-astc: true` and the host selected QuickJS, but checker loading failed with `TN_NATIVE_START_FAILED` / `TN_ASSETS_KTX2_UNPROBED` (`app-logcat.txt`). The decoder-free KTX2 support-detection path therefore remains unqualified; no checker ASTC upload, authored-colour rendering or visual verdict is proved. The single subsequent diagnostic ended at exit 124 with empty output (`opus-diagnostic/terminal.txt`); it supplied no diagnosis and was not retried. These receipts are retained unchanged. + + Original/current web and desktop comparisons used `2d212479255cf725f64343dc72aa2eafe9b7b92d` and `dad0e26fd320adf71e837c0cb40fbda656129d23`. `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json` and `terminal-linked.json` record all six platform × embedded/shared/lightmap pairs passing: exact raw manifests, bake receipts and output trees, with 22 primary and 44 receipt-owned payload matches. Timing reports differ in all six pairs; this proves byte identity only for those fixtures/options. Generated mobile lightmap ASTC, the full assets suite after the latest changes, unsupported-adapter native startup, GPU/colour proof and interleaved V8 timing remain OPEN. Dated notes below retain their checkpoint results; this reconciliation and the phase boxes supersede their stale lists of remaining work. + - **2026-10-10, final authored-Basis guard verification:** a focused final-source run passed 23 tests (63 skipped, exit 0, 3.54 s), including real ETC1S/UASTC 1×1 full-pyramid conversion and serialized required `EXT_texture_astc`, legacy one-level 8×8 Basis quality measurement without a full-chain requirement, existing final-buffer measurements, malformed/missing upper mips, conversion policies, cache replay and final off-colour quality refusal. Full-chain structural/source-mip validation applies only when authored conversion requests it; ordinary legacy measurement retains its prior admission. Direct authored alpha admission is the exact complement of the existing ladder acceptance rule: both require a score above the floor and either configured alpha thresholds or zero mean alpha error. No production source changed during this verification. Assets TypeScript, focused Biome and diff checks passed; the nearest suites' prior 82-pass result remains recorded in this PRD. Broader native/visual and normal/data proof remains open at 0%. - **2026-10-10, Android proof runner environment:** the coordinator accepted the separate runner fix that honors caller-selected QuickJS and Android ABI while retaining the default V8/x86 configuration. The reviewed runner patch reports three stub RED/GREEN cases, five Android capability tests and shell syntax checks passing. This is runner/capability coverage only; it does not prove a device launch, native rendering or visual quality. The runner script was not changed by the authored-Basis verification component. @@ -124,7 +128,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: pending. +- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; checker rendering remains OPEN. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. ## Blocked on @@ -146,15 +150,16 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone Android/iOS byte admission now compares final raw ASTC buffers for maxBytes, smallest candidate and source retention, with a new ASTC cache identity (see Decisions). Standalone quality now scores final emitted ASTC mip 0, with every emitted mip decoded and validated (see Decisions); upper-mip perceptual comparison and normal/data quality certification remain OPEN. Explicit standalone decoder maps and mobile none controls now have focused compile coverage (see Decisions). Embedded/shared model textures and generated lightmaps remain open; this complete cook box stays unticked. Focused proof (2026-10-10): `pnpm exec vitest run packages/assets/__tests__/image-quality.spec.ts packages/assets/__tests__/texture-pass.spec.ts packages/assets/__tests__/compile.spec.ts -t 'transcodes every|rejects invalid or incomplete|cooks raw ASTC|should cook decoder-free ASTC|should let a caller that probed|should emit a full mip chain|should leave sources verbatim|should refuse a source whose|keeps a texture a|should reject corrupt image' --silent` — 12 passed, 73 skipped, exit 0 (22.85 s); helper tests first failed because the ASTC function was absent, then passed. `pnpm --filter @threenative/assets typecheck`, focused Biome checks and `git diff --check` passed. The initial focused proof did not run the full assets suite; the subsequent failure, receipt fix and successful full-suite rerun are recorded in Decisions. Native bundling, emulator, timing, GPU and visual judge remain unrun. -- [ ] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` - Partial: the standalone/shared raw ASTC structural gate and Basis refusal now have local unit coverage (see Decisions). Embedded model inspection and actual native packaging/rendering remain unverified; this broad box stays open. + Partial: standalone and embedded/shared Android/iOS ASTC cooking, authored Basis conversion, final-byte/quality admission and explicit decoder-map/none controls have the focused proof recorded above. The original/current web/desktop comparison now proves exact manifests, bake receipts and output trees for all six embedded/shared/lightmap fixture pairs (22 primary and 44 receipt-owned payload matches; operational timing reports outside the output tree differed in all six pairs; nothing normalized). Generated mobile lightmap ASTC and a full assets-suite run after the latest changes remain OPEN, so this complete cook box stays unticked. Upper-mip perceptual comparison, normal/data certification, native ASTC upload, colours and visual acceptance also remain unproved. +- [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. Startup then failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); rendering is still AC-1 work. No PNG-build APK-size comparison is available. #### Phase 2: Load and check on the host -**Status:** NOT STARTED -**Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (route raw ASTC through `createRawTexture`; adapter check), `packages/core/__tests__/` +**Status:** PARTIAL +**Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` + Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. Actual native startup still fails at support detection even when the adapter advertises ASTC; an unsupported-adapter native startup failure with the intended named ASTC error remains unproved. - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing -**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, owner `/root/prd568_support`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active implementation/review. No push, merge or cleanup yet. +**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. From 6fdbd737c599d3ec2844dbf42671e88c4fd0715a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 22:21:34 -0700 Subject: [PATCH 16/57] fix(native): preserve raw KTX2 loading without decoders --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 10 +- .../__tests__/bundle-mobile-decoders.spec.ts | 135 +++++++++++++++++- packages/runtime-native/scripts/bundle.mjs | 43 +++--- 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 1fafb23996..4681999779 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,7 +41,9 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions -- **2026-10-10, current progress reconciliation at `dad0e26fd320adf71e837c0cb40fbda656129d23`:** the Phase 1 packaging box is verified by the 23-pass native-KTX2 suite recorded below and the actual QuickJS APK build/install in `artifacts/prd568-quickjs-20261011T0323Z/run.log` (`BUILD SUCCESSFUL`, install `Success`). The runner exited 2 (`terminal.txt`): the adapter advertised `texture-compression-astc: true` and the host selected QuickJS, but checker loading failed with `TN_NATIVE_START_FAILED` / `TN_ASSETS_KTX2_UNPROBED` (`app-logcat.txt`). The decoder-free KTX2 support-detection path therefore remains unqualified; no checker ASTC upload, authored-colour rendering or visual verdict is proved. The single subsequent diagnostic ended at exit 124 with empty output (`opus-diagnostic/terminal.txt`); it supplied no diagnosis and was not retried. These receipts are retained unchanged. +- **2026-10-10, decoder-free raw KTX2 bundle fix:** the wrapper now retains installed Three support detection, parsing and loading for raw KTX2 while refusing Basis through `init()`; the ZSTD stub makes `init()` a no-op and rejects actual decoding. The focused root `bundle-mobile-decoders.spec.ts` run passed 3/3 tests in 6.51 s (`/home/joao/.cache/agent-dumps/unreal-borrowing/raw/loader-review-gate.log`); the builder's earlier 6/6 result has terminal output only, with no filesystem receipt. A fresh read-only critic returned PASS. The previous QuickJS failure below predates this fix; no native rerun, GPU upload, checker-colour proof or visual verdict is claimed, and AC-1 plus the unsupported-adapter native box stay open. + +- **2026-10-10, current progress reconciliation at `dad0e26fd320adf71e837c0cb40fbda656129d23`:** the Phase 1 packaging box is verified by the 23-pass native-KTX2 suite recorded below and the actual QuickJS APK build/install in `artifacts/prd568-quickjs-20261011T0323Z/run.log` (`BUILD SUCCESSFUL`, install `Success`). Before the decoder-free loader fix, the runner exited 2 (`terminal.txt`): the adapter advertised `texture-compression-astc: true` and the host selected QuickJS, but checker loading failed with `TN_NATIVE_START_FAILED` / `TN_ASSETS_KTX2_UNPROBED` (`app-logcat.txt`). That attempt failed before ASTC loading; native support detection has not been rerun after the fix, so no checker ASTC upload, authored-colour rendering or visual verdict is proved. The single subsequent diagnostic ended at exit 124 with empty output (`opus-diagnostic/terminal.txt`); it supplied no diagnosis and was not retried. These receipts are retained unchanged. Original/current web and desktop comparisons used `2d212479255cf725f64343dc72aa2eafe9b7b92d` and `dad0e26fd320adf71e837c0cb40fbda656129d23`. `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json` and `terminal-linked.json` record all six platform × embedded/shared/lightmap pairs passing: exact raw manifests, bake receipts and output trees, with 22 primary and 44 receipt-owned payload matches. Timing reports differ in all six pairs; this proves byte identity only for those fixtures/options. Generated mobile lightmap ASTC, the full assets suite after the latest changes, unsupported-adapter native startup, GPU/colour proof and interleaved V8 timing remain OPEN. Dated notes below retain their checkpoint results; this reconciliation and the phase boxes supersede their stale lists of remaining work. @@ -128,7 +130,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; checker rendering remains OPEN. +- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; native startup has not been rerun after the loader fix and checker rendering remains OPEN. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. ## Blocked on @@ -152,14 +154,14 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` Partial: standalone and embedded/shared Android/iOS ASTC cooking, authored Basis conversion, final-byte/quality admission and explicit decoder-map/none controls have the focused proof recorded above. The original/current web/desktop comparison now proves exact manifests, bake receipts and output trees for all six embedded/shared/lightmap fixture pairs (22 primary and 44 receipt-owned payload matches; operational timing reports outside the output tree differed in all six pairs; nothing normalized). Generated mobile lightmap ASTC and a full assets-suite run after the latest changes remain OPEN, so this complete cook box stays unticked. Upper-mip perceptual comparison, normal/data certification, native ASTC upload, colours and visual acceptance also remain unproved. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. - Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. Startup then failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); rendering is still AC-1 work. No PNG-build APK-size comparison is available. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); no native rerun after the loader fix has occurred, and rendering is still AC-1 work. No PNG-build APK-size comparison is available. #### Phase 2: Load and check on the host **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. Actual native startup still fails at support detection even when the adapter advertises ASTC; an unsupported-adapter native startup failure with the intended named ASTC error remains unproved. + Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. The pre-fix native attempt failed at support detection even though the adapter advertised ASTC. The loader fix has focused bundle coverage but no subsequent native run; unsupported-adapter native startup with the intended named ASTC error remains unproved. - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. diff --git a/packages/runtime-native/__tests__/bundle-mobile-decoders.spec.ts b/packages/runtime-native/__tests__/bundle-mobile-decoders.spec.ts index da0cf0f069..e760f87370 100644 --- a/packages/runtime-native/__tests__/bundle-mobile-decoders.spec.ts +++ b/packages/runtime-native/__tests__/bundle-mobile-decoders.spec.ts @@ -1,8 +1,11 @@ import { execFile } from "node:child_process"; -import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { expect, test } from "vitest"; +import { runInNewContext } from "node:vm"; +import { type CompressedTexture, RGBA_ASTC_4x4_Format } from "three"; +import type { KTX2Loader } from "three/addons/loaders/KTX2Loader.js"; +import { expect, test, vi } from "vitest"; import { makeTempDir } from "../../../test-support/temp-dir.js"; const run = promisify(execFile); @@ -59,7 +62,8 @@ test("an Android bundle keeps the real loader only for the codecs --decoders adm const stubbed = await bundle(root, []); expect(stubbed).toContain("TN_NATIVE_KTX2_UNSUPPORTED"); expect(stubbed).toContain("TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED"); - expect(stubbed).not.toMatch(/real-ktx2|real-meshopt|real-draco/u); + expect(stubbed).toContain("real-ktx2"); + expect(stubbed).not.toMatch(/real-meshopt|real-draco/u); const admitted = await bundle(root, ["--decoders", "ktx2,meshopt"]); expect(admitted).toContain("real-ktx2"); @@ -76,3 +80,128 @@ test("--decoders refuses a codec name it does not know", async () => { /unknown codec\(s\): basis/u, ); }, 60_000); + +// Encoder/emitter captures shared with core's raw-ASTC loader checks: full 8x8 mip chains. +const rawAstc = [ + "q0tUWCAyMLsNChoKnQAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQEAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", + "q0tUWCAyMLsNChoKngAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAAAAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAwAQAAAAAAAEAAAAAAAAAAQAAAAAAAAAAgAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAQAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABAAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQIAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjEuMAAAAAAAAPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogID8/f////////DwFBQoKICA/P3////////w8BQUKCiAgPz9////////8PAUFCgogIA=", +]; + +test("decoder-free bundles retain Three's raw ASTC loader and refuse Basis and ZSTD", async () => { + const root = await project(); + await rm(path.join(root, "node_modules/three"), { recursive: true }); + await symlink( + path.resolve("packages/runtime-native/node_modules/three"), + path.join(root, "node_modules/three"), + ); + await writeFile( + path.join(root, "src/game.ts"), + ` +import { KTX2Loader } from "three/examples/jsm/loaders/KTX2Loader.js"; +import { KTX2Loader as alias } from "three/addons/loaders/KTX2Loader.js"; +import { createKtx2Loader } from ${JSON.stringify(path.resolve("packages/core/src/assets.ts"))}; +export default { async start() { globalThis.used = { KTX2Loader, alias, createKtx2Loader }; } }; +`, + ); + const source = await bundle(root, []); + expect(source).not.toMatch(/\bWebAssembly\b/u); + const fetchSpy = vi.fn(fetch); + const worker = vi.fn(() => { + throw new Error("unexpected worker"); + }); + const context = { + URL, + TextDecoder, + AbortController, + AbortSignal, + Headers, + Request, + fetch: fetchSpy, + Worker: worker, + console, + document: { getElementById: () => ({}), querySelector: () => null }, + used: undefined as unknown, + }; + runInNewContext(source, context); + const { + KTX2Loader: Loader, + alias, + createKtx2Loader, + } = context.used as { + KTX2Loader: typeof KTX2Loader; + alias: typeof KTX2Loader; + createKtx2Loader: typeof import("../../core/src/assets.js").createKtx2Loader; + }; + expect(alias).toBe(Loader); + const shared = await createKtx2Loader({ + renderer: { + isWebGPURenderer: true, + hasFeature: (feature: string) => feature === "texture-compression-astc", + }, + }); + expect(shared).toBeInstanceOf(Loader); + (shared as unknown as KTX2Loader).dispose(); + await expect( + createKtx2Loader({ renderer: { isWebGPURenderer: true, hasFeature: () => false } }), + ).rejects.toThrow(/TN_ASSETS_KTX2_UNSUPPORTED/u); + const loader = new Loader(); + expect(loader.setTranscoderPath("/never-fetch-basis/").setWorkerLimit(1)).toBe(loader); + const support = vi.fn((feature: string) => feature === "texture-compression-astc"); + expect(loader.detectSupport({ isWebGPURenderer: true, hasFeature: support } as never)).toBe( + loader, + ); + expect(support).toHaveBeenCalledWith("texture-compression-astc"); + const init = vi.spyOn(loader, "init"); + const parse = (bytes: Uint8Array) => + new Promise((resolve, reject) => { + loader.parse(bytes.buffer as ArrayBuffer, resolve, reject); + }); + for (const [index, base64] of rawAstc.entries()) { + const bytes = Uint8Array.from(Buffer.from(base64, "base64")); + expect(new DataView(bytes.buffer).getUint32(12, true)).toBe(157 + index); + const texture = await parse(bytes); + expect(texture.format).toBe(RGBA_ASTC_4x4_Format); + expect(texture.colorSpace).toBe(index === 0 ? "srgb-linear" : "srgb"); + expect(texture.mipmaps.map((mip) => [mip.width, mip.height, mip.data.byteLength])).toEqual([ + [8, 8, 64], + [4, 4, 16], + [2, 2, 16], + [1, 1, 16], + ]); + for (const mip of texture.mipmaps) { + expect(Buffer.from(mip.data).toString("hex")).toBe( + "fcfdfffffffffffff0f0141428288080".repeat(mip.data.byteLength / 16), + ); + } + texture.dispose(); + } + const loaded = await new Promise((resolve, reject) => { + loader.load(`data:application/octet-stream;base64,${rawAstc[0]}`, resolve, undefined, reject); + }); + expect(loaded.mipmaps).toHaveLength(4); + loaded.dispose(); + expect(init).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + // Valid captures: UASTC Basis from the installed encoder, raw ASTC with each mip ZSTD-compressed. + const basis = + "q0tUWCAyMLsNChoKAAAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAACAAAAsAAAACwAAADcAAAAJAAAAAAAAAAAAAAAAAAAAAAAAABLAQAAAAAAABoAAAAAAAAAQAAAAAAAAAAyAQAAAAAAABkAAAAAAAAAEAAAAAAAAAAZAQAAAAAAABkAAAAAAAAAEAAAAAAAAAAAAQAAAAAAABkAAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACmAQIAAwMAABAAAAAAAAAAAAB/AwAAAAAAAAAA/////x8AAABLVFh3cml0ZXIAQmFzaXMgVW5pdmVyc2FsIDIuNTAAACi1L/0gEIEAABcQEBAQQAgBAAAAAAAAAAAotS/9IBCBAAAXEBAQEEAIAQAAAAAAAAAAKLUv/SAQgQAAFxAQEBBACAEAAAAAAAAAACi1L/0gQI0AAEgXEBAQEEAIAQACAM2MIANH"; + const zstd = + "q0tUWCAyMLsNChoKnQAAAAEAAAAIAAAACAAAAAAAAAAAAAAAAQAAAAQAAAACAAAAsAAAACwAAADcAAAAIAAAAAAAAAAAAAAAAAAAAAAAAABTAQAAAAAAACAAAAAAAAAAQAAAAAAAAAA2AQAAAAAAAB0AAAAAAAAAEAAAAAAAAAAZAQAAAAAAAB0AAAAAAAAAEAAAAAAAAAD8AAAAAAAAAB0AAAAAAAAAEAAAAAAAAAAsAAAAAAAAAAIAKACiAQEAAwMAABAAAAAAAAAAAAB/AAAAAAAAAAAA/////xsAAABLVFh3cml0ZXIAS1RYLVBhcnNlIHYxLjAuMQAAKLUv/QRYgQAA/P3////////w8BQUKCiAgBANsXYotS/9BFiBAAD8/f////////DwFBQoKICAEA2xdii1L/0EWIEAAPz9////////8PAUFCgogIAQDbF2KLUv/QRYnQAAWPz9//DwFBQoKICAAgDNDN6CIXAwVtk="; + for (const base64 of [basis, zstd]) { + await expect(parse(Uint8Array.from(Buffer.from(base64, "base64")))).rejects.toThrow( + /TN_NATIVE_KTX2_UNSUPPORTED/u, + ); + } + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(worker).not.toHaveBeenCalled(); + expect(init).toHaveBeenCalledTimes(1); + loader.dispose(); + for (const extra of [ + ["--decoders", "ktx2"], + ["--target", "desktop"], + ]) { + const unrestricted = await bundle(root, extra); + expect(unrestricted).toMatch(/\bWebAssembly\b/u); + expect(unrestricted).not.toContain("TN_NATIVE_KTX2_UNSUPPORTED"); + } +}, 60_000); diff --git a/packages/runtime-native/scripts/bundle.mjs b/packages/runtime-native/scripts/bundle.mjs index 4f7d52e7a5..88d1fca994 100644 --- a/packages/runtime-native/scripts/bundle.mjs +++ b/packages/runtime-native/scripts/bundle.mjs @@ -148,14 +148,14 @@ function generateVFSHeader(files, outputPath) { } /** - * Decoder-free targets retain loader configuration/lifecycle but refuse actual decoding. - * A mobile bundle keeps the real loader only for a codec `--decoders` admits — the list - * `threenative build` resolves per engine (Android V8: ktx2,meshopt). Desktop QuickJS and an + * Decoder-free targets retain Three's raw KTX2 loader but refuse Basis and ZSTD decoding. + * A mobile bundle keeps full decoding only for a codec `--decoders` admits; raw KTX2 stays. + * `threenative build` resolves the list per engine (Android V8: ktx2,meshopt). Desktop QuickJS and an * unknown desktop artifact use the same refusing stubs. */ const MOBILE_KTX2_MESSAGE = // 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."; + "TN_NATIVE_KTX2_UNSUPPORTED: this decoder-free native bundle carries no qualified Basis or ZSTD decoder. Use GPU-ready KTX2 textures without supercompression, or keep transcoded textures on the web target."; const MOBILE_MESH_MESSAGE = "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."; @@ -163,22 +163,20 @@ const MOBILE_DECODER_STUBS = [ { codec: 'ktx2', endsWith: '/examples/jsm/loaders/KTX2Loader.js', - source: `const message = "${MOBILE_KTX2_MESSAGE}"; -export class KTX2Loader { - constructor() { this.transcoderPath = ""; this.workerConfig = undefined; } - setTranscoderPath(path) { this.transcoderPath = path; return this; } - setWorkerLimit() { return this; } - // Support detection is where core asks whether this platform can transcode at all. Throwing - // is the documented "renderer exposed no surface to probe" answer: the asset loader resolves - // its shared loader to undefined and the game boots without compressed textures. - detectSupport() { throw new Error(message); } - load(url, onLoad, onProgress, onError) { - const error = new Error(\`\${message} (requested '\${url}')\`); - if (typeof onError === "function") onError(error); - else throw error; - } - parse() { throw new Error(message); } - dispose() { return this; } + // The private query bypasses this exact-path replacement once; upstream owns raw parsing. + source: (id) => `import { KTX2Loader as RawKTX2Loader } from ${JSON.stringify(`${id}?threenative-raw-ktx2`)}; +export class KTX2Loader extends RawKTX2Loader { + init() { return Promise.reject(new Error(${JSON.stringify(MOBILE_KTX2_MESSAGE)})); } +} +`, + }, + { + codec: 'ktx2', + endsWith: '/examples/jsm/libs/zstddec.module.js', + source: `export class ZSTDDecoder { + // Upstream awaits init inside an async Promise executor; reject at decode to avoid hanging. + init() { return Promise.resolve(); } + decode() { throw new Error(${JSON.stringify(MOBILE_KTX2_MESSAGE)}); } } `, }, @@ -228,9 +226,10 @@ export class DRACOLoader { */ function mobileDecoderStub(id, admitted) { const normalized = id.split('\\').join('/'); - return MOBILE_DECODER_STUBS.find( + const stub = MOBILE_DECODER_STUBS.find( (stub) => normalized.endsWith(stub.endsWith) && !admitted.includes(stub.codec), - )?.source; + ); + return typeof stub?.source === 'function' ? stub.source(id) : stub?.source; } /** From af3e78a32b6439391eced8dcd059f5f5f3f9b57e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 22:52:50 -0700 Subject: [PATCH 17/57] fix(playtest): attribute Android console to the launched app Preserve startup and exited-process diagnostics, fail closed when app identity is unknown, and retain capture failures in existing failure artifacts. Validation: 60 focused Android console, viewport, and lifecycle tests; playtest typecheck; Biome lint (warnings only); diff check. --- .../__tests__/android-console.spec.ts | 312 ++++++++++++++++++ packages/playtest/src/runner/android.ts | 85 ++++- packages/playtest/src/runner/androidRunner.ts | 13 +- 3 files changed, 394 insertions(+), 16 deletions(-) diff --git a/packages/playtest/__tests__/android-console.spec.ts b/packages/playtest/__tests__/android-console.spec.ts index 242c3c0289..821c7fe5cc 100644 --- a/packages/playtest/__tests__/android-console.spec.ts +++ b/packages/playtest/__tests__/android-console.spec.ts @@ -1,6 +1,316 @@ import { expect, test } from "vitest"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { makeTempDir } from "../../../test-support/temp-dir.js"; import { AdbAndroidDriver, parseAndroidConsole } from "../src/runner/android.js"; +import { runDevicePlaytest } from "../src/runner/androidRunner.js"; +import { PlaytestBridgeError } from "../src/runner/bridgeClient.js"; +import { + androidMailboxPaths, + type IDevicePlaytestTransport, +} from "../src/runner/deviceTransport.js"; +import { PLAYTEST_PROTOCOL_LIMITS } from "../src/protocol.js"; + +test("Android console attributes errors to the launched app rather than another running game", async () => { + const own = "I/MystralRuntime(15365): TN_NATIVE_SMOKE_FIRST_FRAME"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return "15365\n"; + if (args[0] === "logcat" && args[1] === "-d") + return [ + own, + "E/BufferQueueProducer(30108): SurfaceView[com.threenative.game/com.threenative.runtime.MystralActivity] BufferQueue has been abandoned", + 'E/MystralRuntime(30108): TN_SURFACE_ACQUIRE_FAILED:{"status":5}', + ].join("\n"); + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + expect(await driver.captureConsole()).toEqual([{ text: own, type: "log" }]); +}); + +test("Android console retains its launched pid's errors after that process exits", async () => { + let alive = true; + const error = "E/MystralRuntime(15365): TN_NATIVE_START_FAILED: authored startup failed"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return alive ? "15365\n" : ""; + if (args[0] === "logcat" && args[1] === "-d") return error; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + alive = false; + expect(await driver.captureConsole()).toEqual([{ text: error, type: "error" }]); +}); + +test.each([ + [ + "activity launch", + "I/ActivityManager( 575): Start proc 15365:com.threenative.vq01/u0a217 for next-top-activity", + "E/AndroidRuntime(15365): FATAL EXCEPTION: main", + ], + [ + "Java crash identity", + "E/AndroidRuntime(15365): Process: com.threenative.vq01, PID: 15365", + "E/AndroidRuntime(15365): java.lang.IllegalStateException: startup failed", + ], + [ + "native tombstone", + "F/DEBUG(16000): pid: 15365, tid: 15365, name: native >>> com.threenative.vq01 <<<", + "F/libc(15365): Fatal signal 6 (SIGABRT)", + ], +])( + "Android console retains a crash before pidof resolves via %s", + async (_kind, identity, error) => { + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[0] === "logcat" && args[1] === "-d") + return `${identity}\n${error}\nE/MystralRuntime(30108): unrelated game failed`; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + const entries = await driver.captureConsole(); + expect(entries).toContainEqual({ text: error, type: "error" }); + expect(entries.some(({ text }) => text.includes("30108"))).toBe(false); + if (identity.startsWith("F/")) + expect(entries).toContainEqual({ text: identity, type: "error" }); + }, +); + +test.each(["", "15365invalid", "0"])( + "Android console fails closed with evidence when pidof returns %j and no launch identity exists", + async (pidof) => { + const error = "E/AndroidRuntime(15365): FATAL EXCEPTION before any process identity"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return pidof; + if (args[0] === "logcat" && args[1] === "-d") return error; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + await expect(driver.captureConsole()).rejects.toThrow( + "TN_PLAYTEST_ANDROID_CONSOLE_UNATTRIBUTED", + ); + await expect(driver.captureConsole()).rejects.toThrow(error); + }, +); + +test.each(["0", "15365invalid"])( + "Android console rejects invalid historical process identity %s", + async (pid) => { + const packageName = "com.threenative.vq01"; + const raw = [ + `I/ActivityManager(575): Start proc ${pid}:${packageName}/u0a217`, + `E/AndroidRuntime(15365): Process: ${packageName}, PID: ${pid}`, + `F/DEBUG(16000): pid: ${pid}, tid: 15365, name: native >>> ${packageName} <<<`, + ].join("\n"); + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName, + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => (args[0] === "logcat" && args[1] === "-d" ? raw : ""); + await driver.prepare("http://127.0.0.1:41777/playtest"); + await expect(driver.captureConsole()).rejects.toThrow( + "TN_PLAYTEST_ANDROID_CONSOLE_UNATTRIBUTED", + ); + }, +); + +test.each([false, true])( + "unknown console attribution cannot pass with diagnostics disabled; prior bridge failure %s", + async (bridgeFailed) => { + const projectPath = await makeTempDir("playtest-console-attribution-"); + const packageName = "com.threenative.vq01"; + const raw = `${"I/AndroidRuntime(15365): unclassified launch\n".repeat(100)}E/AndroidRuntime(15365): boot crashed`; + await writeFile( + join(projectPath, "scenario.json"), + JSON.stringify({ + schemaVersion: 1, + name: "console-attribution", + warmupFrames: 0, + steps: [{ waitFrames: 1 }], + viewport: { width: 960, height: 640 }, + artifacts: { screenshots: false }, + assert: { + resources: [{ id: "state", path: "frames", changed: true }], + diagnostics: { + noConsoleErrors: false, + noRuntimeDiagnostics: false, + noNetworkErrors: false, + networkErrorsOptOutReason: "No Android network observer.", + consoleErrorsOptOutReason: + "This test disables JavaScript console policy to verify mandatory attribution.", + runtimeDiagnosticsOptOutReason: "This test isolates console attribution.", + }, + }, + }), + ); + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName, + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[0] === "logcat" && args[1] === "-d") return raw; + if (args.join(" ") === "shell wm size") return "Physical size: 960x640"; + return ""; + }; + driver.isAlive = async () => true; + driver.stop = async () => {}; + let tick = 0; + const transport: IDevicePlaytestTransport = { + capabilities: ["browser.console", "browser.screenshot"], + start: async () => {}, + close: async () => {}, + waitForBridge: async () => true, + async call(method: string): Promise { + if (method === "describe") + return { + capabilities: ["runtime.resources", "runtime.fixedStep"], + limits: PLAYTEST_PROTOCOL_LIMITS, + name: "console-attribution", + protocolVersion: 1, + } as T; + if (method === "ready") { + if (bridgeFailed) + throw new PlaytestBridgeError({ + code: "TN_PLAYTEST_STARTUP_HOST_EXITED", + message: "Startup crashed", + severity: "error", + fix: { instruction: "Inspect the startup console." }, + }); + return { ready: true } as T; + } + if (method === "sample") + return { + clock: { mode: "fixed-step", tick: ++tick }, + resources: { state: { frames: tick } }, + entities: [], + diagnostics: [], + } as T; + return {} as T; + }, + }; + const artifactDirectory = join(projectPath, "artifacts"); + const run = runDevicePlaytest( + { + projectPath, + scenarioPath: "scenario.json", + artifactDirectory, + headless: true, + timeoutMs: 1000, + trace: false, + url: "", + target: "android", + }, + { + driver, + mailboxPaths: androidMailboxPaths(packageName), + name: "android", + processName: packageName, + transport, + }, + ); + if (!bridgeFailed) { + await expect(run).rejects.toThrow("TN_PLAYTEST_ANDROID_CONSOLE_UNATTRIBUTED"); + } else { + expect((await run).pass).toBe(false); + const console = JSON.parse(await readFile(join(artifactDirectory, "console.json"), "utf8")); + expect(console).toContainEqual({ + text: expect.stringContaining("TN_PLAYTEST_ANDROID_CONSOLE_UNATTRIBUTED"), + type: "error", + }); + expect(console[0].text).toContain("boot crashed"); + expect(console[0].text.length).toBeLessThan(2300); + } + }, +); + +test("Android console keeps app errors and explicit package crash records from system reporters", async () => { + const own = "E/AndroidRuntime(15365): FATAL EXCEPTION: main"; + const reported = "E/ActivityManager( 575): Process com.threenative.vq01 (pid 15365) has crashed"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return "15365\n"; + if (args[0] === "logcat" && args[1] === "-d") + return [ + own, + reported, + "E/ActivityManager( 575): Process com.threenative.vq01.other (pid 30108) has crashed", + ].join("\n"); + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + expect(await driver.captureConsole()).toEqual([ + { text: own, type: "error" }, + { text: reported, type: "error" }, + ]); +}); + +test("Android console starts a new attribution scope on each prepare", async () => { + let pid = "15365"; + const current = "I/MystralRuntime(16000): current launch"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return pid; + if (args[0] === "logcat" && args[1] === "-d") + return `E/MystralRuntime(15365): prior launch\n${current}`; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + pid = "16000"; + await driver.prepare("http://127.0.0.1:41777/playtest"); + expect(await driver.captureConsole()).toEqual([{ text: current, type: "log" }]); +}); test("Android console starts after stopping the previous process and keeps new launch errors", async () => { let log = ""; @@ -12,6 +322,7 @@ test("Android console starts after stopping the previous process and keeps new l user: "0", }); (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async (args) => { + if (args[1] === "pidof") return "8001\n"; if (args.includes("force-stop")) { log += "E/InputDispatcher( 658): channel 'com.example.game/MystralActivity' ~ Channel is unrecoverably broken and will be disposed!\n"; } @@ -46,6 +357,7 @@ test("Android console capture retries a dropped logcat transport instead of fail }); (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async (args) => { calls.push(args); + if (args[1] === "pidof") return "8001\n"; if (args[0] === "logcat" && args[1] === "-d") { logcatAttempts += 1; if (logcatAttempts === 1) throw new Error("adb logcat -d -v brief failed: I/SDL ( 8001): [Mystral] Runtime ini"); diff --git a/packages/playtest/src/runner/android.ts b/packages/playtest/src/runner/android.ts index d1b77b310e..61421edcb8 100644 --- a/packages/playtest/src/runner/android.ts +++ b/packages/playtest/src/runner/android.ts @@ -203,6 +203,7 @@ export class AdbAndroidDriver implements IAndroidDriver { private touchViewport?: IAndroidTouchViewport; private readonly user?: string; private activeUser?: string; + private readonly consolePids = new Set(); /** Set by a lifecycle `rotate`, whose display lock `stop()` has to free on its own account. */ private lifecycleRotationLocked = false; private viewportPresented = false; @@ -234,6 +235,7 @@ export class AdbAndroidDriver implements IAndroidDriver { if (viewport !== undefined) await this.presentViewport(viewport); // Stopping the old app can log a broken input channel. Capture only the new launch. await this.adb(["logcat", "-c"]); + this.consolePids.clear(); await this.adb([ "shell", "am", @@ -253,6 +255,13 @@ export class AdbAndroidDriver implements IAndroidDriver { // the same two variables in the environment mean the same thing on both device lanes. ...hostCssUiExtras(), ]); + await this.rememberConsolePids(); + } + + private async rememberConsolePids(): Promise { + const output = await this.adb(["shell", "pidof", this.options.packageName]).catch(() => ""); + for (const pid of output.trim().split(/\s+/u)) + if (/^[1-9]\d*$/u.test(pid)) this.consolePids.add(pid); } private async resolveUser(): Promise { @@ -424,7 +433,31 @@ export class AdbAndroidDriver implements IAndroidDriver { } } if (output === undefined) throw failedLogcat(args.join(" "), lastError); - return parseAndroidConsole(output); + await this.rememberConsolePids(); + // A launch may crash before pidof sees it; the cleared log retains its process identity. + for (const line of output.split(/\r?\n/u)) { + const launch = /\bStart proc ([1-9]\d*):([^\s/]+)/u.exec(line); + const java = /\bProcess:\s*([^,\s]+),\s*PID:\s*([1-9]\d*)\b/u.exec(line); + const native = /\bpid:\s*([1-9]\d*),.*>>>\s*([^<\s]+)\s*<< LOGCAT_ERROR_LIMIT ? `…${output.slice(-LOGCAT_ERROR_LIMIT)}` : output; + throw new Error( + `TN_PLAYTEST_ANDROID_CONSOLE_UNATTRIBUTED: no process identity for '${this.options.packageName}'; console tail:\n${excerpt}`, + ); + } + return parseAndroidConsoleEntries(output, { + packageName: this.options.packageName, + pids: this.consolePids, + }); } async screenshot(path: string): Promise { @@ -758,19 +791,43 @@ function failedLogcat(command: string, message: string): Error { } export function parseAndroidConsole(output: string): Array<{ text: string; type: string }> { - return output - .split(/\r?\n/u) - // system_server and the compositor name the activity while reporting on themselves. - .filter((line) => !/^[VDIWEF]\/(?:SurfaceSyncGroup|TransitionController)\(/u.test(line)) - .filter((line) => /Mystral|THREENATIVE|chromium/u.test(line)) - .map((text) => ({ - text, - type: isPlatformWebViewNoise(text) - ? "log" - : /^(?:E|F)\//u.test(text) || /\b(?:Error|FATAL|FAILED)\b/u.test(text) - ? "error" - : "log", - })); + return parseAndroidConsoleEntries(output); +} + +function parseAndroidConsoleEntries( + output: string, + app?: { packageName: string; pids: ReadonlySet }, +): Array<{ text: string; type: string }> { + const packageRecord = + app === undefined + ? undefined + : new RegExp( + `(?:^|[^\\w.])${app.packageName.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?=[:/\\s,})]|$)`, + "u", + ); + return ( + output + .split(/\r?\n/u) + // system_server and the compositor name the activity while reporting on themselves. + .filter((line) => !/^[VDIWEF]\/(?:SurfaceSyncGroup|TransitionController)\(/u.test(line)) + .filter((line) => { + if (app === undefined) return /Mystral|THREENATIVE|chromium/u.test(line); + if (app.pids.size === 0) return line.trim().length > 0; + const pid = /^[VDIWEF]\/[^\n(]+\(\s*(\d+)\)/u.exec(line)?.[1]; + return ( + (pid !== undefined && app.pids.has(pid)) || + (/^[EF]\//u.test(line) && packageRecord!.test(line)) + ); + }) + .map((text) => ({ + text, + type: isPlatformWebViewNoise(text) + ? "log" + : /^(?:E|F)\//u.test(text) || /\b(?:Error|FATAL|FAILED)\b/u.test(text) + ? "error" + : "log", + })) + ); } /** diff --git a/packages/playtest/src/runner/androidRunner.ts b/packages/playtest/src/runner/androidRunner.ts index fe610dcfbb..395149c35c 100644 --- a/packages/playtest/src/runner/androidRunner.ts +++ b/packages/playtest/src/runner/androidRunner.ts @@ -587,8 +587,17 @@ async function runDevicePlaytestInternal( let diagnostic = error.diagnostic; // Preserve this failed host's output before stop/transport cleanup. Diagnostic // collection is best effort and must never replace the original failure. - const consoleEntries = await target.driver.captureConsole().catch(() => []); - await writeFile(join(config.artifactDirectory, "console.json"), `${JSON.stringify(consoleEntries, null, 2)}\n`, "utf8").catch(() => undefined); + const consoleEntries = await target.driver.captureConsole().catch((captureError: unknown) => [ + { + text: captureError instanceof Error ? captureError.message : String(captureError), + type: "error", + }, + ]); + await writeFile( + join(config.artifactDirectory, "console.json"), + `${JSON.stringify(consoleEntries, null, 2)}\n`, + "utf8", + ).catch(() => undefined); if ( diagnostic.code === "TN_PLAYTEST_OPERATION_TIMEOUT" || diagnostic.code === "TN_PLAYTEST_STARTUP_HOST_EXITED" From 6f44b81a2d738e43da36d467c0b54ae0954d1fc6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 22:55:35 -0700 Subject: [PATCH 18/57] fix(assets): cook generated mobile lightmaps as raw ASTC --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 18 ++- packages/assets/__tests__/compile.spec.ts | 131 +++++++++++++++--- .../assets/__tests__/lightmap-pass.spec.ts | 10 +- .../assets/__tests__/shared-images.spec.ts | 44 +++++- packages/assets/src/apply-worker.ts | 4 +- packages/assets/src/compile.ts | 7 +- packages/assets/src/passes/lightmap.ts | 18 ++- packages/assets/src/passes/texture.ts | 6 +- packages/assets/src/worker-protocol.ts | 1 + 9 files changed, 197 insertions(+), 42 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 4681999779..ef701ea8fb 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Standalone and embedded/shared mobile ASTC cooking and packaging admission are implemented; generated mobile lightmaps and native startup/rendering proof remain open. +**Priority:** P2 — Mobile ASTC cooking and packaging admission are implemented; full assets validation and native startup/rendering proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,14 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, generated mobile lightmaps (partial checkpoint):** private compiler/worker selection now emits generated Android/iOS lightmaps as raw ASTC 4×4 UNORM (`vkFormat` 157), linear transfer/primaries 0, full mips and NONE supercompression. Public lightmap options and existing web/desktop decoder admission are unchanged. Focused RED reproduced both omitted mobile lightmaps; final focused source tests passed 14 cases (65 skipped, 45.38 s), covering actual all-mip decoding, two-worker/sequential model and atlas byte equality, warm cache, target-switch invalidation and the unchanged public Basis cache key. The coordinator independently reran both mobile cases successfully (14.46 s; `/home/joao/.cache/agent-dumps/unreal-borrowing/raw/lightmap-review-gate.log`), and a fresh read-only critic returned PASS. Assets typecheck, focused Biome and diff checks passed. Pre-edit compiler output trees are `/tmp/prd568-lightmap-baseline/{web,desktop}/public`; post-edit trees are `/tmp/prd568-lightmap-baseline/{after-web,after-desktop}/public`. `freeze.log` and `byte-identity.log` in that directory record exact model/lightmap filename and byte equality for both platforms using the retained `input.glb`; this is fixture-specific byte proof, not a timing claim. Generated linear lightmaps and authored normal/data slots have no semantic colour-quality certification; `unvalidated-slots` is not a colour pass. + + Full `pnpm exec vitest run packages/assets/__tests__` on Node 20.19.6 was stopped at the first observed failure (TERM, exit 143, before its 300 s limit; `/tmp/prd568-lightmap-baseline/full-assets.log`). `shared-images.spec.ts:601`, “should resize shared embedded images to PNG and stay deduplicated on a decoder-free target”, failed in the 21-case file (one failure; file runtime 160.81 s). The isolated follow-up reproduced its obsolete `.none.png` assertion against actual `57467331719e9457.astc4x4.ktx2` (one failed, 20 skipped, 5.63 s; `/tmp/prd568-lightmap-baseline/shared-mobile-red.log`); the corrected Android default-format test passes without changing production policy (one passed, 20 skipped, 4.42 s; `/tmp/prd568-lightmap-baseline/shared-mobile-green.log`), requiring both raw ASTC formats, 16×16/five-mip/NONE containers, matching metadata, installed all-mip decoding, required glTF ASTC ownership and the original two-file deduplication. Existing web/desktop decoder admission was retained after review; the final combined lightmap/shared-image focus passed 15 tests (85 skipped, 37.92 s; `/tmp/prd568-lightmap-baseline/compat-focused-green.log`). Four completed full-run files reported 106 passes, one failure and no skips before termination; the unfinished suite has no overall runtime/result. Assets typecheck passes, and documentation checks passed 244 tests plus 3,025 link checks before this result-only update. The interrupted run has no final suite summary, and the complete cook box remains unticked. A local partial checkpoint is authorized for the focused increment; the failed full gate earns no cook completion claim and no push is authorized. + + The subsequent full `pnpm exec vitest run packages/assets/__tests__ --bail=1` on Node 20.19.6 exited 1 after 102.307 s wall time: 171 passed, one failed and 136 skipped (Vitest 100.98 s). `model-texture-pass.spec.ts:1066`, “should cap baseColor at 2048 and masks at 1024”, exceeded its unchanged 60 s test limit; the 300 s supervisor did not expire. Exact trace/summary and terminal receipts are `/tmp/prd568-lightmap-baseline/full-assets-rerun.log` and `full-assets-rerun-terminal.json`. One unchanged isolated run under a 100 s supervisor passed (one passed, 66 skipped, exit 0, 30.044 s wall time; `cap-isolated.log` and `cap-isolated-terminal.json` in the same directory). The fixture uses Android, no codec override, embedded images (`sharedImages: false`) and 2048/1024 per-slot caps. Isolation did not reproduce the timeout; resource contention is unproved. The full assets gate remains failed, and the cook box stays open. + + A later QuickJS observation is retained at `artifacts/prd568-quickjs-local-20261011T052548Z/`: `colour-result.json` passes all checker colour counts and `packaged-checker-result.json` confirms the matching 1,696-byte vk158 32×32 NONE/six-mip asset; seven startup/resource assertions pass. That recorded runner attempt exits 1 because diagnostics include foreign PID 30108 (`com.threenative.game`) while VQ PID 15365 reports ready at 1,339.137456 ms. The attribution fix is accepted and committed at `af3e78a32b6439391eced8dcd059f5f5f3f9b57e` (60 tests and a fresh read-only critic PASS); a native rerun is still OPEN. `terminal.txt`, `run.log`, `console.json`, `device-response-observations.json` and `after.png` retain the original observations. The empty 120 s `opus-attribution` timeout supplied no diagnosis. AC-1, native unsupported-adapter proof and visual acceptance remain open. + - **2026-10-10, decoder-free raw KTX2 bundle fix:** the wrapper now retains installed Three support detection, parsing and loading for raw KTX2 while refusing Basis through `init()`; the ZSTD stub makes `init()` a no-op and rejects actual decoding. The focused root `bundle-mobile-decoders.spec.ts` run passed 3/3 tests in 6.51 s (`/home/joao/.cache/agent-dumps/unreal-borrowing/raw/loader-review-gate.log`); the builder's earlier 6/6 result has terminal output only, with no filesystem receipt. A fresh read-only critic returned PASS. The previous QuickJS failure below predates this fix; no native rerun, GPU upload, checker-colour proof or visual verdict is claimed, and AC-1 plus the unsupported-adapter native box stay open. - **2026-10-10, current progress reconciliation at `dad0e26fd320adf71e837c0cb40fbda656129d23`:** the Phase 1 packaging box is verified by the 23-pass native-KTX2 suite recorded below and the actual QuickJS APK build/install in `artifacts/prd568-quickjs-20261011T0323Z/run.log` (`BUILD SUCCESSFUL`, install `Success`). Before the decoder-free loader fix, the runner exited 2 (`terminal.txt`): the adapter advertised `texture-compression-astc: true` and the host selected QuickJS, but checker loading failed with `TN_NATIVE_START_FAILED` / `TN_ASSETS_KTX2_UNPROBED` (`app-logcat.txt`). That attempt failed before ASTC loading; native support detection has not been rerun after the fix, so no checker ASTC upload, authored-colour rendering or visual verdict is proved. The single subsequent diagnostic ended at exit 124 with empty output (`opus-diagnostic/terminal.txt`); it supplied no diagnosis and was not retried. These receipts are retained unchanged. @@ -130,7 +138,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; native startup has not been rerun after the loader fix and checker rendering remains OPEN. +- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; the later `artifacts/prd568-quickjs-local-20261011T052548Z/` run passes checker colour and seven startup/resource assertions but exits 1 on foreign-PID diagnostics; clean attribution and visual acceptance remain OPEN. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. ## Blocked on @@ -152,16 +160,16 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone and embedded/shared Android/iOS ASTC cooking, authored Basis conversion, final-byte/quality admission and explicit decoder-map/none controls have the focused proof recorded above. The original/current web/desktop comparison now proves exact manifests, bake receipts and output trees for all six embedded/shared/lightmap fixture pairs (22 primary and 44 receipt-owned payload matches; operational timing reports outside the output tree differed in all six pairs; nothing normalized). Generated mobile lightmap ASTC and a full assets-suite run after the latest changes remain OPEN, so this complete cook box stays unticked. Upper-mip perceptual comparison, normal/data certification, native ASTC upload, colours and visual acceptance also remain unproved. + Partial: standalone, embedded/shared and generated-lightmap Android/iOS ASTC cooking now have the focused format, decoder-map, worker and cache proof recorded above. Earlier six-pair web/desktop comparisons retain exact manifests, bake receipts and output-tree proof (22 primary and 44 receipt-owned payload matches); the latest generated-lightmap increment also preserves exact web/desktop model and atlas bytes in `/tmp/prd568-lightmap-baseline/byte-identity.log`. The latest full assets run failed at the existing cap test’s unchanged 60 s timeout; its isolated pass does not supply the missing full-suite proof, so this complete cook box stays unticked. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. - Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); no native rerun after the loader fix has occurred, and rendering is still AC-1 work. No PNG-build APK-size comparison is available. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the later checker/resource observations and unresolved foreign-PID diagnostics are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. #### Phase 2: Load and check on the host **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. The pre-fix native attempt failed at support detection even though the adapter advertised ASTC. The loader fix has focused bundle coverage but no subsequent native run; unsupported-adapter native startup with the intended named ASTC error remains unproved. + Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. The pre-fix native attempt failed at support detection even though the adapter advertised ASTC. The loader fix now has the supported-adapter checker/resource observations recorded above; unsupported-adapter native startup with the intended named ASTC error remains unproved. - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 347822c948..9ab68b7ce4 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -13,6 +13,7 @@ import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import { type IAssetSourceConfig, compileAssets } from "../src/index.js"; +import { decodeAstcKtx2 } from "../src/ktx2-encoder.js"; import { modelPass } from "../src/passes/model.js"; import { unpackGlb } from "../src/passes/shared-images.js"; import { sine, wavClip } from "./audio-fixtures.js"; @@ -25,6 +26,26 @@ async function installThree(root: string): Promise { await symlink(THREE_INSTALL, path.join(root, "node_modules/three")); } +async function lightmapFixtureGlb(): Promise { + const source = await readFile( + new URL("../../create-threenative/templates/starter/assets/native-proof.glb", import.meta.url), + ); + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); + const document = await io.readBinary(source); + const extension = document.createExtension(KHRLightsPunctual); + const light = extension.createLight("bake-light").setType("point").setIntensity(1); + document + .getRoot() + .listScenes()[0] + ?.addChild( + document + .createNode("bake-light") + .setTranslation([0, 3, 0]) + .setExtension("KHR_lights_punctual", light), + ); + return Buffer.from(await io.writeBinary(document)); +} + /** * A torus knot, the shape `virtual-pass.spec.ts` settled on: closed, indexed, every vertex * referenced. A sphere leaves two pole vertices no triangle uses and the pass's self-verify @@ -1161,26 +1182,7 @@ describe("compileAssets", () => { it("should run the configured lightmap UV pass through the built-in registry", async () => { const root = await makeTempDir("threenative-compile-lightmap-"); await mkdir(path.join(root, "assets")); - const source = await readFile( - new URL( - "../../create-threenative/templates/starter/assets/native-proof.glb", - import.meta.url, - ), - ); - const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); - const document = await io.readBinary(source); - const extension = document.createExtension(KHRLightsPunctual); - const light = extension.createLight("bake-light").setType("point").setIntensity(1); - document - .getRoot() - .listScenes()[0] - ?.addChild( - document - .createNode("bake-light") - .setTranslation([0, 3, 0]) - .setExtension("KHR_lights_punctual", light), - ); - await writeFile(path.join(root, "assets", "room.glb"), await io.writeBinary(document)); + await writeFile(path.join(root, "assets", "room.glb"), await lightmapFixtureGlb()); await compileAssets({ config: { models: { lightmap: { atlasSize: 128, padding: 2 } } }, @@ -1205,6 +1207,95 @@ describe("compileAssets", () => { expect(json.meshes[0].primitives[0].attributes.TEXCOORD_1).toEqual(expect.any(Number)); }); + it.each(["android", "ios"] as const)( + "cooks linear lightmaps on %s without decoders across workers and cache changes", + async (platform) => { + const root = await makeTempDir("threenative-mobile-lightmap-"); + await mkdir(path.join(root, "assets")); + const input = await lightmapFixtureGlb(); + await writeFile(path.join(root, "assets", "room.glb"), input); + const io = new NodeIO().registerExtensions(ALL_EXTENSIONS); + const secondRoom = await io.readBinary(input); + secondRoom.getRoot().listNodes()[0]?.setTranslation([0.25, 0, 0]); + await writeFile(path.join(root, "assets", "room2.glb"), await io.writeBinary(secondRoom)); + const config: IAssetSourceConfig = { + audio: "none", + textures: "none", + lod: false, + models: { + textures: "none", + passes: { meshopt: false }, + lightmap: { atlasSize: 128, padding: 2 }, + }, + }; + const options = { + config, + cwd: root, + platform, + runtimeDecoders: { ktx2: false, meshopt: false }, + }; + const worker = await compileAssets({ ...options, concurrency: 2 }); + expect(worker.concurrencyUsed).toBe(2); + const manifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ); + const lightmap = manifest.entries["room.glb"].lightmaps?.[0]; + expect(lightmap).toMatchObject({ format: "astc4x4", texCoord: 1 }); + const bytes = await readFile(path.join(root, "public", lightmap.output)); + const container = readKtx2(bytes); + expect(container.vkFormat).toBe(157); + expect(container.supercompressionScheme).toBe(0); + expect(container.globalData).toBeNull(); + expect(container.levelCount).toBe( + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1, + ); + expect(container.dataFormatDescriptor[0]).toMatchObject({ + colorPrimaries: 0, + transferFunction: 1, + texelBlockDimension: [3, 3, 0, 0], + }); + await expect( + decodeAstcKtx2(bytes, container.pixelWidth, container.pixelHeight), + ).resolves.toHaveLength(container.pixelWidth * container.pixelHeight * 4); + const warm = await compileAssets({ ...options, concurrency: 1 }); + expect(warm.written).toBe(0); + expect(warm.skipped).toBe(2); + const serial = await compileAssets({ ...options, concurrency: 1, output: "serial" }); + expect(serial.written).toBe(2); + const serialManifest = JSON.parse( + await readFile(path.join(root, "serial", "assets.manifest.json"), "utf8"), + ); + for (const name of ["room.glb", "room2.glb"]) { + const parallelEntry = manifest.entries[name]; + const serialEntry = serialManifest.entries[name]; + expect(serialEntry.output).toBe(parallelEntry.output); + expect(serialEntry.lightmaps[0].output).toBe(parallelEntry.lightmaps[0].output); + for (const output of [parallelEntry.output, parallelEntry.lightmaps[0].output]) + expect(await readFile(path.join(root, "serial", output))).toEqual( + await readFile(path.join(root, "public", output)), + ); + } + const basis = await compileAssets({ + ...options, + concurrency: 1, + platform: "web", + runtimeDecoders: { ktx2: true, meshopt: true }, + }); + expect(basis.written).toBe(2); + const basisManifest = JSON.parse( + await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), + ); + const basisLightmap = basisManifest.entries["room.glb"].lightmaps[0]; + expect(basisLightmap.format).toBe("etc1s"); + expect(basisLightmap.output).not.toBe(lightmap.output); + const basisBytes = await readFile(path.join(root, "public", basisLightmap.output)); + expect(readKtx2(basisBytes).vkFormat).toBe(0); + const restored = await compileAssets({ ...options, concurrency: 1 }); + expect(restored.written).toBe(2); + expect(await readFile(path.join(root, "public", lightmap.output))).toEqual(bytes); + }, + ); + it("should ship a model byte-identical when assets.models is none", async () => { const root = await makeTempDir("threenative-compile-model-none-"); await mkdir(path.join(root, "assets")); diff --git a/packages/assets/__tests__/lightmap-pass.spec.ts b/packages/assets/__tests__/lightmap-pass.spec.ts index da9e48a0b6..5f56e98c23 100644 --- a/packages/assets/__tests__/lightmap-pass.spec.ts +++ b/packages/assets/__tests__/lightmap-pass.spec.ts @@ -2,7 +2,7 @@ import { Document, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS, KHRLightsPunctual } from "@gltf-transform/extensions"; import { read as readKtx2 } from "ktx-parse"; import { describe, expect, it } from "vitest"; -import { lightmapPass } from "../src/passes/lightmap.js"; +import { createLightmapPass, lightmapPass } from "../src/passes/lightmap.js"; import { modelPass } from "../src/passes/model.js"; async function staticGlb(animated = false, indexed = true): Promise { @@ -95,6 +95,14 @@ function uniquePositions(primitive: Awaited>): } describe("lightmapPass", () => { + it("separates mobile lightmap cache keys while preserving the public Basis key", () => { + const options = { atlasSize: 128, padding: 2 }; + const basis = lightmapPass(options); + expect(basis.cacheKey).toBe(JSON.stringify(options)); + expect(createLightmapPass(options, false).cacheKey).toBe(basis.cacheKey); + expect(createLightmapPass(options, true).cacheKey).not.toBe(basis.cacheKey); + }); + it("writes TEXCOORD_1 for every baked primitive and preserves TEXCOORD_0", async () => { const input = await staticGlb(); const source = await readPrimitive(input); diff --git a/packages/assets/__tests__/shared-images.spec.ts b/packages/assets/__tests__/shared-images.spec.ts index 9f4c434951..2907119f46 100644 --- a/packages/assets/__tests__/shared-images.spec.ts +++ b/packages/assets/__tests__/shared-images.spec.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { Document, NodeIO } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { getTextureColorSpace, listTextureSlots } from "@gltf-transform/functions"; +import { KHR_DF_MODEL_ASTC, read as readKtx2 } from "ktx-parse"; import { MeshoptDecoder } from "meshoptimizer"; import { PNG } from "pngjs"; import { describe, expect, it, vi } from "vitest"; @@ -11,6 +12,7 @@ import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import { compileAssets } from "../src/index.js"; +import { decodeAstcKtx2 } from "../src/ktx2-encoder.js"; import { type IModelPassOptions, modelPass } from "../src/passes/model.js"; import { type ISharedImageStore, @@ -598,7 +600,7 @@ describe("shared model images", () => { expect(after).toEqual(before); }); - it("should resize shared embedded images to PNG and stay deduplicated on a decoder-free target", async () => { + it("should resize shared mobile images to raw ASTC and stay deduplicated", async () => { const root = await makeTempDir("threenative-shared-decoder-free-"); await mkdir(path.join(root, "assets")); const input = await fixture(0); @@ -615,21 +617,53 @@ describe("shared model images", () => { const directory = path.join(root, "public", "shared", "images"); const files = (await readdir(directory)).sort(); expect(files).toHaveLength(2); + const formats: number[] = []; for (const file of files) { - expect(file).toMatch(/^[0-9a-f]{16}\.none\.png$/u); - const png = PNG.sync.read(await readFile(path.join(directory, file))); - expect([png.width, png.height]).toEqual([16, 16]); + expect(file).toMatch(/^[0-9a-f]{16}\.astc4x4\.ktx2$/u); + const bytes = await readFile(path.join(directory, file)); + const container = readKtx2(bytes); + formats.push(container.vkFormat); + expect([container.pixelWidth, container.pixelHeight]).toEqual([16, 16]); + expect(container.levelCount).toBe(5); + expect(container.supercompressionScheme).toBe(0); + expect(container.globalData).toBeNull(); + expect(container.dataFormatDescriptor[0]).toMatchObject({ + colorModel: KHR_DF_MODEL_ASTC, + colorPrimaries: container.vkFormat === 158 ? 1 : 0, + transferFunction: container.vkFormat === 158 ? 2 : 1, + }); + await expect(decodeAstcKtx2(bytes, 16, 16)).resolves.toHaveLength(16 * 16 * 4); } + expect(formats.sort()).toEqual([157, 158]); const manifest = JSON.parse( await readFile(path.join(root, "public", "assets.manifest.json"), "utf8"), ) as { - entries: Record; + entries: Record; }; const a = manifest.entries["a.glb"]?.sharedImages?.map((image) => image.output).sort(); const b = manifest.entries["b.glb"]?.sharedImages?.map((image) => image.output).sort(); expect(a).toHaveLength(2); expect(a).toEqual(b); + for (const name of ["a.glb", "b.glb"]) { + const entry = manifest.entries[name]; + if (entry === undefined) throw new Error("shared mobile model entry missing"); + const json = unpackGlb(await readFile(path.join(root, "public", entry.output))).json; + expect(json.extensionsRequired).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).toContain("EXT_texture_astc"); + expect(json.extensionsUsed).not.toContain("KHR_texture_basisu"); + expect(json.textures).toHaveLength(2); + const sources = json.textures?.map((texture) => { + expect(texture.source).toBeUndefined(); + const astc = texture.extensions?.EXT_texture_astc as { source: number } | undefined; + return astc?.source; + }); + expect(sources?.sort()).toEqual([0, 1]); + expect(json.images?.every((image) => image.mimeType === "image/ktx2")).toBe(true); + expect(json.images?.map((image) => image.uri).sort()).toEqual( + entry.sharedImages?.map((image) => sharedImageUri(entry.output, image.output)).sort(), + ); + } }, 60_000); it("should key a decoder-free shared image by its size cap", async () => { diff --git a/packages/assets/src/apply-worker.ts b/packages/assets/src/apply-worker.ts index 6e42c1d9b6..5667917618 100644 --- a/packages/assets/src/apply-worker.ts +++ b/packages/assets/src/apply-worker.ts @@ -4,7 +4,7 @@ import type { IAssetPass } from "./compile.js"; import { applyPasses } from "./pass-chain.js"; import { audioPass } from "./passes/audio.js"; import { blenderImportPass } from "./passes/blender-import.js"; -import { lightmapPass } from "./passes/lightmap.js"; +import { createLightmapPass } from "./passes/lightmap.js"; import { modelPass } from "./passes/model.js"; import { createSharedImageStore } from "./passes/shared-images.js"; import { texturePass, textureResizePass } from "./passes/texture.js"; @@ -31,7 +31,7 @@ function buildInstances(specs: readonly PassSpec[], root: string): readonly IAss case "blender-import": return blenderImportPass(); case "lightmap": - return lightmapPass(spec.options); + return createLightmapPass(spec.options, spec.astc); case "model": return modelPass({ ...spec.options, diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index fb52be2094..454d9da7de 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -57,7 +57,7 @@ import type { IModelCompactSummary, } from "./passes/compact.js"; import { globMatch } from "./passes/glob.js"; -import { lightmapPass } from "./passes/lightmap.js"; +import { createLightmapPass } from "./passes/lightmap.js"; import type { ILightmapPassOptions } from "./passes/lightmap.js"; import { TEXTURE_SIZE_SLOTS, type TextureMaxSize } from "./passes/model-textures.js"; import { modelPass } from "./passes/model.js"; @@ -1716,9 +1716,10 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }); } if (lightmap !== undefined) { - registerBuiltin(lightmapPass(lightmap), { + registerBuiltin(createLightmapPass(lightmap, mobileAstc), { kind: "lightmap", - needsRuntimeDecoder: true, + ...(mobileAstc ? { astc: true } : {}), + needsRuntimeDecoder: !mobileAstc, options: lightmap, }); } diff --git a/packages/assets/src/passes/lightmap.ts b/packages/assets/src/passes/lightmap.ts index b869dd0821..55b22f76f5 100644 --- a/packages/assets/src/passes/lightmap.ts +++ b/packages/assets/src/passes/lightmap.ts @@ -238,6 +238,7 @@ async function bakeUv2( atlasSize: number, padding: number, logicalPath: string, + astc: boolean, ): Promise { const startedAt = performance.now(); const api = await createAtlasApi(); @@ -250,7 +251,7 @@ async function bakeUv2( ); writeAtlas(document, rows, atlas); const bake = bakeStaticLightmap(document, atlas.width, atlas.height, padding); - const lightmap = await encodeLinearRgbaKtx2(bake.pixels, bake.width, bake.height); + const lightmap = await encodeLinearRgbaKtx2(bake.pixels, bake.width, bake.height, astc); return { auxiliaryOutputs: [ { @@ -260,7 +261,7 @@ async function bakeUv2( metadata: { bytesBefore: bake.pixels.byteLength, dilatedTexels: bake.dilatedTexels, - format: "etc1s", + format: astc ? "astc4x4" : "etc1s", materialTargets: bake.materialTargets, occludedTexels: bake.occludedTexels, texCoord: 1, @@ -291,6 +292,11 @@ async function bakeUv2( } export function lightmapPass(options: ILightmapPassOptions): IAssetPass { + return createLightmapPass(options); +} + +/** Compiler-only target selection; public lightmap options remain target-independent. */ +export function createLightmapPass(options: ILightmapPassOptions, astc = false): IAssetPass { const atlasSize = positiveInteger(options.atlasSize, "atlasSize"); const padding = positiveInteger(options.padding, "padding"); // BC7 (and BC1/ETC2) compress in 4x4 blocks: an atlas edge that is not a multiple of four @@ -302,7 +308,11 @@ export function lightmapPass(options: ILightmapPassOptions): IAssetPass { } return { appliesTo: ["model"], - cacheKey: JSON.stringify({ atlasSize, padding }), + cacheKey: JSON.stringify({ + atlasSize, + padding, + ...(astc ? { gpuFormat: "astc4x4-linear-v1" } : {}), + }), name: "lightmap-uv2", async apply(input: Buffer, logicalPath: string): Promise { if (!/\.glb$/iu.test(logicalPath)) return input; @@ -310,7 +320,7 @@ export function lightmapPass(options: ILightmapPassOptions): IAssetPass { assertStatic(document, logicalPath); const rows = primitivesOf(document); if (rows.length === 0) return input; - return bakeUv2(document, rows, atlasSize, padding, logicalPath); + return bakeUv2(document, rows, atlasSize, padding, logicalPath, astc); }, }; } diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index b443ef431c..2c6bf3497b 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -7,6 +7,7 @@ import { IMAGE_QUALITY_IDENTITY, imageQuality, resolveImageQualityFloor, + transcodeKtx2ToAstc, } from "../image-quality.js"; import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../ktx2-encoder.js"; import { decodeImageBytes } from "./decode-image.js"; @@ -82,13 +83,14 @@ export async function encodeLinearRgbaKtx2( data: Uint8Array, width: number, height: number, + astc = false, ): Promise { const encoded = await encodeToKTX2(new Uint8Array([0]), { generateMipmap: true, imageDecoder: async () => ({ data, height, width }), isPerceptual: false, isSetKTX2SRGBTransferFunc: false, - isUASTC: false, + isUASTC: astc, qualityLevel: DEFAULT_ETC1S_QUALITY, }); const container = readKTX2(encoded); @@ -97,7 +99,7 @@ export async function encodeLinearRgbaKtx2( `TN_ASSETS_MIP_CHAIN_INCOMPLETE: generated lightmap encoded without a mip chain (${String(container.levelCount)} level(s)).`, ); } - return Buffer.from(encoded); + return Buffer.from(astc ? await transcodeKtx2ToAstc(encoded, 0) : encoded); } /** diff --git a/packages/assets/src/worker-protocol.ts b/packages/assets/src/worker-protocol.ts index f00c78db03..be221aa4ef 100644 --- a/packages/assets/src/worker-protocol.ts +++ b/packages/assets/src/worker-protocol.ts @@ -21,6 +21,7 @@ export type PassSpec = | { readonly kind: "blender-import"; readonly needsRuntimeDecoder: boolean } | { readonly kind: "lightmap"; + readonly astc?: boolean; readonly needsRuntimeDecoder: boolean; readonly options: ILightmapPassOptions; } From d428f9839050197e6b226f68596bfa9cf1a2850f Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 23:08:27 -0700 Subject: [PATCH 19/57] docs(prd568): record latest partial native and assets proof Record the own-app rendernode diagnostic, independent colour and packaged ASTC checks, empty Opus timeout, unavailable process map, and incomplete two-worker assets suite. Keep acceptance and cook/native proof open at prd:25%. Validation: 3,025 documentation links; 244 documentation/CI contract tests; PRD progress and diff checks. --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index ef701ea8fb..2755930b09 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,12 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, latest native/assets checkpoint (`6f44b81a2d738e43da36d467c0b54ae0954d1fc6`):** the original QuickJS runner exited 1 in `artifacts/prd568-quickjs-local-20261011T055813Z/` (`run.log`, `terminal.txt`, `report.json`). Seven of eight assertions pass; diagnostics fail on the sole own-PID 15933 error `E/platform: Failed to open rendernode: No such file or directory`, mirrored into runtime diagnostics. Foreign PID 30108 contributes zero entries. QuickJS and the NVIDIA/Vulkan ASTC adapter are recorded; ready is 1,865.131662 ms with compilation settled. `source-guard.json` and input hashes match before/after at the same HEAD. + + Independent unchanged screenshot inspection passes at actual 960×640, with both colour counts per specimen `[[10455,10742],[7156,6679],[9500,9834]]` (`colour-result.json`). `packaged-checker-result.json` passes the 1,696-byte vk158 32×32 NONE/six-mip checker, byte-identical to cooked; the packaged bundle contains no `WebAssembly`. APK SHA256 remains `bbf140901b8cc3aada33bc9f45c6af3ed9735c24e5c38f888956435805ccbca5` and matches its build report. These independent observations do not pass the failed scenario or supply a visual verdict. The single 120 s Opus5.5 HIGH diagnosis attempt exited 124 with empty stdout/stderr (`opus-rendernode/`); the later read-only `/proc/15933/maps` capture returned “No such file or directory” and stopped (`provider/`). Provider origin, cause and optionality remain unverified; no waiver or retry follows. + + Latest full assets run: Node 20.19.6, `--maxWorkers=2`, supervisor exit 124 at 300.039 s, six files reporting 150 passed/one skipped and no observed failure before timeout. `/tmp/prd568-lightmap-baseline/full-assets-workers2.log` and `/tmp/prd568-lightmap-baseline/full-assets-workers2-terminal.json` retain the incomplete run. Full cook validation, native and acceptance boxes remain OPEN; progress remains `prd:25%`. + - **2026-10-10, generated mobile lightmaps (partial checkpoint):** private compiler/worker selection now emits generated Android/iOS lightmaps as raw ASTC 4×4 UNORM (`vkFormat` 157), linear transfer/primaries 0, full mips and NONE supercompression. Public lightmap options and existing web/desktop decoder admission are unchanged. Focused RED reproduced both omitted mobile lightmaps; final focused source tests passed 14 cases (65 skipped, 45.38 s), covering actual all-mip decoding, two-worker/sequential model and atlas byte equality, warm cache, target-switch invalidation and the unchanged public Basis cache key. The coordinator independently reran both mobile cases successfully (14.46 s; `/home/joao/.cache/agent-dumps/unreal-borrowing/raw/lightmap-review-gate.log`), and a fresh read-only critic returned PASS. Assets typecheck, focused Biome and diff checks passed. Pre-edit compiler output trees are `/tmp/prd568-lightmap-baseline/{web,desktop}/public`; post-edit trees are `/tmp/prd568-lightmap-baseline/{after-web,after-desktop}/public`. `freeze.log` and `byte-identity.log` in that directory record exact model/lightmap filename and byte equality for both platforms using the retained `input.glb`; this is fixture-specific byte proof, not a timing claim. Generated linear lightmaps and authored normal/data slots have no semantic colour-quality certification; `unvalidated-slots` is not a colour pass. Full `pnpm exec vitest run packages/assets/__tests__` on Node 20.19.6 was stopped at the first observed failure (TERM, exit 143, before its 300 s limit; `/tmp/prd568-lightmap-baseline/full-assets.log`). `shared-images.spec.ts:601`, “should resize shared embedded images to PNG and stay deduplicated on a decoder-free target”, failed in the 21-case file (one failure; file runtime 160.81 s). The isolated follow-up reproduced its obsolete `.none.png` assertion against actual `57467331719e9457.astc4x4.ktx2` (one failed, 20 skipped, 5.63 s; `/tmp/prd568-lightmap-baseline/shared-mobile-red.log`); the corrected Android default-format test passes without changing production policy (one passed, 20 skipped, 4.42 s; `/tmp/prd568-lightmap-baseline/shared-mobile-green.log`), requiring both raw ASTC formats, 16×16/five-mip/NONE containers, matching metadata, installed all-mip decoding, required glTF ASTC ownership and the original two-file deduplication. Existing web/desktop decoder admission was retained after review; the final combined lightmap/shared-image focus passed 15 tests (85 skipped, 37.92 s; `/tmp/prd568-lightmap-baseline/compat-focused-green.log`). Four completed full-run files reported 106 passes, one failure and no skips before termination; the unfinished suite has no overall runtime/result. Assets typecheck passes, and documentation checks passed 244 tests plus 3,025 link checks before this result-only update. The interrupted run has no final suite summary, and the complete cook box remains unticked. A local partial checkpoint is authorized for the focused increment; the failed full gate earns no cook completion claim and no push is authorized. @@ -138,7 +144,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; the later `artifacts/prd568-quickjs-local-20261011T052548Z/` run passes checker colour and seven startup/resource assertions but exits 1 on foreign-PID diagnostics; clean attribution and visual acceptance remain OPEN. +- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; the latest `artifacts/prd568-quickjs-local-20261011T055813Z/` run passes checker colour/package inspection and seven startup/resource assertions, with zero foreign-PID entries, but exits 1 on the own-PID `E/platform` rendernode error; clean native diagnostics and visual acceptance remain OPEN. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. ## Blocked on @@ -160,9 +166,9 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone, embedded/shared and generated-lightmap Android/iOS ASTC cooking now have the focused format, decoder-map, worker and cache proof recorded above. Earlier six-pair web/desktop comparisons retain exact manifests, bake receipts and output-tree proof (22 primary and 44 receipt-owned payload matches); the latest generated-lightmap increment also preserves exact web/desktop model and atlas bytes in `/tmp/prd568-lightmap-baseline/byte-identity.log`. The latest full assets run failed at the existing cap test’s unchanged 60 s timeout; its isolated pass does not supply the missing full-suite proof, so this complete cook box stays unticked. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. + Partial: standalone, embedded/shared and generated-lightmap Android/iOS ASTC cooking now have the focused format, decoder-map, worker and cache proof recorded above. Earlier six-pair web/desktop comparisons retain exact manifests, bake receipts and output-tree proof (22 primary and 44 receipt-owned payload matches); the latest generated-lightmap increment also preserves exact web/desktop model and atlas bytes in `/tmp/prd568-lightmap-baseline/byte-identity.log`. The latest full assets run with two workers timed out at 300.039 s (exit 124; six files reported 150 passed/one skipped, no observed failure); the incomplete run does not supply full-suite proof, so this complete cook box stays unticked. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. - Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the later checker/resource observations and unresolved foreign-PID diagnostics are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest checker/resource observations and unresolved own-PID rendernode diagnostic are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. #### Phase 2: Load and check on the host **Status:** PARTIAL From 8ef69785836f4f14f1a56540c13495b1277f8fe5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 23:30:32 -0700 Subject: [PATCH 20/57] Record verified ASTC cook and adapter guard proofs Complete Phase 1 from the full assets suite and fixture byte comparisons; record the scoped adapter-guard red/green proof. Keep native acceptance and APK size comparison open. Validation: 3025 documentation links, 244 documentation tests, and PRD progress at 75%. --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 2755930b09..d33d4e42d2 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Mobile ASTC cooking and packaging admission are implemented; full assets validation and native startup/rendering proof remain open. +**Priority:** P2 — Mobile ASTC cooking and packaging admission are verified; native startup/rendering, APK size comparison and acceptance proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, full assets qualification:** `pnpm exec vitest run packages/assets/__tests__ --maxWorkers=1 --bail=1` on Node 20.19.6 passed 623 tests (two skipped), with 39 files passing and one skipped, exit 0, 809.100 s supervisor elapsed / 807.17 s Vitest duration; existing test limits were unchanged. `/tmp/prd568-lightmap-baseline/full-assets-workers1.log` and `/tmp/prd568-lightmap-baseline/full-assets-workers1-terminal.json` retain the final proof, completing the cook box together with the historical format/missing-lightmap RED cases and six original/current web/desktop fixture pairs plus two latest lightmap byte comparisons recorded below. Phase 1 is DONE; AC-1, AC-2 and the APK size comparison remain OPEN, with no native or visual acceptance claim. + - **2026-10-10, latest native/assets checkpoint (`6f44b81a2d738e43da36d467c0b54ae0954d1fc6`):** the original QuickJS runner exited 1 in `artifacts/prd568-quickjs-local-20261011T055813Z/` (`run.log`, `terminal.txt`, `report.json`). Seven of eight assertions pass; diagnostics fail on the sole own-PID 15933 error `E/platform: Failed to open rendernode: No such file or directory`, mirrored into runtime diagnostics. Foreign PID 30108 contributes zero entries. QuickJS and the NVIDIA/Vulkan ASTC adapter are recorded; ready is 1,865.131662 ms with compilation settled. `source-guard.json` and input hashes match before/after at the same HEAD. Independent unchanged screenshot inspection passes at actual 960×640, with both colour counts per specimen `[[10455,10742],[7156,6679],[9500,9834]]` (`colour-result.json`). `packaged-checker-result.json` passes the 1,696-byte vk158 32×32 NONE/six-mip checker, byte-identical to cooked; the packaged bundle contains no `WebAssembly`. APK SHA256 remains `bbf140901b8cc3aada33bc9f45c6af3ed9735c24e5c38f888956435805ccbca5` and matches its build report. These independent observations do not pass the failed scenario or supply a visual verdict. The single 120 s Opus5.5 HIGH diagnosis attempt exited 124 with empty stdout/stderr (`opus-rendernode/`); the later read-only `/proc/15933/maps` capture returned “No such file or directory” and stopped (`provider/`). Provider origin, cause and optionality remain unverified; no waiver or retry follows. @@ -163,10 +165,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Execution Phases #### Phase 1: Cook and admit raw ASTC -**Status:** PARTIAL +**Status:** DONE **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` -- [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: standalone, embedded/shared and generated-lightmap Android/iOS ASTC cooking now have the focused format, decoder-map, worker and cache proof recorded above. Earlier six-pair web/desktop comparisons retain exact manifests, bake receipts and output-tree proof (22 primary and 44 receipt-owned payload matches); the latest generated-lightmap increment also preserves exact web/desktop model and atlas bytes in `/tmp/prd568-lightmap-baseline/byte-identity.log`. The latest full assets run with two workers timed out at 300.039 s (exit 124; six files reported 150 passed/one skipped, no observed failure); the incomplete run does not supply full-suite proof, so this complete cook box stays unticked. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. +- [x] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` + Verified cook scope: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the final full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Standalone, embedded/shared and generated-lightmap Android/iOS cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest checker/resource observations and unresolved own-PID rendernode diagnostic are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. @@ -174,8 +176,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. -- [ ] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - Partial: the core assets suite passed all 82 tests, including unsupported ASTC guards. The pre-fix native attempt failed at support detection even though the adapter advertised ASTC. The loader fix now has the supported-adapter checker/resource observations recorded above; unsupported-adapter native startup with the intended named ASTC error remains unproved. +- [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` + Verified unit-scoped proof: public `assets.texture()` tests parse actual raw vk157/vk158 fixtures with the installed `KTX2Loader` across WebGL/WebGPU capability maps. BC-only and ETC2-only support rejects with `TN_ASSETS_ASTC_UNSUPPORTED` naming the asset URL and disposes the texture once; ASTC support retains the mip payloads. Historical RED: eight failed/five passed/69 skipped, exit 1, 1.82 s (`/tmp/prd568-core-astc-guard-red.log`); GREEN: 13 passed/69 skipped, exit 0, 855 ms (`/tmp/prd568-core-astc-guard-green.log`), with original exit attribution retained in `/tmp/prd568-core-astc-guard-8vsdw86a/actual.patch`. Current focused `-t 'ASTC|astc' --maxWorkers=1` proof passed 28 tests (54 deselected), exit 0, 7.82 s (`/tmp/prd568-core-astc-requirement-audit.log`). This completes the specified unit-test proof; it claims no unsupported-adapter native process run or visual result. AC-1 and AC-2 remain open. - [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. From d95bc84ff350da63ef8514af3600dcfb3c5e052e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 23:50:42 -0700 Subject: [PATCH 21/57] Preserve Android runtime console scope with app attribution Restore the prior runtime string selection within the launched-app PID scope while retaining critical crash records and unknown-identity failure. Regression RED: two vendor cases failed with six crash controls passing. GREEN: 68 nearest tests, playtest typecheck, Biome and documentation checks pass; fresh read-only critic PASS. AC1 and AC2 remain open. --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 2 + .../__tests__/android-console.spec.ts | 50 +++++++++++++++++++ packages/playtest/src/runner/android.ts | 5 +- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index d33d4e42d2..ad41cb62dc 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, Android console compatibility correction:** the earlier PID-attribution fix unintentionally expanded the console from the original `Mystral|THREENATIVE|chromium` string selection to every own-PID log line. The collector now combines that exact original selection with app ownership, retaining E/F `AndroidRuntime`, `libc` and `DEBUG` crash records, explicit-package crash reports and mandatory unknown-identity failure. Two unrelated vendor-tag cases reproduced the broadening (two failed/six crash controls passed, exit 1, 5.14 s); the nearest console/lifecycle/rotation suites pass all 68 tests (exit 0, 12.11 s), playtest typecheck passes, and a fresh read-only critic returned PASS (`/tmp/prd568-console-tag-contract-{red,nearest-green,typecheck,biome}.log`). This is a console-contract correction, with no provider-cause, harmlessness or special-literal claim. The separate provenance-context Opus5.5 HIGH attempt returned no diagnosis (exit 124, 300.406 s, empty stdout/stderr; `artifacts/prd568-quickjs-local-20261011T055813Z/opus-rendernode-provenance/`). AC-1 and AC-2 remain OPEN. + - **2026-10-10, full assets qualification:** `pnpm exec vitest run packages/assets/__tests__ --maxWorkers=1 --bail=1` on Node 20.19.6 passed 623 tests (two skipped), with 39 files passing and one skipped, exit 0, 809.100 s supervisor elapsed / 807.17 s Vitest duration; existing test limits were unchanged. `/tmp/prd568-lightmap-baseline/full-assets-workers1.log` and `/tmp/prd568-lightmap-baseline/full-assets-workers1-terminal.json` retain the final proof, completing the cook box together with the historical format/missing-lightmap RED cases and six original/current web/desktop fixture pairs plus two latest lightmap byte comparisons recorded below. Phase 1 is DONE; AC-1, AC-2 and the APK size comparison remain OPEN, with no native or visual acceptance claim. - **2026-10-10, latest native/assets checkpoint (`6f44b81a2d738e43da36d467c0b54ae0954d1fc6`):** the original QuickJS runner exited 1 in `artifacts/prd568-quickjs-local-20261011T055813Z/` (`run.log`, `terminal.txt`, `report.json`). Seven of eight assertions pass; diagnostics fail on the sole own-PID 15933 error `E/platform: Failed to open rendernode: No such file or directory`, mirrored into runtime diagnostics. Foreign PID 30108 contributes zero entries. QuickJS and the NVIDIA/Vulkan ASTC adapter are recorded; ready is 1,865.131662 ms with compilation settled. `source-guard.json` and input hashes match before/after at the same HEAD. diff --git a/packages/playtest/__tests__/android-console.spec.ts b/packages/playtest/__tests__/android-console.spec.ts index 821c7fe5cc..56636c30cc 100644 --- a/packages/playtest/__tests__/android-console.spec.ts +++ b/packages/playtest/__tests__/android-console.spec.ts @@ -57,6 +57,56 @@ test("Android console retains its launched pid's errors after that process exits expect(await driver.captureConsole()).toEqual([{ text: error, type: "error" }]); }); +test.each([ + "E/platform(15365): driver-specific diagnostic", + "F/VendorDisplay(15365): vendor-specific diagnostic", +])( + "Android console preserves runtime tag selection for own-PID vendor records: %s", + async (vendor) => { + const own = "E/MystralRuntime(15365): TN_NATIVE_START_FAILED: authored startup failed"; + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return "15365\n"; + if (args[0] === "logcat" && args[1] === "-d") return `${vendor}\n${own}`; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + expect(await driver.captureConsole()).toEqual([{ text: own, type: "error" }]); + }, +); + +test.each( + ["AndroidRuntime", "libc", "DEBUG"].flatMap((tag) => + ["E", "F"].map((level) => `${level}/${tag.padEnd(8, " ")}(15365): startup crash evidence`), + ), +)( + "Android console retains own-PID critical crash records beyond runtime tags: %s", + async (crash) => { + const driver = new AdbAndroidDriver({ + activity: ".MystralActivity", + adbPath: "/nonexistent/adb", + packageName: "com.threenative.vq01", + user: "0", + }); + (driver as unknown as { adb: (args: readonly string[]) => Promise }).adb = async ( + args, + ) => { + if (args[1] === "pidof") return "15365\n"; + if (args[0] === "logcat" && args[1] === "-d") return crash; + return ""; + }; + await driver.prepare("http://127.0.0.1:41777/playtest"); + expect(await driver.captureConsole()).toEqual([{ text: crash, type: "error" }]); + }, +); + test.each([ [ "activity launch", diff --git a/packages/playtest/src/runner/android.ts b/packages/playtest/src/runner/android.ts index 61421edcb8..42324ccf7e 100644 --- a/packages/playtest/src/runner/android.ts +++ b/packages/playtest/src/runner/android.ts @@ -815,7 +815,10 @@ function parseAndroidConsoleEntries( if (app.pids.size === 0) return line.trim().length > 0; const pid = /^[VDIWEF]\/[^\n(]+\(\s*(\d+)\)/u.exec(line)?.[1]; return ( - (pid !== undefined && app.pids.has(pid)) || + (pid !== undefined && + app.pids.has(pid) && + (/Mystral|THREENATIVE|chromium/u.test(line) || + /^[EF]\/(?:AndroidRuntime|libc|DEBUG)\s*\(/u.test(line))) || (/^[EF]\//u.test(line) && packageRecord!.test(line)) ); }) From b8bd0e9c4b29bb0d932034e022a68716ad620506 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sat, 10 Oct 2026 23:57:36 -0700 Subject: [PATCH 22/57] Reopen mobile ASTC cooking for the V8 target gap Keep the historical assets suite proof while recording that ordinary V8 capability maps still select Basis. Record the original QuickJS eight-assertion pass and independent checker evidence without visual acceptance or driver-cause claims. Documentation checks: 3025 links and 244 tests pass; progress returns to 50% with AC1 and AC2 open. --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index ad41cb62dc..1ed53519c2 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Mobile ASTC cooking and packaging admission are verified; native startup/rendering, APK size comparison and acceptance proof remain open. +**Priority:** P2 — V8 mobile ASTC cooking, APK size comparison and native/visual acceptance proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-10, mobile-target requirement reconciliation:** Solution 1 requires ASTC cooking for all Android/iOS targets, including V8; the current selection in `packages/assets/src/compile.ts` additionally requires `!runtimeDecoders.ktx2`, so ordinary Android V8 (`ktx2: true`) still cooks Basis. The historical 623-pass full assets run remains valid for its assertions but does not prove that V8 target requirement; the cook box is reopened and Phase 1 is PARTIAL, superseding the earlier completion claim. The unchanged QuickJS proof at `artifacts/prd568-quickjs-compat-20261011T065204Z/` passes all eight original assertions (exit 0, 15.178 s), with matching source hashes, actual x86_64 QuickJS/ASTC provenance, both checker colours at 960×640 and the packaged six-mip vk158 checker independently passing. Its raw own-PID driver log still contains the rendernode error; the restored original runtime-console contract reports zero console errors, without claiming a physical driver fix. The captures are UNJUDGED; AC-1 and AC-2 remain OPEN. + - **2026-10-10, Android console compatibility correction:** the earlier PID-attribution fix unintentionally expanded the console from the original `Mystral|THREENATIVE|chromium` string selection to every own-PID log line. The collector now combines that exact original selection with app ownership, retaining E/F `AndroidRuntime`, `libc` and `DEBUG` crash records, explicit-package crash reports and mandatory unknown-identity failure. Two unrelated vendor-tag cases reproduced the broadening (two failed/six crash controls passed, exit 1, 5.14 s); the nearest console/lifecycle/rotation suites pass all 68 tests (exit 0, 12.11 s), playtest typecheck passes, and a fresh read-only critic returned PASS (`/tmp/prd568-console-tag-contract-{red,nearest-green,typecheck,biome}.log`). This is a console-contract correction, with no provider-cause, harmlessness or special-literal claim. The separate provenance-context Opus5.5 HIGH attempt returned no diagnosis (exit 124, 300.406 s, empty stdout/stderr; `artifacts/prd568-quickjs-local-20261011T055813Z/opus-rendernode-provenance/`). AC-1 and AC-2 remain OPEN. - **2026-10-10, full assets qualification:** `pnpm exec vitest run packages/assets/__tests__ --maxWorkers=1 --bail=1` on Node 20.19.6 passed 623 tests (two skipped), with 39 files passing and one skipped, exit 0, 809.100 s supervisor elapsed / 807.17 s Vitest duration; existing test limits were unchanged. `/tmp/prd568-lightmap-baseline/full-assets-workers1.log` and `/tmp/prd568-lightmap-baseline/full-assets-workers1-terminal.json` retain the final proof, completing the cook box together with the historical format/missing-lightmap RED cases and six original/current web/desktop fixture pairs plus two latest lightmap byte comparisons recorded below. Phase 1 is DONE; AC-1, AC-2 and the APK size comparison remain OPEN, with no native or visual acceptance claim. @@ -167,10 +169,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Execution Phases #### Phase 1: Cook and admit raw ASTC -**Status:** DONE +**Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` -- [x] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Verified cook scope: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the final full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Standalone, embedded/shared and generated-lightmap Android/iOS cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification, clean native diagnostics and visual acceptance remain open. +- [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` + Partial: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Existing standalone, embedded/shared and generated-lightmap cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior, but ordinary Android V8 with `runtimeDecoders.ktx2: true` still selects Basis and lacks the required ASTC proof. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest checker/resource observations and unresolved own-PID rendernode diagnostic are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. From ed6338a716566c47bedb5249aec51091bf0e30dc Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:04:15 -0700 Subject: [PATCH 23/57] Record the blocked V8 baseline and provisioning requirement Retain the cooked-only Basis evidence and missing receipt gate without claiming a V8 APK, runtime, transcoder or timing result. Defer the existing producer for this bounded run while the offload laptop lacks thermal room. Validation: 3025 documentation links, 244 document tests and unchanged 50% PRD progress; cook and AC1/AC2 stay open. --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 1ed53519c2..c637f69c9b 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, V8 baseline blocked:** the unchanged original V8 runner stopped before APK creation/install at Gradle `:app:verifyV8Dependency`, which runs `node packages/runtime-native/scripts/build-android-v8.mjs --verify`; the lane lacks `packages/runtime-native/third_party/v8-android/build-receipt.json` (exit 1, 8.973 s; `artifacts/prd568-v8-basis-before-20261011T065815Z/`). Its retained 529-byte cooked checker is UASTC Basis (vk0, DFD166, ZSTD, 32×32, six mips), proving cooking only; there is no V8 packaged/runtime, transcoder-call or launch-timing proof. The primary checkout's old V8 cache has libraries/snapshots but lacks the required provenance receipt, STL payloads and licenses; no receipt is fabricated and the verifier is not bypassed. Recovery uses the existing `build-android-v8.mjs` producer, which builds both ABIs; no measured ETA is available. Provisioning is deferred for this bounded run because the offload laptop's last status was 100°C with no room, rather than permanently declined. The cook box and AC-1/AC-2 remain OPEN at `prd:50%`. + - **2026-10-10, mobile-target requirement reconciliation:** Solution 1 requires ASTC cooking for all Android/iOS targets, including V8; the current selection in `packages/assets/src/compile.ts` additionally requires `!runtimeDecoders.ktx2`, so ordinary Android V8 (`ktx2: true`) still cooks Basis. The historical 623-pass full assets run remains valid for its assertions but does not prove that V8 target requirement; the cook box is reopened and Phase 1 is PARTIAL, superseding the earlier completion claim. The unchanged QuickJS proof at `artifacts/prd568-quickjs-compat-20261011T065204Z/` passes all eight original assertions (exit 0, 15.178 s), with matching source hashes, actual x86_64 QuickJS/ASTC provenance, both checker colours at 960×640 and the packaged six-mip vk158 checker independently passing. Its raw own-PID driver log still contains the rendernode error; the restored original runtime-console contract reports zero console errors, without claiming a physical driver fix. The captures are UNJUDGED; AC-1 and AC-2 remain OPEN. - **2026-10-10, Android console compatibility correction:** the earlier PID-attribution fix unintentionally expanded the console from the original `Mystral|THREENATIVE|chromium` string selection to every own-PID log line. The collector now combines that exact original selection with app ownership, retaining E/F `AndroidRuntime`, `libc` and `DEBUG` crash records, explicit-package crash reports and mandatory unknown-identity failure. Two unrelated vendor-tag cases reproduced the broadening (two failed/six crash controls passed, exit 1, 5.14 s); the nearest console/lifecycle/rotation suites pass all 68 tests (exit 0, 12.11 s), playtest typecheck passes, and a fresh read-only critic returned PASS (`/tmp/prd568-console-tag-contract-{red,nearest-green,typecheck,biome}.log`). This is a console-contract correction, with no provider-cause, harmlessness or special-literal claim. The separate provenance-context Opus5.5 HIGH attempt returned no diagnosis (exit 124, 300.406 s, empty stdout/stderr; `artifacts/prd568-quickjs-local-20261011T055813Z/opus-rendernode-provenance/`). AC-1 and AC-2 remain OPEN. From 71e6ef72afba9ef4a6ab84daa57e545c3db81491 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:06:56 -0700 Subject: [PATCH 24/57] fix(assets): cook ASTC for every mobile runtime --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 4 +- packages/assets/__tests__/compile.spec.ts | 47 ++++++++++++++----- packages/assets/src/compile.ts | 4 +- 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index c637f69c9b..1596386fa7 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, all mobile runtimes cook ASTC:** the private mobile selection now depends only on the Android/iOS target, including decoder-capable V8. Six new full-capability standalone, embedded and generated-lightmap cases first failed on the old Basis output (exit 1, 16.163 s); the complete affected groups then passed 26 cases (46 skipped, exit 0, 56.551 s), retaining explicit-none/custom-decoder controls, web/desktop Basis, actual ASTC decoding, worker/serial bytes, warm cache and target switches. Assets typecheck and diff checks pass; focused Biome passes with 15 warnings. Raw logs and terminal receipts are `/tmp/prd568-lightmap-baseline/all-mobile-target-{red,green}.log` and the matching `*-terminal.json`; typecheck/Biome logs share that prefix. The coordinator reviewed the two-file diff and a fresh read-only requirement audit returned PASS. Existing frozen web/desktop fixtures and byte-comparison receipts remain preserved. Fresh full assets qualification is pending, so the cook box remains OPEN at `prd:50%`; AC-1/AC-2 and semantic/visual acceptance remain OPEN. + - **2026-10-11, V8 baseline blocked:** the unchanged original V8 runner stopped before APK creation/install at Gradle `:app:verifyV8Dependency`, which runs `node packages/runtime-native/scripts/build-android-v8.mjs --verify`; the lane lacks `packages/runtime-native/third_party/v8-android/build-receipt.json` (exit 1, 8.973 s; `artifacts/prd568-v8-basis-before-20261011T065815Z/`). Its retained 529-byte cooked checker is UASTC Basis (vk0, DFD166, ZSTD, 32×32, six mips), proving cooking only; there is no V8 packaged/runtime, transcoder-call or launch-timing proof. The primary checkout's old V8 cache has libraries/snapshots but lacks the required provenance receipt, STL payloads and licenses; no receipt is fabricated and the verifier is not bypassed. Recovery uses the existing `build-android-v8.mjs` producer, which builds both ABIs; no measured ETA is available. Provisioning is deferred for this bounded run because the offload laptop's last status was 100°C with no room, rather than permanently declined. The cook box and AC-1/AC-2 remain OPEN at `prd:50%`. - **2026-10-10, mobile-target requirement reconciliation:** Solution 1 requires ASTC cooking for all Android/iOS targets, including V8; the current selection in `packages/assets/src/compile.ts` additionally requires `!runtimeDecoders.ktx2`, so ordinary Android V8 (`ktx2: true`) still cooks Basis. The historical 623-pass full assets run remains valid for its assertions but does not prove that V8 target requirement; the cook box is reopened and Phase 1 is PARTIAL, superseding the earlier completion claim. The unchanged QuickJS proof at `artifacts/prd568-quickjs-compat-20261011T065204Z/` passes all eight original assertions (exit 0, 15.178 s), with matching source hashes, actual x86_64 QuickJS/ASTC provenance, both checker colours at 960×640 and the packaged six-mip vk158 checker independently passing. Its raw own-PID driver log still contains the rendernode error; the restored original runtime-console contract reports zero console errors, without claiming a physical driver fix. The captures are UNJUDGED; AC-1 and AC-2 remain OPEN. @@ -174,7 +176,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Status:** PARTIAL **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Existing standalone, embedded/shared and generated-lightmap cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior, but ordinary Android V8 with `runtimeDecoders.ktx2: true` still selects Basis and lacks the required ASTC proof. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. + Partial: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Existing standalone, embedded/shared and generated-lightmap cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior, and the all-mobile target correction now passes six new full-capability RED/GREEN cases within 26 focused passes, including Android V8-equivalent decoder capabilities. Fresh full-suite qualification after that correction is pending. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest checker/resource observations and unresolved own-PID rendernode diagnostic are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. diff --git a/packages/assets/__tests__/compile.spec.ts b/packages/assets/__tests__/compile.spec.ts index 9ab68b7ce4..bbb5305f83 100644 --- a/packages/assets/__tests__/compile.spec.ts +++ b/packages/assets/__tests__/compile.spec.ts @@ -268,16 +268,21 @@ describe("compileAssets", () => { expect(webManifest.entries["rock.png"]?.output).toMatch(/\.ktx2$/u); }); - it.each(["android", "ios"] as const)( - "cooks embedded ASTC on %s while retaining Meshopt", - async (platform) => { + it.each([ + ["android", false], + ["android", true], + ["ios", false], + ["ios", true], + ] as const)( + "cooks embedded ASTC on %s with ktx2=%s while retaining Meshopt", + async (platform, ktx2) => { const root = await makeTempDir("threenative-embedded-astc-"); await mkdir(path.join(root, "assets")); await writeFile(path.join(root, "assets", "character.glb"), await singleImageFixtureGlb()); const result = await compileAssets({ cwd: root, platform, - runtimeDecoders: { ktx2: false, meshopt: true }, + runtimeDecoders: { ktx2, meshopt: true }, config: { budget: "none", textures: "none", @@ -319,7 +324,7 @@ describe("compileAssets", () => { await compileAssets({ cwd: root, platform, - runtimeDecoders: { ktx2: false, meshopt: true }, + runtimeDecoders: { ktx2, meshopt: true }, config: { budget: "none", textures: "none", @@ -378,14 +383,25 @@ describe("compileAssets", () => { ) as { entries: Record }; const entry = manifest.entries["rock.png"]; if (entry === undefined) throw new Error("fixture texture is missing from the manifest"); - const container = readKtx2(await readFile(path.join(root, "public", entry.output))); - expect(container.vkFormat).toBe(ktx2 ? 0 : 158); - expect(entry.format).toBe(ktx2 ? "etc1s" : "astc4x4"); + const mobile = platform === "android" || platform === "ios"; + const bytes = await readFile(path.join(root, "public", entry.output)); + const container = readKtx2(bytes); + expect(container.vkFormat).toBe(mobile ? 158 : 0); + expect(entry.format).toBe(mobile ? "astc4x4" : "etc1s"); expect(container.levelCount).toBeGreaterThan(1); + if (mobile) { + expect(container.supercompressionScheme).toBe(0); + expect(container.levelCount).toBe( + Math.floor(Math.log2(Math.max(container.pixelWidth, container.pixelHeight))) + 1, + ); + await expect( + decodeAstcKtx2(bytes, container.pixelWidth, container.pixelHeight), + ).resolves.toHaveLength(container.pixelWidth * container.pixelHeight * 4); + } expect(result.skippedCompression.filter((row) => row.kind === "texture")).toEqual([]); for (const name of ["basis_transcoder.js", "basis_transcoder.wasm"]) { const output = path.join(root, "public", "basis", name); - if (ktx2) expect((await stat(output)).size).toBeGreaterThan(0); + if (ktx2 && !mobile) expect((await stat(output)).size).toBeGreaterThan(0); else await expect(stat(output)).rejects.toMatchObject({ code: "ENOENT" }); } }, @@ -1207,9 +1223,14 @@ describe("compileAssets", () => { expect(json.meshes[0].primitives[0].attributes.TEXCOORD_1).toEqual(expect.any(Number)); }); - it.each(["android", "ios"] as const)( - "cooks linear lightmaps on %s without decoders across workers and cache changes", - async (platform) => { + it.each([ + ["android", false], + ["android", true], + ["ios", false], + ["ios", true], + ] as const)( + "cooks linear lightmaps on %s with decoder capabilities=%s across workers and cache changes", + async (platform, decoders) => { const root = await makeTempDir("threenative-mobile-lightmap-"); await mkdir(path.join(root, "assets")); const input = await lightmapFixtureGlb(); @@ -1232,7 +1253,7 @@ describe("compileAssets", () => { config, cwd: root, platform, - runtimeDecoders: { ktx2: false, meshopt: false }, + runtimeDecoders: { ktx2: decoders, meshopt: decoders }, }; const worker = await compileAssets({ ...options, concurrency: 2 }); expect(worker.concurrencyUsed).toBe(2); diff --git a/packages/assets/src/compile.ts b/packages/assets/src/compile.ts index 454d9da7de..12ad11ebd0 100644 --- a/packages/assets/src/compile.ts +++ b/packages/assets/src/compile.ts @@ -1619,9 +1619,7 @@ function resolveLayout(cwd: string, options: IAssetCompileOptions): ICompileLayo }; const runtimeDecoderAvailable = runtimeDecoderCapabilities.ktx2 && runtimeDecoderCapabilities.meshopt; - const mobileAstc = - (options.platform === "android" || options.platform === "ios") && - !runtimeDecoderCapabilities.ktx2; + const mobileAstc = options.platform === "android" || options.platform === "ios"; const audio = parseAudioConfig(config.audio); const configuredTextures = parseTexturesConfig(config.textures); const configuredModels = parseModelsConfig(config.models); From b09a10a0e697bbb7d59a4c4085b325ba3ddd265e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:16:40 -0700 Subject: [PATCH 25/57] docs(prd568): record paired APK bytes and track remaining acceptance --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 1596386fa7..3e186d6753 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — V8 mobile ASTC cooking, APK size comparison and native/visual acceptance proof remain open. +**Priority:** P2 — Fresh full cook qualification and native/visual acceptance proof remain open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, matched QuickJS PNG/ASTC byte comparison:** both original VQ-01 runner arms were freshly built at `71e6ef72afba9ef4a6ab84daa57e545c3db81491`, with QuickJS/x86_64, unchanged fixture/scenario and only the PNG arm's standalone texture codec set to `none`. `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json` records both exits 0, eight of eight original assertions per arm, matching installed/cooked payload hashes, restored configuration and unchanged source guards; the complete pair took 64.222 s, not a launch benchmark. ASTC APK: 25,678,858 bytes, SHA256 `bbf140901b8cc3aada33bc9f45c6af3ed9735c24e5c38f888956435805ccbca5`; PNG APK: 25,674,761 bytes, SHA256 `065f652831d1e021a7aa272f2b7aab371b12f4e488efffe4df8d8cb6c424f14f`. The packaged checker is 1,696 bytes for ASTC (vk158, 32×32, six mips, NONE supercompression) versus the authored 187-byte 32×32 PNG. ASTC increases this fixture's APK by 4,097 bytes and encoded checker by 1,509 bytes; these are stored bytes, not VRAM. The unchanged inspector passes both authored colours in all three specimens at actual 960×640 in each arm. Captures remain UNJUDGED; AC-1/AC-2 remain OPEN. The comparison criterion requires recording, not reduction. Phase 2 now explicitly tracks the existing acceptance proofs, so progress includes those outstanding requirements; the cook box remains OPEN pending fresh full-suite completion. + - **2026-10-11, all mobile runtimes cook ASTC:** the private mobile selection now depends only on the Android/iOS target, including decoder-capable V8. Six new full-capability standalone, embedded and generated-lightmap cases first failed on the old Basis output (exit 1, 16.163 s); the complete affected groups then passed 26 cases (46 skipped, exit 0, 56.551 s), retaining explicit-none/custom-decoder controls, web/desktop Basis, actual ASTC decoding, worker/serial bytes, warm cache and target switches. Assets typecheck and diff checks pass; focused Biome passes with 15 warnings. Raw logs and terminal receipts are `/tmp/prd568-lightmap-baseline/all-mobile-target-{red,green}.log` and the matching `*-terminal.json`; typecheck/Biome logs share that prefix. The coordinator reviewed the two-file diff and a fresh read-only requirement audit returned PASS. Existing frozen web/desktop fixtures and byte-comparison receipts remain preserved. Fresh full assets qualification is pending, so the cook box remains OPEN at `prd:50%`; AC-1/AC-2 and semantic/visual acceptance remain OPEN. - **2026-10-11, V8 baseline blocked:** the unchanged original V8 runner stopped before APK creation/install at Gradle `:app:verifyV8Dependency`, which runs `node packages/runtime-native/scripts/build-android-v8.mjs --verify`; the lane lacks `packages/runtime-native/third_party/v8-android/build-receipt.json` (exit 1, 8.973 s; `artifacts/prd568-v8-basis-before-20261011T065815Z/`). Its retained 529-byte cooked checker is UASTC Basis (vk0, DFD166, ZSTD, 32×32, six mips), proving cooking only; there is no V8 packaged/runtime, transcoder-call or launch-timing proof. The primary checkout's old V8 cache has libraries/snapshots but lacks the required provenance receipt, STL payloads and licenses; no receipt is fabricated and the verifier is not bypassed. Recovery uses the existing `build-android-v8.mjs` producer, which builds both ABIs; no measured ETA is available. Provisioning is deferred for this bounded run because the offload laptop's last status was 100°C with no room, rather than permanently declined. The cook box and AC-1/AC-2 remain OPEN at `prd:50%`. @@ -154,7 +156,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: `artifacts/prd568-quickjs-20261011T0323Z/{terminal.txt,run.log,app-logcat.txt}` proves ASTC packaging/install, but the pre-fix runner exited 2 at `TN_ASSETS_KTX2_UNPROBED`; the latest `artifacts/prd568-quickjs-local-20261011T055813Z/` run passes checker colour/package inspection and seven startup/resource assertions, with zero foreign-PID entries, but exits 1 on the own-PID `E/platform` rendernode error; clean native diagnostics and visual acceptance remain OPEN. +- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: the latest matched-source QuickJS/x86_64 ASTC arm in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/` exits 0 with all eight original assertions, packaged raw ASTC and both authored checker colours passing. The earlier own-PID driver line remains raw evidence under the restored original runtime-console contract, with no physical-driver-fix claim. Captures are UNJUDGED; visual acceptance remains OPEN. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. ## Blocked on @@ -178,7 +180,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` Partial: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Existing standalone, embedded/shared and generated-lightmap cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior, and the all-mobile target correction now passes six new full-capability RED/GREEN cases within 26 focused passes, including Android V8-equivalent decoder capabilities. Fresh full-suite qualification after that correction is pending. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. - Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest checker/resource observations and unresolved own-PID rendernode diagnostic are recorded above, and AC-1 remains open. No PNG-build APK-size comparison is available. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest paired native assertions and PNG/ASTC size comparison are recorded above. AC-1 remains open for visual acceptance. #### Phase 2: Load and check on the host **Status:** PARTIAL @@ -186,6 +188,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo **Verification:** AC-1 and AC-2 close this phase. - [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` Verified unit-scoped proof: public `assets.texture()` tests parse actual raw vk157/vk158 fixtures with the installed `KTX2Loader` across WebGL/WebGPU capability maps. BC-only and ETC2-only support rejects with `TN_ASSETS_ASTC_UNSUPPORTED` naming the asset URL and disposes the texture once; ASTC support retains the mip payloads. Historical RED: eight failed/five passed/69 skipped, exit 1, 1.82 s (`/tmp/prd568-core-astc-guard-red.log`); GREEN: 13 passed/69 skipped, exit 0, 855 ms (`/tmp/prd568-core-astc-guard-green.log`), with original exit attribution retained in `/tmp/prd568-core-astc-guard-8vsdw86a/actual.patch`. Current focused `-t 'ASTC|astc' --maxWorkers=1` proof passed 28 tests (54 deselected), exit 0, 7.82 s (`/tmp/prd568-core-astc-requirement-audit.log`). This completes the specified unit-test proof; it claims no unsupported-adapter native process run or visual result. AC-1 and AC-2 remain open. -- [ ] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing +- [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. +- [ ] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: the existing AC-1 runner and visual-judge contract; paired functional assertions/colour inspection pass above, captures remain UNJUDGED. +- [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and runtime/timing proof remain pending. **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. From 9464b5f83cfbf23d3018e4fe9fc89b7063fba48c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:18:26 -0700 Subject: [PATCH 26/57] docs(prd568): record all-mobile cook qualification --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 3e186d6753..3c63c84593 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Fresh full cook qualification and native/visual acceptance proof remain open. +**Priority:** P2 — Mobile ASTC cooking and packaging are verified; native/visual acceptance proof remains open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, fresh all-mobile cook qualification:** the complete assets suite on source commit `71e6ef72afba9ef4a6ab84daa57e545c3db81491` passes 627 tests (two skipped), with 39 files passing and one skipped, exit 0, 594.681 s supervisor elapsed / 593.87 s Vitest duration. Exact command: `pnpm exec vitest run packages/assets/__tests__ --maxWorkers=1 --bail=1`, Node 20.19.6, unchanged per-test limits and a 1,800 s supervisor. Local execution followed the laptop's recorded 100°C/no-capacity result. `/tmp/prd568-lightmap-baseline/full-assets-all-mobile-workers1.log` and `full-assets-all-mobile-workers1-terminal.json` retain the complete terminal proof. Together with the six new full-capability RED cases and reviewed target-only selector, this closes the cook box and Phase 1. Existing frozen web/desktop byte comparisons remain preserved; the selector leaves those targets unchanged. AC-1/AC-2, the fresh visual verdict and semantic quality limits remain OPEN; no acceptance or physical-device claim follows. + - **2026-10-11, matched QuickJS PNG/ASTC byte comparison:** both original VQ-01 runner arms were freshly built at `71e6ef72afba9ef4a6ab84daa57e545c3db81491`, with QuickJS/x86_64, unchanged fixture/scenario and only the PNG arm's standalone texture codec set to `none`. `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json` records both exits 0, eight of eight original assertions per arm, matching installed/cooked payload hashes, restored configuration and unchanged source guards; the complete pair took 64.222 s, not a launch benchmark. ASTC APK: 25,678,858 bytes, SHA256 `bbf140901b8cc3aada33bc9f45c6af3ed9735c24e5c38f888956435805ccbca5`; PNG APK: 25,674,761 bytes, SHA256 `065f652831d1e021a7aa272f2b7aab371b12f4e488efffe4df8d8cb6c424f14f`. The packaged checker is 1,696 bytes for ASTC (vk158, 32×32, six mips, NONE supercompression) versus the authored 187-byte 32×32 PNG. ASTC increases this fixture's APK by 4,097 bytes and encoded checker by 1,509 bytes; these are stored bytes, not VRAM. The unchanged inspector passes both authored colours in all three specimens at actual 960×640 in each arm. Captures remain UNJUDGED; AC-1/AC-2 remain OPEN. The comparison criterion requires recording, not reduction. Phase 2 now explicitly tracks the existing acceptance proofs, so progress includes those outstanding requirements; the cook box remains OPEN pending fresh full-suite completion. - **2026-10-11, all mobile runtimes cook ASTC:** the private mobile selection now depends only on the Android/iOS target, including decoder-capable V8. Six new full-capability standalone, embedded and generated-lightmap cases first failed on the old Basis output (exit 1, 16.163 s); the complete affected groups then passed 26 cases (46 skipped, exit 0, 56.551 s), retaining explicit-none/custom-decoder controls, web/desktop Basis, actual ASTC decoding, worker/serial bytes, warm cache and target switches. Assets typecheck and diff checks pass; focused Biome passes with 15 warnings. Raw logs and terminal receipts are `/tmp/prd568-lightmap-baseline/all-mobile-target-{red,green}.log` and the matching `*-terminal.json`; typecheck/Biome logs share that prefix. The coordinator reviewed the two-file diff and a fresh read-only requirement audit returned PASS. Existing frozen web/desktop fixtures and byte-comparison receipts remain preserved. Fresh full assets qualification is pending, so the cook box remains OPEN at `prd:50%`; AC-1/AC-2 and semantic/visual acceptance remain OPEN. @@ -175,10 +177,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Execution Phases #### Phase 1: Cook and admit raw ASTC -**Status:** PARTIAL +**Status:** DONE **Files:** `packages/assets/src/image-quality.ts`, `packages/assets/src/compile.ts`, `packages/assets/src/passes/texture.ts`, `packages/assets/src/passes/model-textures.ts`, `packages/assets/src/passes/lightmap.ts`, `packages/assets/__tests__/`, `packages/create-threenative/src/build.ts`, `packages/create-threenative/__tests__/native-ktx2.spec.ts` -- [ ] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` - Partial: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; the full assets suite passed 623 tests (two skipped), exit 0, 809.100 s elapsed / 807.17 s Vitest duration, with unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-workers1.log`, `full-assets-workers1-terminal.json`). Existing standalone, embedded/shared and generated-lightmap cases cover raw ASTC format, full mips, NONE supercompression, decoder maps, workers and cache behavior, and the all-mobile target correction now passes six new full-capability RED/GREEN cases within 26 focused passes, including Android V8-equivalent decoder capabilities. Fresh full-suite qualification after that correction is pending. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. +- [x] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` + Verified: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; six new full-capability standalone/embedded/lightmap cases reproduce the old Basis selection and pass after the all-mobile target correction. Fresh full assets qualification passes 627 tests (two skipped), 39 files passed/one skipped, exit 0, 594.681 s elapsed / 593.87 s Vitest duration, on Node 20.19.6 with `--maxWorkers=1 --bail=1` and unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-all-mobile-workers1.log`, `full-assets-all-mobile-workers1-terminal.json`). Existing cases verify raw ASTC formats, full mips, NONE supercompression, decoder maps including V8-equivalent capabilities, workers, cache behavior and explicit-none controls. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest paired native assertions and PNG/ASTC size comparison are recorded above. AC-1 remains open for visual acceptance. From 5bd3b213d0584dcea90b47e9929bacf647e82ccd Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:25:32 -0700 Subject: [PATCH 27/57] docs(prd568): record repeated captures and V8 provisioning decision --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 3c63c84593..84d57a9279 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, second native capture per arm:** the exact retained PNG and ASTC APKs/build reports from the matched pair were reused without rebuilding or changing configuration at HEAD `9464b5f83cfbf23d3018e4fe9fc89b7063fba48c`, whose product source matches the paired candidate. Each arm now has two original-runner passes (eight of eight assertions each), matching installed APK hashes, the same authored pose and unchanged 960×640 checker inspection passing both colours in all three specimens. Raw captures and second-run receipts are `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/{png,astc}/{after.png,second-capture/after.png}` and each `second-capture/{capture-result.json,run.log,terminal.txt}`; `second-capture-source-guard.json` and `astc-second-capture-source-guard.json` retain unchanged HEAD/config and all 9,213 input hashes. The first supervisor exited 1 after the successful PNG run because its added post-run PID check conflicted with intentional teardown: `packages/playtest/src/runner/androidRunner.ts:650` calls `driver.stop()`, whose `android.ts:642` force-stops the app. That failure remains retained; only the added supervisor requirement was removed for the remaining ASTC run (runner and supervisor exits 0), without rerunning PNG or changing the original gates. Burned BEFORE/AFTER labels and the formal visual verdict remain outstanding; captures are UNJUDGED and AC-1/AC-2 stay OPEN. + +- **2026-10-11, V8 provisioning decision supersedes the earlier deferral:** a local read-only resource audit found sufficient room and the exact NDK `28.2.13676358`; the laptop's fresh status still refuses work at 100°C. Local execution of the existing isolated V8 dependency producer is now authorized with two workers and `nice -n 10`, rather than remaining deferred for this bounded run. Duration is unknown, and this checkpoint claims neither a live producer nor completion without its actual receipt; V8 packaged/runtime, zero-transcoder-call and interleaved-timing proof remain OPEN. + - **2026-10-11, fresh all-mobile cook qualification:** the complete assets suite on source commit `71e6ef72afba9ef4a6ab84daa57e545c3db81491` passes 627 tests (two skipped), with 39 files passing and one skipped, exit 0, 594.681 s supervisor elapsed / 593.87 s Vitest duration. Exact command: `pnpm exec vitest run packages/assets/__tests__ --maxWorkers=1 --bail=1`, Node 20.19.6, unchanged per-test limits and a 1,800 s supervisor. Local execution followed the laptop's recorded 100°C/no-capacity result. `/tmp/prd568-lightmap-baseline/full-assets-all-mobile-workers1.log` and `full-assets-all-mobile-workers1-terminal.json` retain the complete terminal proof. Together with the six new full-capability RED cases and reviewed target-only selector, this closes the cook box and Phase 1. Existing frozen web/desktop byte comparisons remain preserved; the selector leaves those targets unchanged. AC-1/AC-2, the fresh visual verdict and semantic quality limits remain OPEN; no acceptance or physical-device claim follows. - **2026-10-11, matched QuickJS PNG/ASTC byte comparison:** both original VQ-01 runner arms were freshly built at `71e6ef72afba9ef4a6ab84daa57e545c3db81491`, with QuickJS/x86_64, unchanged fixture/scenario and only the PNG arm's standalone texture codec set to `none`. `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json` records both exits 0, eight of eight original assertions per arm, matching installed/cooked payload hashes, restored configuration and unchanged source guards; the complete pair took 64.222 s, not a launch benchmark. ASTC APK: 25,678,858 bytes, SHA256 `bbf140901b8cc3aada33bc9f45c6af3ed9735c24e5c38f888956435805ccbca5`; PNG APK: 25,674,761 bytes, SHA256 `065f652831d1e021a7aa272f2b7aab371b12f4e488efffe4df8d8cb6c424f14f`. The packaged checker is 1,696 bytes for ASTC (vk158, 32×32, six mips, NONE supercompression) versus the authored 187-byte 32×32 PNG. ASTC increases this fixture's APK by 4,097 bytes and encoded checker by 1,509 bytes; these are stored bytes, not VRAM. The unchanged inspector passes both authored colours in all three specimens at actual 960×640 in each arm. Captures remain UNJUDGED; AC-1/AC-2 remain OPEN. The comparison criterion requires recording, not reduction. Phase 2 now explicitly tracks the existing acceptance proofs, so progress includes those outstanding requirements; the cook box remains OPEN pending fresh full-suite completion. From d706da7191caa940af8ffdc38453505f73b35cda Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:42:27 -0700 Subject: [PATCH 28/57] docs(prd568): record real-call probe and V8 producer start --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 84d57a9279..6b61b45bbb 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, AC-2 observation prototype only:** on Node 20.19.6/V8, the retained 529-byte Basis checker decodes all six mips with the installed Three Basis JS/WASM. The unwrapped control decodes six but observes zero calls, failing the required-six assertion (RED, exit 1); the factory hook observes exactly six real `KTX2File.prototype.transcodeImage` calls (GREEN, exit 0), preserving all decoded mip lengths/hashes, numeric success returns and the original initialization callback once. The separately asserted worker-lexical harness runs with `module`/`exports` undefined and owns its counter: six calls, one installed hook, one callback, and all six decoded mip bytes/hashes/returns equal the retained CJS GREEN. It preserves `this`, arguments and return via `Reflect.apply`; the earlier boolean-versus-numeric ABI assertion failure is retained separately and is not the meaningful RED. Receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/{red.log,green.log,lexical-asserted-green.log}` and their terminal files. This supports the appended lexical factory-hook approach in Node only. ASTC skips worker initialization, so absent worker logs alone cannot prove zero calls: actual Android always-armed worker-construction coverage, a real-call counter with positive Basis control, and three interleaved timing comparisons remain OPEN. No native AC-2 proof or acceptance tick follows. + +- **2026-10-11, V8 producer start confirmed:** the producer owner's actual start receipt records live session `55580`, PID `2433811`, two workers and `nice -n 10`, superseding the preceding authorization-only checkpoint. No completion receipt or measured duration is claimed; Android V8 packaging/runtime and AC-2 remain OPEN. + - **2026-10-11, second native capture per arm:** the exact retained PNG and ASTC APKs/build reports from the matched pair were reused without rebuilding or changing configuration at HEAD `9464b5f83cfbf23d3018e4fe9fc89b7063fba48c`, whose product source matches the paired candidate. Each arm now has two original-runner passes (eight of eight assertions each), matching installed APK hashes, the same authored pose and unchanged 960×640 checker inspection passing both colours in all three specimens. Raw captures and second-run receipts are `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/{png,astc}/{after.png,second-capture/after.png}` and each `second-capture/{capture-result.json,run.log,terminal.txt}`; `second-capture-source-guard.json` and `astc-second-capture-source-guard.json` retain unchanged HEAD/config and all 9,213 input hashes. The first supervisor exited 1 after the successful PNG run because its added post-run PID check conflicted with intentional teardown: `packages/playtest/src/runner/androidRunner.ts:650` calls `driver.stop()`, whose `android.ts:642` force-stops the app. That failure remains retained; only the added supervisor requirement was removed for the remaining ASTC run (runner and supervisor exits 0), without rerunning PNG or changing the original gates. Burned BEFORE/AFTER labels and the formal visual verdict remain outstanding; captures are UNJUDGED and AC-1/AC-2 stay OPEN. - **2026-10-11, V8 provisioning decision supersedes the earlier deferral:** a local read-only resource audit found sufficient room and the exact NDK `28.2.13676358`; the laptop's fresh status still refuses work at 100°C. Local execution of the existing isolated V8 dependency producer is now authorized with two workers and `nice -n 10`, rather than remaining deferred for this bounded run. Duration is unknown, and this checkpoint claims neither a live producer nor completion without its actual receipt; V8 packaged/runtime, zero-transcoder-call and interleaved-timing proof remain OPEN. From 437d92459b3f16906d236386ae667134559c91c5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 00:58:08 -0700 Subject: [PATCH 29/57] docs(prd568): record browser observer and prepared native overlay --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 6b61b45bbb..0879e1233c 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, browser CPU observer proof and unapplied native overlay:** installed Chromium `151.0.7922.34` runs the actual installed Three loader, Blob worker and Basis WASM. The positive Basis arm records one worker, one installed hook and six real `transcodeImage` calls (mips 0–5); raw vk158 ASTC parses all six mips with the always-armed constructor observer still installed, zero workers and zero calls, without forcing decoder initialization. Executable assertions pass, exit 0; receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/{browser-probe.log,browser-probe-terminal.txt,browser-basis.log,browser-astc.log}`. This is CPU browser evidence only, with no GPU, Android/native, visual or timing claim. The reviewed temporary overlay is prepared there as `native-game-overlay.diff`/`native-game.instrumented.ts`, NOT applied: it arms before fixture asset loads and completes after the original `game.ts:116` loaded-assets/state-snapshot anchor, checking Worker/Blob/URL wrappers remain installed. Exact original fixture bytes are retained with SHA256 `a79bf1f265428abbb01b6085b706a429523d5dd43d85d16b0032733eacc8ace2`; `native-overlay-inputs.json` and the guarded apply/restore recipe preserve them and refuse unexpected fixture edits. Android call-counter proof and separate uninstrumented three-interleaved-launch timing comparisons remain OPEN. AC-2 specifies no explicit statistic or tolerance, so an easier median criterion is not assumed; AC-1/AC-2 remain unticked. + - **2026-10-11, AC-2 observation prototype only:** on Node 20.19.6/V8, the retained 529-byte Basis checker decodes all six mips with the installed Three Basis JS/WASM. The unwrapped control decodes six but observes zero calls, failing the required-six assertion (RED, exit 1); the factory hook observes exactly six real `KTX2File.prototype.transcodeImage` calls (GREEN, exit 0), preserving all decoded mip lengths/hashes, numeric success returns and the original initialization callback once. The separately asserted worker-lexical harness runs with `module`/`exports` undefined and owns its counter: six calls, one installed hook, one callback, and all six decoded mip bytes/hashes/returns equal the retained CJS GREEN. It preserves `this`, arguments and return via `Reflect.apply`; the earlier boolean-versus-numeric ABI assertion failure is retained separately and is not the meaningful RED. Receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/{red.log,green.log,lexical-asserted-green.log}` and their terminal files. This supports the appended lexical factory-hook approach in Node only. ASTC skips worker initialization, so absent worker logs alone cannot prove zero calls: actual Android always-armed worker-construction coverage, a real-call counter with positive Basis control, and three interleaved timing comparisons remain OPEN. No native AC-2 proof or acceptance tick follows. - **2026-10-11, V8 producer start confirmed:** the producer owner's actual start receipt records live session `55580`, PID `2433811`, two workers and `nice -n 10`, superseding the preceding authorization-only checkpoint. No completion receipt or measured duration is claimed; Android V8 packaging/runtime and AC-2 remain OPEN. From aed83ee6d7c2defbb257681c52f83fafe4e78635 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 03:54:17 -0700 Subject: [PATCH 30/57] docs(prd568): record native V8 counters and failed timing pair --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 0879e1233c..689ab62b71 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,12 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, native V8 counters verified; first timing pair fails:** the original `build-android-v8.mjs --verify` now exits 0 for both ABIs, V8 `11.0.226.16`, recipe 6 and 16 KB LOAD alignment. The 127-file installed `packages/runtime-native/third_party/v8-android/build-receipt.json` has SHA256 `df682bf917fe1c058e37f96729b8242865fc5cb9c7f40e30c8998af46da2aa14`; original verification receipts are in the native counter executions below. This supersedes the earlier missing-dependency/provisioning checkpoints. + + Native diagnostic arms use the same reviewed temporary overlay: Basis records one covered worker, one installed hook and six real `transcodeImage` calls, mips 0–5; ASTC records always-armed coverage with zero workers/hooks/calls and one completion. Both original runner passes have eight of eight assertions, actual V8/NVIDIA Vulkan/ASTC support, matching installed APK hashes and unchanged 960×640 inspection passing both authored colours in all three specimens. Under `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/`, evidence is `execution-20261011T102953Z/basis/` and `execution-20261011T103630Z/astc/` (`counter-result.json`, console, package, colour and terminal receipts). Diagnostic APK SHA256s are Basis `e090085668fd137550842af71522a29e3de3f49700cf98a76f56dd4e9748d888` and ASTC `7cd49f33006d157aae6694b07d269b920cecda96a391af507441eeff341a1e9a`. Main-thread JS/Stdio mirrors are validated before canonical Stdio counting; actual worker call JSON is parsed unchanged. The original transport-count failure remains retained. + + Separately built **uninstrumented** timing APKs in `execution-20261011T104406Z/{basis,astc}/` lack both measurement prefixes in packaged JS and console, and pass the original eight assertions, packaging, installed-hash and colour checks. Their SHA256s are Basis `e659524ba2fdd9d526422fe0ee2a0b8f422370f778da16470a802f318d2a963c` and ASTC `3f8f72d0b33ab7f3925d2a61120c6f2a325c27e847a565a12a35855fd3d1e21b`. Priming build launches are excluded. Verified B1 is **946.213 ms** (PID 21275); A1 is **1036.165 ms** (PID 21683), **+89.952 ms**, failing the strict individual `ASTC <= Basis` predicate. B1 inspection needed a temporary parser correction for direct CLI JSON at byte zero; the failed supervisor/raw capture remain preserved, and the recovery pause before A1 is disclosed without an adjacency or cache-state claim. `execution-20261011T105039Z/timing-comparison.json` and `continue-timing-20261011T105038Z.{log,terminal.txt}` retain pair 1 FAIL / supervisor exit 1. Only A1 ran during continuation; B2/A2/B3/A3 were not launched, with no retry, median, tolerance or favourable selection. No causal attribution follows this comparison. Exact compiler/fixture/assets-dist restoration and all 9,268 input, 82 timing-evidence and 18 diagnostic-evidence hashes pass; the intentional PRD commit subsequently makes the old HEAD guard stale. AC-1 remains OPEN for the formal visual judge; AC-2 remains OPEN because all three timing pairs have not passed. Progress remains four of six execution boxes, 0/2 acceptance, `prd:50%`. + - **2026-10-11, browser CPU observer proof and unapplied native overlay:** installed Chromium `151.0.7922.34` runs the actual installed Three loader, Blob worker and Basis WASM. The positive Basis arm records one worker, one installed hook and six real `transcodeImage` calls (mips 0–5); raw vk158 ASTC parses all six mips with the always-armed constructor observer still installed, zero workers and zero calls, without forcing decoder initialization. Executable assertions pass, exit 0; receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/{browser-probe.log,browser-probe-terminal.txt,browser-basis.log,browser-astc.log}`. This is CPU browser evidence only, with no GPU, Android/native, visual or timing claim. The reviewed temporary overlay is prepared there as `native-game-overlay.diff`/`native-game.instrumented.ts`, NOT applied: it arms before fixture asset loads and completes after the original `game.ts:116` loaded-assets/state-snapshot anchor, checking Worker/Blob/URL wrappers remain installed. Exact original fixture bytes are retained with SHA256 `a79bf1f265428abbb01b6085b706a429523d5dd43d85d16b0032733eacc8ace2`; `native-overlay-inputs.json` and the guarded apply/restore recipe preserve them and refuse unexpected fixture edits. Android call-counter proof and separate uninstrumented three-interleaved-launch timing comparisons remain OPEN. AC-2 specifies no explicit statistic or tolerance, so an easier median criterion is not assumed; AC-1/AC-2 remain unticked. - **2026-10-11, AC-2 observation prototype only:** on Node 20.19.6/V8, the retained 529-byte Basis checker decodes all six mips with the installed Three Basis JS/WASM. The unwrapped control decodes six but observes zero calls, failing the required-six assertion (RED, exit 1); the factory hook observes exactly six real `KTX2File.prototype.transcodeImage` calls (GREEN, exit 0), preserving all decoded mip lengths/hashes, numeric success returns and the original initialization callback once. The separately asserted worker-lexical harness runs with `module`/`exports` undefined and owns its counter: six calls, one installed hook, one callback, and all six decoded mip bytes/hashes/returns equal the retained CJS GREEN. It preserves `this`, arguments and return via `Reflect.apply`; the earlier boolean-versus-numeric ABI assertion failure is retained separately and is not the meaningful RED. Receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/{red.log,green.log,lexical-asserted-green.log}` and their terminal files. This supports the appended lexical factory-hook approach in Node only. ASTC skips worker initialization, so absent worker logs alone cannot prove zero calls: actual Android always-armed worker-construction coverage, a real-call counter with positive Basis control, and three interleaved timing comparisons remain OPEN. No native AC-2 proof or acceptance tick follows. @@ -169,7 +175,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: the latest matched-source QuickJS/x86_64 ASTC arm in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/` exits 0 with all eight original assertions, packaged raw ASTC and both authored checker colours passing. The earlier own-PID driver line remains raw evidence under the restored original runtime-console contract, with no physical-driver-fix claim. Captures are UNJUDGED; visual acceptance remains OPEN. -- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: pending. +- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. See the latest dated decision; AC-2 stays OPEN. ## Blocked on @@ -202,6 +208,6 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo Verified unit-scoped proof: public `assets.texture()` tests parse actual raw vk157/vk158 fixtures with the installed `KTX2Loader` across WebGL/WebGPU capability maps. BC-only and ETC2-only support rejects with `TN_ASSETS_ASTC_UNSUPPORTED` naming the asset URL and disposes the texture once; ASTC support retains the mip payloads. Historical RED: eight failed/five passed/69 skipped, exit 1, 1.82 s (`/tmp/prd568-core-astc-guard-red.log`); GREEN: 13 passed/69 skipped, exit 0, 855 ms (`/tmp/prd568-core-astc-guard-green.log`), with original exit attribution retained in `/tmp/prd568-core-astc-guard-8vsdw86a/actual.patch`. Current focused `-t 'ASTC|astc' --maxWorkers=1` proof passed 28 tests (54 deselected), exit 0, 7.82 s (`/tmp/prd568-core-astc-requirement-audit.log`). This completes the specified unit-test proof; it claims no unsupported-adapter native process run or visual result. AC-1 and AC-2 remain open. - [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. - [ ] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: the existing AC-1 runner and visual-judge contract; paired functional assertions/colour inspection pass above, captures remain UNJUDGED. -- [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and runtime/timing proof remain pending. +- [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and native zero-call coverage with positive Basis control pass; the first uninstrumented timing pair fails and the remaining two pairs are unrun. See the latest dated decision; this box remains OPEN. **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. From 5a743271cd68548a0162b9a2dd35f59cc3409734 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 04:02:51 -0700 Subject: [PATCH 31/57] docs(prd568): retain empty timing diagnosis receipt --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 689ab62b71..dea5207fba 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, requested timing diagnosis returned no result:** the bounded read-only Claude Opus invocation used `--model claude-opus-5-5 --effort high` and ended at `11:00:46Z`, exit 124 after 300.190 s, with empty stdout/stderr. Receipt: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-diagnosis-20261011T105546Z/terminal.json`. No diagnosis, fallback or retry followed; the timing cause remains UNDIAGNOSED. The recorded first-pair FAIL, OPEN AC-1/AC-2 and `prd:50%` remain unchanged, with no causal claim. + - **2026-10-11, native V8 counters verified; first timing pair fails:** the original `build-android-v8.mjs --verify` now exits 0 for both ABIs, V8 `11.0.226.16`, recipe 6 and 16 KB LOAD alignment. The 127-file installed `packages/runtime-native/third_party/v8-android/build-receipt.json` has SHA256 `df682bf917fe1c058e37f96729b8242865fc5cb9c7f40e30c8998af46da2aa14`; original verification receipts are in the native counter executions below. This supersedes the earlier missing-dependency/provisioning checkpoints. Native diagnostic arms use the same reviewed temporary overlay: Basis records one covered worker, one installed hook and six real `transcodeImage` calls, mips 0–5; ASTC records always-armed coverage with zero workers/hooks/calls and one completion. Both original runner passes have eight of eight assertions, actual V8/NVIDIA Vulkan/ASTC support, matching installed APK hashes and unchanged 960×640 inspection passing both authored colours in all three specimens. Under `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/`, evidence is `execution-20261011T102953Z/basis/` and `execution-20261011T103630Z/astc/` (`counter-result.json`, console, package, colour and terminal receipts). Diagnostic APK SHA256s are Basis `e090085668fd137550842af71522a29e3de3f49700cf98a76f56dd4e9748d888` and ASTC `7cd49f33006d157aae6694b07d269b920cecda96a391af507441eeff341a1e9a`. Main-thread JS/Stdio mirrors are validated before canonical Stdio counting; actual worker call JSON is parsed unchanged. The original transport-count failure remains retained. From e3911864b377d5f69a51deb1d6474bbf3cb36035 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 04:18:40 -0700 Subject: [PATCH 32/57] docs(prd568): record completed Opus timing diagnosis --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index dea5207fba..baa403d272 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, completed Opus 5.5 HIGH timing diagnosis:** the corrected read-only inline-evidence invocation completed, exit 0 after 170.856 s. Its final answer and terminal receipt are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-adequate-budget-20261011T111205Z/{completed-final-answer.txt,terminal.json}`. In the immutable first pair, ASTC reaches `frameBeganAtMs` 96.824 ms later, while the subsequent gap shrinks 6.871 ms (the 0.001 ms difference from the headline +89.952 ms is rounding). Measured gap-segment deltas are texture upload +0.523 ms, buffer upload -6.879 ms, pipeline compile +0.406 ms and shader compile -0.047 ms; attributed time and residual both shrink. The observed regression therefore sits before `frameBeganAtMs`, where these objects provide no breakdown. Confidence in that localization is high; no causal mechanism, concrete engine bug or minimal fix is established. Driver, JS, asset, cache and variance explanations remain unproven. The missing evidence is a same-clock breakdown of that earlier interval for both arms, separate from the counter-marked runs. This diagnosis does not re-judge pair 1 FAIL; AC-1/AC-2 remain OPEN and progress remains `prd:50%`. + - **2026-10-11, requested timing diagnosis returned no result:** the bounded read-only Claude Opus invocation used `--model claude-opus-5-5 --effort high` and ended at `11:00:46Z`, exit 124 after 300.190 s, with empty stdout/stderr. Receipt: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-diagnosis-20261011T105546Z/terminal.json`. No diagnosis, fallback or retry followed; the timing cause remains UNDIAGNOSED. The recorded first-pair FAIL, OPEN AC-1/AC-2 and `prd:50%` remain unchanged, with no causal claim. - **2026-10-11, native V8 counters verified; first timing pair fails:** the original `build-android-v8.mjs --verify` now exits 0 for both ABIs, V8 `11.0.226.16`, recipe 6 and 16 KB LOAD alignment. The 127-file installed `packages/runtime-native/third_party/v8-android/build-receipt.json` has SHA256 `df682bf917fe1c058e37f96729b8242865fc5cb9c7f40e30c8998af46da2aa14`; original verification receipts are in the native counter executions below. This supersedes the earlier missing-dependency/provisioning checkpoints. @@ -177,7 +179,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: the latest matched-source QuickJS/x86_64 ASTC arm in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/` exits 0 with all eight original assertions, packaged raw ASTC and both authored checker colours passing. The earlier own-PID driver line remains raw evidence under the restored original runtime-console contract, with no physical-driver-fix claim. Captures are UNJUDGED; visual acceptance remains OPEN. -- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. See the latest dated decision; AC-2 stays OPEN. +- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. Completed Opus diagnosis localizes the difference before `frameBeganAtMs` without establishing its cause; a same-clock breakdown there remains missing. See the latest dated decision; AC-2 stays OPEN. ## Blocked on From 4fe4ef2e7a604daaf29278fdd4cb287e30a7f03a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 04:22:13 -0700 Subject: [PATCH 33/57] docs(prd568): locate startup timing delta before game evaluation --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index baa403d272..91fde27e38 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -43,6 +43,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-11, completed Opus 5.5 HIGH timing diagnosis:** the corrected read-only inline-evidence invocation completed, exit 0 after 170.856 s. Its final answer and terminal receipt are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-adequate-budget-20261011T111205Z/{completed-final-answer.txt,terminal.json}`. In the immutable first pair, ASTC reaches `frameBeganAtMs` 96.824 ms later, while the subsequent gap shrinks 6.871 ms (the 0.001 ms difference from the headline +89.952 ms is rounding). Measured gap-segment deltas are texture upload +0.523 ms, buffer upload -6.879 ms, pipeline compile +0.406 ms and shader compile -0.047 ms; attributed time and residual both shrink. The observed regression therefore sits before `frameBeganAtMs`, where these objects provide no breakdown. Confidence in that localization is high; no causal mechanism, concrete engine bug or minimal fix is established. Driver, JS, asset, cache and variance explanations remain unproven. The missing evidence is a same-clock breakdown of that earlier interval for both arms, separate from the counter-marked runs. This diagnosis does not re-judge pair 1 FAIL; AC-1/AC-2 remain OPEN and progress remains `prd:50%`. + The retained B1/A1 uninstrumented `TN_COLD_START` markers further localize the earlier interval: script read is 6.310→43.652 ms (+37.342 ms), and `asset_complete`→first bootstrap compile is 684.765→743.582 ms (+58.817 ms). Source mapping places the `readAsset` brackets at `android_main.cpp:172–174`, `Runtime::create` at line 241 before `runtime_created` at line 248 and game evaluation at line 304. In `runtime.cpp`, line 4238 initializes the runtime; GPU/window/surface initialization precedes JS bindings at line 700, and the fetch polyfill is the first embedded evaluation at line 2533. These runtime intervals precede game JS texture decoding; asset cooking already occurred at build time. Neither interval attributes cause. Finer attribution is missing inside `readAsset` and native runtime initialization; no speculative product fix or rerun follows, and AC-1/AC-2 remain OPEN at `prd:50%`. + - **2026-10-11, requested timing diagnosis returned no result:** the bounded read-only Claude Opus invocation used `--model claude-opus-5-5 --effort high` and ended at `11:00:46Z`, exit 124 after 300.190 s, with empty stdout/stderr. Receipt: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-diagnosis-20261011T105546Z/terminal.json`. No diagnosis, fallback or retry followed; the timing cause remains UNDIAGNOSED. The recorded first-pair FAIL, OPEN AC-1/AC-2 and `prd:50%` remain unchanged, with no causal claim. - **2026-10-11, native V8 counters verified; first timing pair fails:** the original `build-android-v8.mjs --verify` now exits 0 for both ABIs, V8 `11.0.226.16`, recipe 6 and 16 KB LOAD alignment. The 127-file installed `packages/runtime-native/third_party/v8-android/build-receipt.json` has SHA256 `df682bf917fe1c058e37f96729b8242865fc5cb9c7f40e30c8998af46da2aa14`; original verification receipts are in the native counter executions below. This supersedes the earlier missing-dependency/provisioning checkpoints. From e4ad8dc6920f187ff252ffe11da19a1a29da6a31 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 04:34:56 -0700 Subject: [PATCH 34/57] docs(prd568): retain diagnostic timing direction changes --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 91fde27e38..535fc7a646 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, fixed BAAB repeatability diagnostic only:** four uninstrumented diagnostic launches in the fixed Basis/ASTC/ASTC/Basis order pass all eight original assertions per launch, colour and installed-APK hash checks; the supervisor exits 0 and the source guard passes. Evidence: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/diagnostic-repeatability-20261011T1120Z/execution-20261011T113146Z/diagnostic-result.json` and the adjacent terminal/guard receipts. First frames are 1090.794, 989.673, 998.560 and 977.203 ms. The two adjacent-pair ASTC-minus-Basis deltas are -101.121/+21.357 ms; corresponding pre-frame deltas are -49.686/-2.050 ms, gap -51.436/+23.407 ms, asset read -17.372/+0.452 ms and read-complete→first bootstrap compile -44.209/-33.010 ms. The direction changes and localization is inconsistent; cause remains unknown. These diagnostic pairs supply no causal, statistical or acceptance-pass claim and introduce no tolerance. The original 946.213→1036.165 ms acceptance pair remains FAIL, its remaining two acceptance pairs were not run, and AC-1/AC-2 remain OPEN at `prd:50%`. + - **2026-10-11, completed Opus 5.5 HIGH timing diagnosis:** the corrected read-only inline-evidence invocation completed, exit 0 after 170.856 s. Its final answer and terminal receipt are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-adequate-budget-20261011T111205Z/{completed-final-answer.txt,terminal.json}`. In the immutable first pair, ASTC reaches `frameBeganAtMs` 96.824 ms later, while the subsequent gap shrinks 6.871 ms (the 0.001 ms difference from the headline +89.952 ms is rounding). Measured gap-segment deltas are texture upload +0.523 ms, buffer upload -6.879 ms, pipeline compile +0.406 ms and shader compile -0.047 ms; attributed time and residual both shrink. The observed regression therefore sits before `frameBeganAtMs`, where these objects provide no breakdown. Confidence in that localization is high; no causal mechanism, concrete engine bug or minimal fix is established. Driver, JS, asset, cache and variance explanations remain unproven. The missing evidence is a same-clock breakdown of that earlier interval for both arms, separate from the counter-marked runs. This diagnosis does not re-judge pair 1 FAIL; AC-1/AC-2 remain OPEN and progress remains `prd:50%`. The retained B1/A1 uninstrumented `TN_COLD_START` markers further localize the earlier interval: script read is 6.310→43.652 ms (+37.342 ms), and `asset_complete`→first bootstrap compile is 684.765→743.582 ms (+58.817 ms). Source mapping places the `readAsset` brackets at `android_main.cpp:172–174`, `Runtime::create` at line 241 before `runtime_created` at line 248 and game evaluation at line 304. In `runtime.cpp`, line 4238 initializes the runtime; GPU/window/surface initialization precedes JS bindings at line 700, and the fetch polyfill is the first embedded evaluation at line 2533. These runtime intervals precede game JS texture decoding; asset cooking already occurred at build time. Neither interval attributes cause. Finer attribution is missing inside `readAsset` and native runtime initialization; no speculative product fix or rerun follows, and AC-1/AC-2 remain OPEN at `prd:50%`. @@ -181,7 +183,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria - [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: the latest matched-source QuickJS/x86_64 ASTC arm in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/` exits 0 with all eight original assertions, packaged raw ASTC and both authored checker colours passing. The earlier own-PID driver line remains raw evidence under the restored original runtime-console contract, with no physical-driver-fix claim. Captures are UNJUDGED; visual acceptance remains OPEN. -- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. Completed Opus diagnosis localizes the difference before `frameBeganAtMs` without establishing its cause; a same-clock breakdown there remains missing. See the latest dated decision; AC-2 stays OPEN. +- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. Completed Opus diagnosis localizes the difference before `frameBeganAtMs` without establishing its cause; a same-clock breakdown there remains missing. The separate fixed BAAB diagnostic changes direction and does not qualify acceptance. See the latest dated decisions; AC-2 stays OPEN. ## Blocked on From 70e88a1a59cd8eb13c964dbb78c666b88cb7c97a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 05:06:34 -0700 Subject: [PATCH 35/57] docs(prd568): record CPU diagnosis limits and visual blocker --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 535fc7a646..c162728450 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Root reviewed the restoration1/paranoid-1 restoration checks and guards. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. + - **2026-10-11, fixed BAAB repeatability diagnostic only:** four uninstrumented diagnostic launches in the fixed Basis/ASTC/ASTC/Basis order pass all eight original assertions per launch, colour and installed-APK hash checks; the supervisor exits 0 and the source guard passes. Evidence: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/diagnostic-repeatability-20261011T1120Z/execution-20261011T113146Z/diagnostic-result.json` and the adjacent terminal/guard receipts. First frames are 1090.794, 989.673, 998.560 and 977.203 ms. The two adjacent-pair ASTC-minus-Basis deltas are -101.121/+21.357 ms; corresponding pre-frame deltas are -49.686/-2.050 ms, gap -51.436/+23.407 ms, asset read -17.372/+0.452 ms and read-complete→first bootstrap compile -44.209/-33.010 ms. The direction changes and localization is inconsistent; cause remains unknown. These diagnostic pairs supply no causal, statistical or acceptance-pass claim and introduce no tolerance. The original 946.213→1036.165 ms acceptance pair remains FAIL, its remaining two acceptance pairs were not run, and AC-1/AC-2 remain OPEN at `prd:50%`. - **2026-10-11, completed Opus 5.5 HIGH timing diagnosis:** the corrected read-only inline-evidence invocation completed, exit 0 after 170.856 s. Its final answer and terminal receipt are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/opus-timing-adequate-budget-20261011T111205Z/{completed-final-answer.txt,terminal.json}`. In the immutable first pair, ASTC reaches `frameBeganAtMs` 96.824 ms later, while the subsequent gap shrinks 6.871 ms (the 0.001 ms difference from the headline +89.952 ms is rounding). Measured gap-segment deltas are texture upload +0.523 ms, buffer upload -6.879 ms, pipeline compile +0.406 ms and shader compile -0.047 ms; attributed time and residual both shrink. The observed regression therefore sits before `frameBeganAtMs`, where these objects provide no breakdown. Confidence in that localization is high; no causal mechanism, concrete engine bug or minimal fix is established. Driver, JS, asset, cache and variance explanations remain unproven. The missing evidence is a same-clock breakdown of that earlier interval for both arms, separate from the counter-marked runs. This diagnosis does not re-judge pair 1 FAIL; AC-1/AC-2 remain OPEN and progress remains `prd:50%`. @@ -187,6 +189,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Blocked on +- **2026-10-11, AC-1 visual judgment — BLOCKED.** The requested ClaudeCode visual-judge skill is unavailable, as established by the prior exhaustive filesystem and dynamic searches; no substitute verdict is supplied. Captures remain UNJUDGED, and GitHub publication is held for human inspection. AC-1 remains OPEN. - **Physical-device VRAM figure (Pixel 8).** The resident texture bytes on hardware, against the PNG build — unblocked by João attaching the device. - **iOS JSC.** Same path on iOS — unblocked by the iOS lane (`BLOCKED/requires-ios-ecossystem`). From a0d0ab0b93e7e59e98d15c0a1835e5b09a70ec2c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 05:07:58 -0700 Subject: [PATCH 36/57] docs(prd568): state blocked disposition and restored security settings --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index c162728450..c3f0c3414d 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,7 +41,9 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions -- **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Root reviewed the restoration1/paranoid-1 restoration checks and guards. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. +- **2026-10-11, PRD568 BLOCKED:** the requested ClaudeCode visual-judge skill is unavailable. AC-1 remains UNJUDGED, AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. + +- **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Reviewed receipts show `security.perf_harden` restored to 1 and `perf_event_paranoid` unchanged at -1, with source guards passing. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. - **2026-10-11, fixed BAAB repeatability diagnostic only:** four uninstrumented diagnostic launches in the fixed Basis/ASTC/ASTC/Basis order pass all eight original assertions per launch, colour and installed-APK hash checks; the supervisor exits 0 and the source guard passes. Evidence: `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/diagnostic-repeatability-20261011T1120Z/execution-20261011T113146Z/diagnostic-result.json` and the adjacent terminal/guard receipts. First frames are 1090.794, 989.673, 998.560 and 977.203 ms. The two adjacent-pair ASTC-minus-Basis deltas are -101.121/+21.357 ms; corresponding pre-frame deltas are -49.686/-2.050 ms, gap -51.436/+23.407 ms, asset read -17.372/+0.452 ms and read-complete→first bootstrap compile -44.209/-33.010 ms. The direction changes and localization is inconsistent; cause remains unknown. These diagnostic pairs supply no causal, statistical or acceptance-pass claim and introduce no tolerance. The original 946.213→1036.165 ms acceptance pair remains FAIL, its remaining two acceptance pairs were not run, and AC-1/AC-2 remain OPEN at `prd:50%`. From 6c42faab127cdf9f70b327e39fa24c4d17c586d1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Sun, 11 Oct 2026 09:47:44 -0700 Subject: [PATCH 37/57] docs(prd568): retract unavailable visual workflow blocker --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index c3f0c3414d..5fec167097 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,7 +41,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions -- **2026-10-11, PRD568 BLOCKED:** the requested ClaudeCode visual-judge skill is unavailable. AC-1 remains UNJUDGED, AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. +- **2026-10-11, user-directed judging-workflow lookup; unavailable-workflow blocker retracted:** lookup in the engine folder found the existing Claude visual judge rubric at `.claude/skills/self-improve/references/judge-rubric.md`; its parent `self-improve/SKILL.md:80–83` describes a fresh gauntlet critic and `pnpm sweep:judge`. The generated `threenative-visuals` skill also describes a fresh read-only verifier. The earlier unavailable-workflow claim is retracted; the blind benchmark rubric does not automatically supply this PRD's required IMPROVEMENT verdict. AC-1 remains UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER capture rules. AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. - **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Reviewed receipts show `security.perf_harden` restored to 1 and `perf_event_paranoid` unchanged at -1, with source guards passing. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. @@ -191,7 +191,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Blocked on -- **2026-10-11, AC-1 visual judgment — BLOCKED.** The requested ClaudeCode visual-judge skill is unavailable, as established by the prior exhaustive filesystem and dynamic searches; no substitute verdict is supplied. Captures remain UNJUDGED, and GitHub publication is held for human inspection. AC-1 remains OPEN. +- **2026-10-11, AC-1 visual judgment — PENDING REVIEW.** User-directed engine-folder lookup found the existing Claude visual judge rubric/workflow; the earlier unavailable-workflow blocker is retracted. AC-1 remains OPEN and captures remain UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER rules. GitHub publication remains held for human inspection; no formal verdict is claimed. - **Physical-device VRAM figure (Pixel 8).** The resident texture bytes on hardware, against the PNG build — unblocked by João attaching the device. - **iOS JSC.** Same path on iOS — unblocked by the iOS lane (`BLOCKED/requires-ios-ecossystem`). From e1f77f39786a07378ff0c14d4ff8654c2b0651b4 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 22:55:14 -0700 Subject: [PATCH 38/57] docs(prd568): record repeated visual regression --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 5fec167097..18c30c5f33 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-12, bounded Sonnet action review returned no result:** the read-only `claude-sonnet-5-5 --effort high` task ended at its 600-second supervisor limit (exit 124), with empty stdout/stderr in `/tmp/pr488-sonnet-action.log`. It establishes no timing cause, justified fix or acceptance result; no Sonnet retry followed. The user subsequently requested Haiku 5.5 MAX for grunt work and emulator-based mobile validation. + +- **2026-10-12, fresh visual judge: REGRESSION in both repeated PNG/ASTC pairs.** The original same-pose 960×640 Android-emulator captures in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/{png,astc}/` were preserved. Separate `judge-labeled/{png-1,png-2,astc-1,astc-2}.png` derivatives add an 80-pixel black header with large BEFORE/AFTER and run labels. A fresh read-only judge viewed all four derivatives: the right checker plane's top-left square changes from green to blue and its alternating colour placement reverses in both runs; cube, ring, framing and shadows appear unchanged. Both per-pair verdicts and the overall verdict are **REGRESSION**. This establishes an appearance mismatch, not its cause or a performance result. Haiku 5.5 MAX is tracing the fixture inputs and texture orientation before any fix or emulator recapture. AC-1 remains OPEN; AC-2's recorded timing failure remains OPEN, and progress remains `prd:50%`. + - **2026-10-11, user-directed judging-workflow lookup; unavailable-workflow blocker retracted:** lookup in the engine folder found the existing Claude visual judge rubric at `.claude/skills/self-improve/references/judge-rubric.md`; its parent `self-improve/SKILL.md:80–83` describes a fresh gauntlet critic and `pnpm sweep:judge`. The generated `threenative-visuals` skill also describes a fresh read-only verifier. The earlier unavailable-workflow claim is retracted; the blind benchmark rubric does not automatically supply this PRD's required IMPROVEMENT verdict. AC-1 remains UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER capture rules. AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. - **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Reviewed receipts show `security.perf_harden` restored to 1 and `perf_event_paranoid` unchanged at -1, with source guards passing. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. From e47085b8865eed4d9d976f1168136e65678bbe1e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 23:01:13 -0700 Subject: [PATCH 39/57] docs(prd568): localize standalone KTX2 orientation mismatch Record the byte-pair trace in PRD-568. Both arms cook the same checker.png. The decoded ASTC level 0 runs top-down, so the cook adds no flip. Standalone PNG loads with flipY=true. Standalone KTX2 loads as CompressedTexture with flipY=false, and three's WebGPU compressed upload ignores flipY. The layer is the engine's standalone KTX2 orientation contract. The owner decides where the flip lives. No code change. No box ticked. Emulator, device and web lanes not run. --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 18c30c5f33..b409a84e8f 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -45,6 +45,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-12, fresh visual judge: REGRESSION in both repeated PNG/ASTC pairs.** The original same-pose 960×640 Android-emulator captures in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/{png,astc}/` were preserved. Separate `judge-labeled/{png-1,png-2,astc-1,astc-2}.png` derivatives add an 80-pixel black header with large BEFORE/AFTER and run labels. A fresh read-only judge viewed all four derivatives: the right checker plane's top-left square changes from green to blue and its alternating colour placement reverses in both runs; cube, ring, framing and shadows appear unchanged. Both per-pair verdicts and the overall verdict are **REGRESSION**. This establishes an appearance mismatch, not its cause or a performance result. Haiku 5.5 MAX is tracing the fixture inputs and texture orientation before any fix or emulator recapture. AC-1 remains OPEN; AC-2's recorded timing failure remains OPEN, and progress remains `prd:50%`. +- **2026-10-12, byte-pair orientation trace (no code change, no box changed).** Both arms cook the same 187-byte `checker.png` (`examples/abyss-framework/vq-assets/generate.ts:134-135`), and their job records differ only in arm, pid, paths, start time and `configSha256`. The decoded ASTC level-0 blocks in `astc/checker.ae3bc85c.ktx2` run top-down with the top-left block green, matching the PNG source, so the cook adds no flip. Standalone PNG loads with `flipY = true` (`packages/core/src/assets.ts:384-406`). Standalone KTX2 loads as `CompressedTexture` with `flipY = false` (`packages/core/node_modules/three/src/textures/CompressedTexture.js:67`, route `packages/core/src/assets.ts:194-196`). three's WebGPU compressed upload ignores `flipY` (`packages/core/node_modules/three/src/renderers/webgpu/utils/WebGPUTextureUtils.js:661-668`), so the loader cannot flip these rows. + +- **2026-10-12, orientation owner decision (open).** Layer: engine, the standalone KTX2 orientation contract; the fixture is not at fault. Mechanism: a V-axis flip, which fits the observed swap. Not proved: a 4x4 checker looks the same under a U or V flip, so an asymmetric check on device must confirm the axis. Not run: emulator, device and web lanes. Owner decision needed: flip standalone KTX2 rows cook-side in `packages/assets/src/passes/texture.ts`, or load-side with a UV transform; the cook-side option changes every standalone KTX2 output on the shared loader route and needs a visual judge. + - **2026-10-11, user-directed judging-workflow lookup; unavailable-workflow blocker retracted:** lookup in the engine folder found the existing Claude visual judge rubric at `.claude/skills/self-improve/references/judge-rubric.md`; its parent `self-improve/SKILL.md:80–83` describes a fresh gauntlet critic and `pnpm sweep:judge`. The generated `threenative-visuals` skill also describes a fresh read-only verifier. The earlier unavailable-workflow claim is retracted; the blind benchmark rubric does not automatically supply this PRD's required IMPROVEMENT verdict. AC-1 remains UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER capture rules. AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. - **2026-10-11, stock CPU captures and completed Opus followup; undirected profiling stopped:** the two diagnostic startup captures contain 70/80 CPU-clock samples, pass the original eight assertions per arm plus installed-APK hash and colour checks, and match runtime BuildID `0a5a849c29023c80c3c413cad4709d2bd07edc4d`. Reviewed receipts show `security.perf_harden` restored to 1 and `perf_event_paranoid` unchanged at -1, with source guards passing. Capture receipts are `artifacts/prd568-v8-basis-before-20261011T065815Z/basis-call-probe/native-counter-recipe/simpleperf-startup-20261011T114914Z/{basis,astc}/capture-result.json`, beside the startup reports. The read-only Opus 5.5 HIGH followup completed, exit 0 after 526.436 s; final answer and terminal are in the sibling `opus-simpleperf-diagnosis-20261011T115504Z/{completed-final-answer.txt,terminal.json}`. It establishes no concrete ASTC bug or justified fix. On-CPU samples, approximate windows, absent V8 debug symbols and 7/3 full-app callchain errors limit attribution; these captures supply no causal, wall-time or acceptance-pass claim. Undirected profiling stops. The immutable original timing pair remains FAIL, its remaining acceptance pairs remain NOT RUN, and no further qualification follows without a justified product change. Cause remains unknown; AC-1/AC-2 remain OPEN, 0/2 acceptance and `prd:50%`. From d1db0357ed6ede80a4f0ac7d72ada77172c1cfd8 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Mon, 12 Oct 2026 23:21:30 -0700 Subject: [PATCH 40/57] fix(assets): store standalone ASTC rows bottom-up for mobile Standalone ASTC uploads have no flipY, so the first stored row must be the bottom row. PNG uploads get flipY. With astc: true, the texture pass now copies RGBA rows bottom-up into a separate buffer after block size admission and before encodeTextureLadder, so the ladder scores the oriented pixels. The PNG fallback, codec none, unaligned output and output without astc keep the original row order. The ASTC gpuFormat identity moves from v3 to astc4x4-final-quality-v4-bottom-up. Red: the asymmetric ASTC regression decoded level 30 where level 170 was required. Green: texture-pass.spec.ts runs 31 passed and 1 skipped, exit 0. pnpm -C packages/assets typecheck exits 0. Not run: Android emulator, device, web, native and visual judge. PRD-568: orientation decision recorded. AC-1 and AC-2 stay open. --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 2 +- .../assets/__tests__/texture-pass.spec.ts | 74 ++++++++++++++++++- packages/assets/src/passes/texture.ts | 16 +++- 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index b409a84e8f..49af88a960 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -47,7 +47,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - **2026-10-12, byte-pair orientation trace (no code change, no box changed).** Both arms cook the same 187-byte `checker.png` (`examples/abyss-framework/vq-assets/generate.ts:134-135`), and their job records differ only in arm, pid, paths, start time and `configSha256`. The decoded ASTC level-0 blocks in `astc/checker.ae3bc85c.ktx2` run top-down with the top-left block green, matching the PNG source, so the cook adds no flip. Standalone PNG loads with `flipY = true` (`packages/core/src/assets.ts:384-406`). Standalone KTX2 loads as `CompressedTexture` with `flipY = false` (`packages/core/node_modules/three/src/textures/CompressedTexture.js:67`, route `packages/core/src/assets.ts:194-196`). three's WebGPU compressed upload ignores `flipY` (`packages/core/node_modules/three/src/renderers/webgpu/utils/WebGPUTextureUtils.js:661-668`), so the loader cannot flip these rows. -- **2026-10-12, orientation owner decision (open).** Layer: engine, the standalone KTX2 orientation contract; the fixture is not at fault. Mechanism: a V-axis flip, which fits the observed swap. Not proved: a 4x4 checker looks the same under a U or V flip, so an asymmetric check on device must confirm the axis. Not run: emulator, device and web lanes. Owner decision needed: flip standalone KTX2 rows cook-side in `packages/assets/src/passes/texture.ts`, or load-side with a UV transform; the cook-side option changes every standalone KTX2 output on the shared loader route and needs a visual judge. +- **2026-10-12, orientation decision: cook-side ASTC row order (host-proved; device not run).** Decided by the parent agent under the bounded brief. Layer: engine, `packages/assets/src/passes/texture.ts`. Reason: a standalone ASTC upload has no flipY, but a PNG upload does. Design: when `astc: true`, the pass copies RGBA rows bottom-up into a separate buffer. The copy runs after block-size admission and before `encodeTextureLadder`, so the ladder scores the oriented pixels. The PNG fallback, `codec: none`, unaligned output and output without `astc: true` keep the original row order. The ASTC `gpuFormat` identity changes from `astc4x4-final-quality-v3` to `astc4x4-final-quality-v4-bottom-up`. Red: the new ASTC regression failed before the change. Decoded level 30 appeared where level 170 was required (`expected 140 to be less than or equal to 16`). Green: that case passes after the change. The full `texture-pass.spec.ts` run ends with 31 passed and 1 skipped, exit 0, in 115.85 s. `pnpm -C packages/assets typecheck` exits 0. `biome lint` reports the same two complexity warnings as HEAD and no new warning. Test change: the first retention check relied on 0–7 level periodic noise. The resize erased that noise, so ASTC passed the floor exactly. Hashed noise from -16 to 15 levels, checked with 8×8 window means, replaces it. Not run: Android emulator capture, device, web, native and visual judge. AC-1 and AC-2 stay OPEN. Visual acceptance stays OPEN. The REGRESSION verdict and the timing failure notes above stay as recorded. - **2026-10-11, user-directed judging-workflow lookup; unavailable-workflow blocker retracted:** lookup in the engine folder found the existing Claude visual judge rubric at `.claude/skills/self-improve/references/judge-rubric.md`; its parent `self-improve/SKILL.md:80–83` describes a fresh gauntlet critic and `pnpm sweep:judge`. The generated `threenative-visuals` skill also describes a fresh read-only verifier. The earlier unavailable-workflow claim is retracted; the blind benchmark rubric does not automatically supply this PRD's required IMPROVEMENT verdict. AC-1 remains UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER capture rules. AC-2 remains FAIL/OPEN and progress remains `prd:50%`; no completion is claimed. diff --git a/packages/assets/__tests__/texture-pass.spec.ts b/packages/assets/__tests__/texture-pass.spec.ts index ce62a400e5..b6a4093737 100644 --- a/packages/assets/__tests__/texture-pass.spec.ts +++ b/packages/assets/__tests__/texture-pass.spec.ts @@ -7,9 +7,9 @@ import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { basisTranscoderPaths } from "../../../test-support/three-basis.js"; import * as imageQualityModule from "../src/image-quality.js"; -import { transcodeKtx2ToAstc } from "../src/image-quality.js"; +import { decodeBasisKtx2, transcodeKtx2ToAstc } from "../src/image-quality.js"; import { type IAssetSourceConfig, compileAssets, texturePass } from "../src/index.js"; -import { KTX2_ENCODER_VERSION, encodeToKTX2 } from "../src/ktx2-encoder.js"; +import { KTX2_ENCODER_VERSION, decodeAstcKtx2, encodeToKTX2 } from "../src/ktx2-encoder.js"; import { encodeTextureLadder } from "../src/passes/model-textures.js"; import { parsePng } from "../src/png.js"; @@ -41,6 +41,19 @@ async function compileOne( }; } +/** Flat levels run from 30 at the top-left to 240 at the bottom-right. A flip moves each level to another corner. */ +function quadrantLevel(size: number, x: number, y: number): number { + return 30 + 70 * ((y < size / 2 ? 0 : 2) + (x < size / 2 ? 0 : 1)); +} + +/** Grey quadrants. Hashed noise from -16 to 15 levels keeps ASTC inexact after a resize. */ +function quadrantPng(size: number, noisy = false): Buffer { + const noise = (x: number, y: number) => + (((Math.imul(x + 1, 0x45d9f3b) ^ Math.imul(y + 1, 0x27d4eb2d)) >>> 16) % 32) - 16; + const level = (x: number, y: number) => quadrantLevel(size, x, y) + (noisy ? noise(x, y) : 0); + return rgbaPng({ blue: level, green: level, height: size, red: level, width: size }); +} + function ktx2Magic(bytes: Buffer): boolean { return [...bytes.subarray(1, 7)].map((byte) => String.fromCharCode(byte)).join("") === "KTX 20"; } @@ -337,6 +350,63 @@ describe("the ktx2 texture pass", () => { expect(ktx2.pixelHeight % 4).toBe(0); }); + it("stores standalone ASTC rows bottom-up, so an upload without flipY matches the PNG", async () => { + const size = 64; + const result = await texturePass({ + astc: true, + overrides: [{ glob: "colour.png", codec: "uastc" }], + }).apply(quadrantPng(size), "colour.png"); + if (Buffer.isBuffer(result)) throw new Error("texture was not encoded"); + expect(result.entry?.format).toBe("astc4x4"); + const decoded = await decodeAstcKtx2(result.buffer, size, size); + const red = (x: number, y: number) => decoded[(y * size + x) * 4] ?? Number.NaN; + // Decoded row y must hold source row size - 1 - y. A column flip or no flip fails these samples. + for (const y of [8, 56]) + for (const x of [8, 56]) + expect(Math.abs(red(x, y) - quadrantLevel(size, x, size - 1 - y))).toBeLessThanOrEqual(16); + }); + + it("keeps PNG orientation for codec none, retained PNG and non-ASTC output", async () => { + const size = 64; + const input = quadrantPng(size, true); + const none = await texturePass({ + astc: true, + overrides: [{ glob: "colour.png", codec: "none" }], + }).apply(input, "colour.png"); + if (Buffer.isBuffer(none)) throw new Error("codec none did not pass the source through"); + expect(none.buffer).toEqual(input); + + // No lossy encoder meets this floor, so the pass retains the resized PNG. + const retained = await texturePass({ + astc: true, + floor: { meanDeltaE00: 0, ssim: 1 }, + maxSize: 32, + }).apply(input, "colour.png"); + if (Buffer.isBuffer(retained)) throw new Error("texture was not retained"); + expect(retained.entry?.format).toBe("none"); + const png = PNG.sync.read(retained.buffer); + // An 8x8 window mean cancels the hashed noise. A row flip moves each mean to another quadrant. + const mean = (cx: number, cy: number) => { + let sum = 0; + for (let y = cy - 4; y < cy + 4; y += 1) + for (let x = cx - 4; x < cx + 4; x += 1) sum += png.data[(y * 32 + x) * 4] ?? Number.NaN; + return sum / 64; + }; + for (const y of [8, 24]) + for (const x of [8, 24]) + expect(Math.abs(mean(x, y) - quadrantLevel(size, x * 2, y * 2))).toBeLessThanOrEqual(16); + + // The Basis path keeps source row order. A row flip would move level 30 to level 170 here. + // The flat source keeps this single-pixel sample free of the hashed noise. + const basis = await texturePass({ + overrides: [{ glob: "colour.png", codec: "uastc" }], + }).apply(quadrantPng(size), "colour.png"); + if (Buffer.isBuffer(basis)) throw new Error("texture was not encoded"); + const decodedBasis = await decodeBasisKtx2(basis.buffer, size, size); + const basisRed = decodedBasis[(8 * size + 8) * 4] ?? Number.NaN; + expect(Math.abs(basisRed - quadrantLevel(size, 8, 8))).toBeLessThanOrEqual(16); + }); + it("should cap standalone colour and normal textures through the encoder decoder without upscaling or changing none bytes", async () => { // Removing maxSize from the pass configuration makes the first two KTX2 dimensions stay // 12x8, so this asserts the encoded artifact rather than a decoder mock. diff --git a/packages/assets/src/passes/texture.ts b/packages/assets/src/passes/texture.ts index 2c6bf3497b..7d4dfdbc50 100644 --- a/packages/assets/src/passes/texture.ts +++ b/packages/assets/src/passes/texture.ts @@ -113,7 +113,7 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { appliesTo: ["texture"], configuration: { encoder: KTX2_ENCODER_VERSION, - ...(options.astc === true ? { gpuFormat: "astc4x4-final-quality-v3" } : {}), + ...(options.astc === true ? { gpuFormat: "astc4x4-final-quality-v4-bottom-up" } : {}), instrument: IMAGE_QUALITY_IDENTITY, floor: resolveImageQualityFloor(options.floor), keepSmallerSource: true, @@ -165,7 +165,19 @@ export function texturePass(options: ITexturePassOptions = {}): IAssetPass { if (target.width % BLOCK_SIZE !== 0 || target.height % BLOCK_SIZE !== 0) { return unalignedOutcome(input, logicalPath, choice.codec, choice.explicit, target); } - const selected = await encodeTextureLadder(data, target.width, target.height, { + // Standalone ASTC uploads without the flipY that PNG gets, so its first stored row is the bottom row. + // The ladder scores these pixels. The PNG fallback below keeps reading `data`. + let encodeInput = data; + if (options.astc === true) { + const stride = target.width * 4; + encodeInput = new Uint8Array(data.length); + for (let row = 0; row < target.height; row += 1) + encodeInput.set( + data.subarray((target.height - 1 - row) * stride, (target.height - row) * stride), + row * stride, + ); + } + const selected = await encodeTextureLadder(encodeInput, target.width, target.height, { slots: [choice.normalMap ? "normalTexture" : "baseColorTexture"], floor: options.floor, srgb: !choice.normalMap, From 22e67c40e3f39e7ad89923e5f5f9868f57d9917c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 08:38:04 -0700 Subject: [PATCH 41/57] docs(prd568): record v4 emulator qualification attempts --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 49af88a960..5f5233b1f7 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -41,6 +41,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Decisions +- **2026-10-13, v4 QuickJS package built; emulator rendering remains unverified.** Assets and scaffolder builds passed after `d1db0357e`. The original AC-1 runner built and installed a 16 KB-aligned QuickJS APK with the new standalone ASTC cook (final checker SSIM 1.0000, mean ΔE00 0.000), but exited 2 with `TN_CAPTURE_BLANK`. The software emulator reported SwiftShader/Vulkan, ASTC support and game readiness; its screenshot remained black. Device doctor passed connectivity and thermal checks. A host-GPU emulator attempt then failed with `VK_ERROR_INCOMPATIBLE_DRIVER`, consistent with the observed NVIDIA driver/library mismatch. Both owned desktop emulator processes are stopped. The immutable candidate is `artifacts/prd568-v4-quickjs/candidate.apk`, SHA256 `1fb2c7c145dc4c2a67246477adea54e4e3de6de6f66b3be576ea632b51b22ad7`; failed capture and logcat remain beside it. Haiku 5.5 MAX is staging this same APK for laptop host-GPU emulator validation without a native rebuild. No native, visual or timing acceptance box is ticked; progress remains `prd:50%`. + - **2026-10-12, bounded Sonnet action review returned no result:** the read-only `claude-sonnet-5-5 --effort high` task ended at its 600-second supervisor limit (exit 124), with empty stdout/stderr in `/tmp/pr488-sonnet-action.log`. It establishes no timing cause, justified fix or acceptance result; no Sonnet retry followed. The user subsequently requested Haiku 5.5 MAX for grunt work and emulator-based mobile validation. - **2026-10-12, fresh visual judge: REGRESSION in both repeated PNG/ASTC pairs.** The original same-pose 960×640 Android-emulator captures in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/{png,astc}/` were preserved. Separate `judge-labeled/{png-1,png-2,astc-1,astc-2}.png` derivatives add an 80-pixel black header with large BEFORE/AFTER and run labels. A fresh read-only judge viewed all four derivatives: the right checker plane's top-left square changes from green to blue and its alternating colour placement reverses in both runs; cube, ring, framing and shadows appear unchanged. Both per-pair verdicts and the overall verdict are **REGRESSION**. This establishes an appearance mismatch, not its cause or a performance result. Haiku 5.5 MAX is tracing the fixture inputs and texture orientation before any fix or emulator recapture. AC-1 remains OPEN; AC-2's recorded timing failure remains OPEN, and progress remains `prd:50%`. From cf146326d144e35c531cad8430bbaff1870f4c63 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:24:59 -0700 Subject: [PATCH 42/57] docs(prd568): accept repeated native ASTC visual correction --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 5f5233b1f7..2c6a49853a 100644 --- a/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/unreal-source-borrowing/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -196,14 +196,14 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo ## Acceptance Criteria -- [ ] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: `sh scripts/vq01-android-proof.sh emulator-5554` with `THREENATIVE_GRADLE_ARGS=-PthreenativeJsEngine=quickjs` — Evidence: the latest matched-source QuickJS/x86_64 ASTC arm in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/` exits 0 with all eight original assertions, packaged raw ASTC and both authored checker colours passing. The earlier own-PID driver line remains raw evidence under the restored original runtime-console contract, with no physical-driver-fix claim. Captures are UNJUDGED; visual acceptance remains OPEN. +- [x] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: the existing VQ-01 Android scenario — current bottom-up v4 APK SHA256 `1fb2c7c145dc4c2a67246477adea54e4e3de6de6f66b3be576ea632b51b22ad7` packages successfully and passes all eight original assertions twice on the 16 KB Android emulator, Intel Iris Xe/Mesa Vulkan with ASTC support. `artifacts/prd568-v4-visual-pair/after-{1,2}/` retains both captures and runner observations. Fresh read-only independent judge: **IMPROVEMENT** for both matched v3-before/v4-after pairs and overall; both restore the authored green upper-left checker square, with other colours, geometry, framing and shadows preserved and no unmatched poses. Large BEFORE/AFTER labels are in `judge-labeled/`; the original PNG capture is an appearance reference only because its adapter differs. The optional supplemental raw logcat dump after run 2 stalled when the emulator disappeared; its reader was stopped, while both completed runner diagnostic observations and zero exit codes remain intact. The subsequent PNG control installation failed because the emulator had exited; it is not claimed as a completed control run. - [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. Completed Opus diagnosis localizes the difference before `frameBeganAtMs` without establishing its cause; a same-clock breakdown there remains missing. The separate fixed BAAB diagnostic changes direction and does not qualify acceptance. See the latest dated decisions; AC-2 stays OPEN. ## Blocked on -- **2026-10-11, AC-1 visual judgment — PENDING REVIEW.** User-directed engine-folder lookup found the existing Claude visual judge rubric/workflow; the earlier unavailable-workflow blocker is retracted. AC-1 remains OPEN and captures remain UNJUDGED pending a suitable fresh Sonnet 5.5 HIGH review under the owner's same-pose, two-runs-per-arm and BEFORE/AFTER rules. GitHub publication remains held for human inspection; no formal verdict is claimed. -- **Physical-device VRAM figure (Pixel 8).** The resident texture bytes on hardware, against the PNG build — unblocked by João attaching the device. -- **iOS JSC.** Same path on iOS — unblocked by the iOS lane (`BLOCKED/requires-ios-ecossystem`). +- **AC-2 remains open.** Fresh current-candidate V8 zero-call and three interleaved first-frame comparisons are still required; the historical failed timing pair remains recorded above. +- **Physical-device VRAM follow-up (Pixel 8), outside the explicit Android-emulator acceptance boxes.** Resident texture bytes against PNG remain unverified; encoded texture/APK sizes do not prove VRAM savings. +- **iOS JSC runtime follow-up, outside the explicit Android acceptance boxes.** The mobile cook has unit coverage, but same-path iOS execution remains unverified (`BLOCKED/requires-ios-ecossystem`). ## Integration Ledger @@ -230,7 +230,9 @@ A done PRD made the opposite trade on purpose. [PRD-349](../done/PRD-349-the-coo - [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` Verified unit-scoped proof: public `assets.texture()` tests parse actual raw vk157/vk158 fixtures with the installed `KTX2Loader` across WebGL/WebGPU capability maps. BC-only and ETC2-only support rejects with `TN_ASSETS_ASTC_UNSUPPORTED` naming the asset URL and disposes the texture once; ASTC support retains the mip payloads. Historical RED: eight failed/five passed/69 skipped, exit 1, 1.82 s (`/tmp/prd568-core-astc-guard-red.log`); GREEN: 13 passed/69 skipped, exit 0, 855 ms (`/tmp/prd568-core-astc-guard-green.log`), with original exit attribution retained in `/tmp/prd568-core-astc-guard-8vsdw86a/actual.patch`. Current focused `-t 'ASTC|astc' --maxWorkers=1` proof passed 28 tests (54 deselected), exit 0, 7.82 s (`/tmp/prd568-core-astc-requirement-audit.log`). This completes the specified unit-test proof; it claims no unsupported-adapter native process run or visual result. AC-1 and AC-2 remain open. - [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. -- [ ] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: the existing AC-1 runner and visual-judge contract; paired functional assertions/colour inspection pass above, captures remain UNJUDGED. +- [x] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: current v4 APK build/install, two original eight-assertion emulator runs, and two matched labeled visual pairs; fresh independent verdict **IMPROVEMENT** per pair and overall, as recorded in AC-1. - [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and native zero-call coverage with positive Basis control pass; the first uninstrumented timing pair fails and the remaining two pairs are unrun. See the latest dated decision; this box remains OPEN. **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. + +**2026-10-13, current v4 qualification:** fresh full asset suite passes 625 tests, six skipped, 39 files passed/one skipped (exit 0, 488.05 s) on the laptop, with two workers and unchanged limits. The actual packaged checker decodes to the vertically reversed authored RGBA bytes with maximum channel error zero and all six mips. Root lint passes (exit 0; existing warnings). Root typecheck initially lacked generated terrain/procedural-animal outputs and the terrain horizon fixture; after building/baking those prerequisites, three test-typing errors in the core ASTC cases remain under repair. No full typecheck/test completion or AC-2 success is claimed. From 2b55bf18b5875d4c863db7735f135a66dfedc3f6 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 11:27:31 -0700 Subject: [PATCH 43/57] test(assets): fix KTX2 spec typing for typecheck Cast the renderer stub through unknown, matching the spec's other test casts. Annotate the spied load receiver as KTX2Loader and the parse callback texture as CompressedTexture. Runtime behavior and assertions are unchanged. --- packages/core/__tests__/assets.spec.ts | 34 ++++++++++++++------------ 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 706488c913..81b82d3c9b 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -832,7 +832,7 @@ describe("IAssetLoader compressed textures", () => { loader.detectSupport({ isWebGPURenderer: true, hasFeature: (name: string) => name === "texture-compression-astc", - } as Parameters[0]); + } as unknown as Parameters[0]); const init = vi.spyOn(loader, "init").mockImplementation(() => { throw new Error("unexpected transcoder init"); }); @@ -893,20 +893,24 @@ describe("IAssetLoader compressed textures", () => { }; const disposed = vi.fn(); let parsed: CompressedTexture | undefined; - vi.spyOn(KTX2Loader.prototype, "load").mockImplementation( - function (_url, onLoad, _onProgress, onError) { - this.parse( - bytes.buffer, - (texture) => { - parsed = texture; - texture.addEventListener("dispose", disposed); - onLoad?.(texture); - }, - onError, - ); - return undefined; - }, - ); + vi.spyOn(KTX2Loader.prototype, "load").mockImplementation(function ( + this: KTX2Loader, + _url, + onLoad, + _onProgress, + onError, + ) { + this.parse( + bytes.buffer, + (texture: CompressedTexture) => { + parsed = texture; + texture.addEventListener("dispose", disposed); + onLoad?.(texture); + }, + onError, + ); + return undefined; + }); const init = vi.spyOn(KTX2Loader.prototype, "init").mockImplementation(() => { throw new Error("unexpected transcoder init"); }); From 4aaa2f65a1a15ecd1e8526577aeb0b2cf69b9017 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 14:30:15 -0700 Subject: [PATCH 44/57] test: prepare PRD-568 V8 AC-2 harness --- .linchpin/prd568-v8-ac2.mjs | 479 ++++++++++++++++++++++++++++++++++++ 1 file changed, 479 insertions(+) create mode 100644 .linchpin/prd568-v8-ac2.mjs diff --git a/.linchpin/prd568-v8-ac2.mjs b/.linchpin/prd568-v8-ac2.mjs new file mode 100644 index 0000000000..75ae037b94 --- /dev/null +++ b/.linchpin/prd568-v8-ac2.mjs @@ -0,0 +1,479 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const project = join(root, "examples/abyss-framework/vq-assets"); +const packageName = "com.threenative.vq01"; +const activity = "com.threenative.runtime.MystralActivity"; +const scenario = join(root, "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json"); +const runtimeRoot = join(root, "packages/runtime-native"); +const threenativeCli = join(root, "packages/create-threenative/dist/threenative.js"); +const bundleSource = join(runtimeRoot, "scripts/bundle.mjs"); +const assetsPackage = join(root, "packages/assets/dist/index.js"); +const runner = join(root, "packages/playtest/dist/runner/cli.js"); +const receipt = join(runtimeRoot, "third_party/v8-android/build-receipt.json"); +const packageAndroidUrl = pathToFileURL(join(runtimeRoot, "scripts/package-android.mjs")).href; +const coldStartUrl = pathToFileURL(join(runtimeRoot, "scripts/measure-cold-start.mjs")).href; +const sourceEntry = join(project, "src/game.ts"); + +const names = { + astcCoverage: "astc-coverage.apk", + basisCoverage: "basis-coverage.apk", + astcTiming: "astc-timing.apk", + basisTiming: "basis-timing.apk", +}; + +function usage() { + console.log(`Usage: node --import tsx .linchpin/prd568-v8-ac2.mjs [options] + +Builds current V8 ASTC and Basis-control APKs, proves decoder-call coverage, and runs +three interleaved first-frame pairs. The default mode runs both lanes. + +Options: + --device SERIAL named Android device/emulator (required to run) + --output DIR ignored output directory + --coverage build/run only the always-armed counter coverage + --timing build/run only the uninstrumented timing sequence + --reuse use APKs already present under --output + --self-check validate the tracked bundle's instrumentation anchors only + --plan print exact build and launch commands without running them + --help show this message`); +} + +function parseArgs(argv) { + const options = { mode: "all", reuse: false, plan: false }; + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--help") { + options.help = true; + } else if (argument === "--coverage" || argument === "--timing") { + if (options.mode !== "all") throw new Error("TN_PRD568_MODE_CONFLICT: choose --coverage or --timing"); + options.mode = argument.slice(2); + } else if (argument === "--reuse") { + options.reuse = true; + } else if (argument === "--plan") { + options.plan = true; + } else if (argument === "--self-check") { + options.selfCheck = true; + } else if (["--device", "--output"].includes(argument)) { + const value = argv[++index]; + if (!value || value.startsWith("--")) throw new Error(`TN_PRD568_OPTION_VALUE_MISSING:${argument}`); + options[argument.slice(2)] = value; + } else { + throw new Error(`TN_PRD568_OPTION_UNKNOWN:${argument}`); + } + } + return options; +} + +function writeJson(file, value) { + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function sha256(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function fail(message) { + throw new Error(message); +} + +function command(executable, args, options = {}) { + try { + return execFileSync(executable, args, { + cwd: options.cwd ?? root, + encoding: "utf8", + stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"], + timeout: options.timeout ?? 30_000, + env: options.env, + }); + } catch (error) { + const detail = error && typeof error === "object" + ? String(error.stderr ?? error.stdout ?? error.message ?? error) + : String(error); + fail(`TN_PRD568_COMMAND_FAILED:${executable} ${args.join(" ")}:${detail.trim()}`); + } +} + +function resolveAdb() { + const androidHome = process.env.ANDROID_HOME ?? join(homedir(), "Android/Sdk"); + const candidate = process.env.TN_PRD568_ADB ?? join(androidHome, "platform-tools/adb"); + if (existsSync(candidate)) return candidate; + try { + command("adb", ["version"]); + return "adb"; + } catch { + fail(`TN_PRD568_ADB_MISSING:${candidate}`); + } +} + +function adb(adbPath, serial, args, timeout = 30_000) { + return command(adbPath, ["-s", serial, ...args], { timeout }); +} + +function extractJsonMarkers(text, marker) { + const frames = []; + let cursor = 0; + while (true) { + const markerAt = text.indexOf(marker, cursor); + if (markerAt < 0) break; + const start = text.indexOf("{", markerAt + marker.length); + if (start < 0) fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); + let depth = 0; + let end = -1; + for (let index = start; index < text.length; index += 1) { + if (text[index] === "{") depth += 1; + if (text[index] === "}") { + depth -= 1; + if (depth === 0) { + end = index + 1; + break; + } + } + if (text[index] === "\n" && depth > 0) break; + } + if (end < 0) fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); + try { + frames.push(JSON.parse(text.slice(start, end))); + } catch { + fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); + } + cursor = end; + } + const unique = new Map(frames.map((frame) => [JSON.stringify(frame), frame])); + return [...unique.values()]; +} + +function requireV8Receipt() { + if (!existsSync(receipt)) { + fail(`TN_PRD568_V8_PAYLOAD_MISSING:${receipt}; finish the existing V8 producer, then rerun`); + } + try { + const value = JSON.parse(readFileSync(receipt, "utf8")); + if (!value || typeof value !== "object" || typeof value.build?.version !== "string") + fail(`TN_PRD568_V8_RECEIPT_INVALID:${receipt}`); + return { path: receipt, version: value.build.version }; + } catch (error) { + if (error instanceof Error && error.message.startsWith("TN_PRD568_")) throw error; + fail(`TN_PRD568_V8_RECEIPT_INVALID:${receipt}`); + } +} + +function projectConfigFrom(moduleValue) { + const config = moduleValue.default; + if (!config || typeof config !== "object" || !config.assets) fail("TN_PRD568_CONFIG_INVALID: missing default.assets"); + return config; +} + +async function loadProjectConfig() { + if (!process.execArgv.some((entry) => entry === "tsx" || entry.endsWith("/tsx"))) + fail("TN_PRD568_TSX_REQUIRED: invoke with node --import tsx"); + return projectConfigFrom(await import(pathToFileURL(join(project, "threenative.config.ts")).href)); +} + +function instrumentBundleSource(source) { + const plugin = [ + " const prd568InstrumentationPlugin = {", + " name: 'threenative-prd568-instrumentation',", + " enforce: 'post',", + " transform(code, id) {", + " const sourceId = id.split('?')[0];", + " const replaceOnce = (text, needle, replacement, label) => {", + " if (!text.includes(needle)) throw new Error('TN_PRD568_INSTRUMENTATION_ANCHOR_MISSING:' + label);", + " return text.replace(needle, replacement);", + " };", + " if (sourceId.endsWith('/KTX2Loader.js')) {", + " let result = code;", + " result = replaceOnce(result, 'super( manager );', 'super( manager );\\n\\t\\tglobalThis.__TN_PRD568_INSTRUMENTED__ = true;', 'constructor');", + " result = replaceOnce(result, 'const worker = new Worker( this.workerSourceURL );', 'globalThis.__TN_PRD568_WORKERS__ = (globalThis.__TN_PRD568_WORKERS__ ?? 0) + 1;\\n\\t\\tconst worker = new Worker( this.workerSourceURL );', 'worker');", + " result = replaceOnce(result, 'const { type: messageType, error, data: { faces, width, height, format, type, dfdFlags } } = transcodeResult;', 'const { type: messageType, error, data: { faces, width, height, format, type, dfdFlags, __tnPrd568TranscodeCalls } } = transcodeResult;\\n\\t\\tglobalThis.__TN_PRD568_CALLS__ = (globalThis.__TN_PRD568_CALLS__ ?? 0) + (__tnPrd568TranscodeCalls ?? 0);', 'main-response');", + " result = replaceOnce(result, 'function transcode( buffer ) {', 'function transcode( buffer ) { let __tnPrd568TranscodeCalls = 0;', 'worker-transcode');", + " result = replaceOnce(result, 'const status = ktx2File.transcodeImage( dst, mip, layer, face, transcoderFormat, 0, - 1, - 1 );', 'const status = ktx2File.transcodeImage( dst, mip, layer, face, transcoderFormat, 0, - 1, - 1 ); __tnPrd568TranscodeCalls += 1;', 'transcode-call');", + " result = replaceOnce(result, \"self.postMessage( { type: 'transcode', id: message.id, data: { faces, width, height, hasAlpha, format, type, dfdFlags } }, buffers );\", \"self.postMessage( { type: 'transcode', id: message.id, data: { faces, width, height, hasAlpha, format, type, dfdFlags, __tnPrd568TranscodeCalls } }, buffers );\", 'worker-response');", + " return { code: result, map: null };", + " }", + ` if (sourceId === ${JSON.stringify(sourceEntry)}) {`, + " const anchor = 'ctx.state.set({ loadedModels: this.#models.length, texturedMeshes, vertices, entries: entry });';", + " const marker = `${anchor}\\n console.info(\"TN_PRD568_COUNTER:\" + JSON.stringify({ ready: true, armed: globalThis.__TN_PRD568_INSTRUMENTED__ === true, calls: globalThis.__TN_PRD568_CALLS__ ?? 0, workers: globalThis.__TN_PRD568_WORKERS__ ?? 0 }));`;", + " return { code: replaceOnce(code, anchor, marker, 'fixture-state'), map: null };", + " }", + " return null;", + " },", + " };", + ].join("\n"); + const insertion = " const mobileDecodersPlugin = {"; + if (!source.includes(insertion)) fail("TN_PRD568_BUNDLE_ANCHOR_MISSING:mobileDecodersPlugin"); + const withPlugin = source.replace(insertion, `${plugin}\n${insertion}`); + const pluginList = ": [importMetaPlugin, mobileDecodersPlugin, nativeEntryPlugin],"; + if (!withPlugin.includes(pluginList)) fail("TN_PRD568_BUNDLE_ANCHOR_MISSING:plugin-list"); + return withPlugin.replace(pluginList, ": [importMetaPlugin, prd568InstrumentationPlugin, mobileDecodersPlugin, nativeEntryPlugin],"); +} + +function makeInstrumentedBundleScript(output) { + mkdirSync(dirname(output), { recursive: true }); + writeFileSync(output, instrumentBundleSource(readFileSync(bundleSource, "utf8")), "utf8"); + return output; +} + +async function generateFixture() { + command(process.execPath, ["--import", "tsx", "-e", "const { generateNativeAssetFixture } = await import('./examples/abyss-framework/vq-assets/generate.ts'); await generateNativeAssetFixture('examples/abyss-framework/vq-assets/.generated/assets');"], { cwd: root, timeout: 120_000, inherit: true }); +} + +async function compileArm(config, output, platform, identity) { + rmSync(output, { force: true, recursive: true }); + const { compileAssets } = await import(pathToFileURL(assetsPackage).href); + await compileAssets({ + config: config.assets, + cwd: project, + output, + platform, + runtimeDecoders: { ktx2: true, meshopt: true, draco: false }, + runtimeIdentity: identity, + }); + if (!existsSync(join(output, "manifest.json"))) fail(`TN_PRD568_ASSET_MANIFEST_MISSING:${output}`); + return output; +} + +function bundleArm(output, instrumented) { + const script = instrumented ? makeInstrumentedBundleScript(join(dirname(output), "native-bundle.mjs")) : bundleSource; + command(process.execPath, [script, "--project", project, "--entry", "src/game.ts", "--target", "android", "--output", output, "--native-backend", "--decoders", "ktx2,meshopt"], { timeout: 180_000, inherit: true }); + if (!existsSync(output)) fail(`TN_PRD568_BUNDLE_MISSING:${output}`); + return output; +} + +async function packageArm(config, bundle, assets, apk, abi) { + const { packageAndroid } = await import(packageAndroidUrl); + const previous = process.env.THREENATIVE_GRADLE_ARGS; + process.env.THREENATIVE_GRADLE_ARGS = `-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`; + try { + await packageAndroid(bundle, apk, assets, undefined, config, { + allowSourceBuild: true, + format: "apk", + mode: "debug", + projectRoot: project, + runtimeRoot, + }); + } finally { + if (previous === undefined) delete process.env.THREENATIVE_GRADLE_ARGS; + else process.env.THREENATIVE_GRADLE_ARGS = previous; + } + if (!existsSync(apk)) fail(`TN_PRD568_APK_MISSING:${apk}`); + return apk; +} + +async function buildCurrentAstc(runRoot, abi) { + const sourceApk = join(project, "dist-native/vq-native-assets.apk"); + const apk = join(runRoot, "apk", names.astcTiming); + const previousRuntime = process.env.THREENATIVE_RUNTIME_SOURCE; + const previousGradle = process.env.THREENATIVE_GRADLE_ARGS; + process.env.THREENATIVE_RUNTIME_SOURCE = runtimeRoot; + process.env.THREENATIVE_GRADLE_ARGS = `-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`; + try { + command(process.execPath, [threenativeCli, "build", "--target", "android", "--allow-source-build"], { + cwd: project, + timeout: 900_000, + inherit: true, + }); + } finally { + if (previousRuntime === undefined) delete process.env.THREENATIVE_RUNTIME_SOURCE; + else process.env.THREENATIVE_RUNTIME_SOURCE = previousRuntime; + if (previousGradle === undefined) delete process.env.THREENATIVE_GRADLE_ARGS; + else process.env.THREENATIVE_GRADLE_ARGS = previousGradle; + } + if (!existsSync(sourceApk)) fail(`TN_PRD568_CURRENT_ASTC_APK_MISSING:${sourceApk}`); + mkdirSync(dirname(apk), { recursive: true }); + copyFileSync(sourceApk, apk); + return { apk, sha256: sha256(apk), source: "threenative build --target android" }; +} + +async function buildArm(config, runRoot, label, platform, instrumented, abi) { + const assets = join(runRoot, "assets", label); + const bundle = join(runRoot, "bundles", `${label}.js`); + const apk = join(runRoot, "apk", names[`${label}${instrumented ? "Coverage" : "Timing"}`]); + await compileArm(config, assets, platform, `android:v8:${label}`); + bundleArm(bundle, instrumented); + await packageArm(config, bundle, assets, apk, abi); + return { assets, bundle, apk, sha256: sha256(apk) }; +} + +function requireDevice(adbPath, serial) { + if (!serial) fail("TN_PRD568_DEVICE_REQUIRED: pass --device SERIAL"); + adb(adbPath, serial, ["get-state"]); + const qemu = adb(adbPath, serial, ["shell", "getprop", "ro.kernel.qemu"]).trim(); + return { serial, qemu: qemu || "unknown" }; +} + +function install(adbPath, serial, apk) { + adb(adbPath, serial, ["install", "-r", "--no-streaming", apk], 60_000); +} + +function runCoverage(adbPath, serial, apk, output, expected) { + const artifactDirectory = join(output, "coverage", expected); + rmSync(artifactDirectory, { force: true, recursive: true }); + install(adbPath, serial, apk); + command(process.execPath, [ + runner, + scenario, + "--target", "android", + "--device", serial, + "--package", packageName, + "--activity", activity, + "--adb", adbPath, + "--artifact", apk, + "--timeout", "120000", + "--artifacts", artifactDirectory, + ], { timeout: 180_000, inherit: true }); + const consoleFile = join(artifactDirectory, "console.json"); + if (!existsSync(consoleFile)) fail(`TN_PRD568_COUNTER_OBSERVATION_MISSING:${consoleFile}`); + const consoleText = readFileSync(consoleFile, "utf8"); + const counters = extractJsonMarkers(consoleText, "TN_PRD568_COUNTER:"); + if (counters.length !== 1) fail(`TN_PRD568_COUNTER_OBSERVATION_COUNT:${expected}:${counters.length}`); + const counter = counters[0]; + if (counter.ready !== true || counter.armed !== true || !Number.isInteger(counter.calls) || !Number.isInteger(counter.workers)) + fail(`TN_PRD568_COUNTER_OBSERVATION_INVALID:${expected}`); + const log = adb(adbPath, serial, ["logcat", "-d"], 30_000); + if (!/JS engine created:\s*V8/u.test(log)) fail(`TN_PRD568_V8_MARKER_MISSING:coverage:${expected}`); + if (log.includes("TN_ASSETS_ASTC_UNSUPPORTED")) fail(`TN_PRD568_ASTC_ADAPTER_REFUSED:coverage:${expected}`); + const expectedCalls = expected === "astc" ? 0 : 6; + const expectedWorkers = expected === "astc" ? 0 : 1; + if (counter.calls !== expectedCalls || counter.workers !== expectedWorkers) + fail(`TN_PRD568_COUNTER_EXPECTATION:${expected}:calls=${counter.calls},workers=${counter.workers}`); + return { expected, counter, consoleFile }; +} + +function parseTiming(log, label) { + if (!/JS engine created:\s*V8/u.test(log)) fail(`TN_PRD568_V8_MARKER_MISSING:${label}`); + const frames = extractJsonMarkers(log, "TN_STALL_SEGMENTS:"); + if (frames.length !== 1) fail(`TN_PRD568_STALL_OBSERVATION_COUNT:${label}:${frames.length}`); + const frame = frames[0]; + for (const field of ["toFirstFrameMs", "frameBeganAtMs", "gapMs", "attributedMs", "residualMs", "attributedShare"]) + if (!Number.isFinite(frame[field])) fail(`TN_PRD568_STALL_FIELD_MISSING:${label}:${field}`); + if (!frame.segments || typeof frame.segments !== "object" || Object.keys(frame.segments).length === 0) + fail(`TN_PRD568_STALL_SEGMENTS_MISSING:${label}`); + for (const [name, value] of Object.entries(frame.segments)) { + if (!value || !Number.isFinite(value.ms) || !Number.isInteger(value.calls)) fail(`TN_PRD568_STALL_SEGMENT_INVALID:${label}:${name}`); + } + return frame; +} + +async function runTiming(adbPath, serial, apks, output) { + const { breakdown, parseMarkers, SEGMENTS } = await import(coldStartUrl); + const sequence = ["basis", "astc", "basis", "astc", "basis", "astc"]; + const results = []; + for (let index = 0; index < sequence.length; index += 1) { + const label = `${String(index + 1).padStart(2, "0")}-${sequence[index]}`; + const apk = apks[sequence[index]]; + install(adbPath, serial, apk); + adb(adbPath, serial, ["shell", "am", "force-stop", packageName]); + adb(adbPath, serial, ["logcat", "-c"]); + adb(adbPath, serial, ["shell", "am", "start", "-n", `${packageName}/${activity}`]); + const deadline = Date.now() + 120_000; + let log = ""; + while (Date.now() < deadline) { + log = adb(adbPath, serial, ["logcat", "-d"], 30_000); + if (log.includes("TN_STALL_SEGMENTS:")) break; + await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); + } + if (!log.includes("TN_STALL_SEGMENTS:")) fail(`TN_PRD568_STALL_TIMEOUT:${label}`); + const stall = parseTiming(log, label); + const coldMarkers = parseMarkers(log); + const cold = breakdown(coldMarkers, SEGMENTS); + const logFile = join(output, "timing", `${label}.log`); + mkdirSync(dirname(logFile), { recursive: true }); + writeFileSync(logFile, log, "utf8"); + results.push({ label, arm: sequence[index], logFile, toFirstFrameMs: stall.toFirstFrameMs, gapMs: stall.gapMs, coldStartMs: cold.totalMs }); + console.log(`TN_PRD568_TIMING:${label}:toFirstFrameMs=${stall.toFirstFrameMs.toFixed(3)}:gapMs=${stall.gapMs.toFixed(3)}`); + } + const pairs = []; + for (let index = 0; index < 3; index += 1) { + const basis = results[index * 2]; + const astc = results[index * 2 + 1]; + if (!(astc.toFirstFrameMs <= basis.toFirstFrameMs)) + fail(`TN_PRD568_AC2_TIMING_FAIL:pair=${index + 1}:basis=${basis.toFirstFrameMs}:astc=${astc.toFirstFrameMs}`); + pairs.push({ pair: index + 1, basisMs: basis.toFirstFrameMs, astcMs: astc.toFirstFrameMs, pass: true }); + } + const result = { sequence, results, pairs, qualified: true }; + writeJson(join(output, "timing", "comparison.json"), result); + return result; +} + +function printPlan(runRoot, mode, abi) { + console.log(`TN_PRD568_PLAN_OUTPUT:${runRoot}`); + console.log(`TN_PRD568_PLAN_V8_RECEIPT:${receipt}`); + console.log(`TN_PRD568_PLAN_BUILD:compile android ASTC -> ${join(runRoot, "assets/astc")}`); + console.log(`TN_PRD568_PLAN_BUILD:compile web Basis control -> ${join(runRoot, "assets/basis")}`); + console.log(`TN_PRD568_PLAN_GRADLE:-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`); + if (mode === "all" || mode === "timing") console.log("TN_PRD568_PLAN_CURRENT_ASTC:node packages/create-threenative/dist/threenative.js build --target android --allow-source-build"); + if (mode === "all" || mode === "coverage") console.log("TN_PRD568_PLAN_COVERAGE:astc, basis positive control; expected calls 0, 6"); + if (mode === "all" || mode === "timing") console.log("TN_PRD568_PLAN_TIMING:basis, astc, basis, astc, basis, astc; compare TN_STALL_SEGMENTS.toFirstFrameMs"); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + usage(); + return; + } + if (options.selfCheck) { + const generated = join(root, ".linchpin", "prd568-v8-ac2-selfcheck-bundle.mjs"); + try { + writeFileSync(generated, instrumentBundleSource(readFileSync(bundleSource, "utf8")), "utf8"); + command(process.execPath, ["--check", generated]); + console.log(`TN_PRD568_SELF_CHECK_OK:${generated}`); + } finally { + rmSync(generated, { force: true }); + } + return; + } + const runRoot = resolve(options.output ?? join(root, ".linchpin", "prd568-ac2-v8", new Date().toISOString().replaceAll(/[:.]/gu, "-"))); + if (options.plan) { + printPlan(runRoot, options.mode, "x86_64"); + return; + } + if (existsSync(runRoot) && !options.reuse) fail(`TN_PRD568_OUTPUT_EXISTS:${runRoot}; choose a new --output or pass --reuse`); + mkdirSync(runRoot, { recursive: true }); + const adbPath = resolveAdb(); + const device = requireDevice(adbPath, options.device); + const abi = "x86_64"; + const config = await loadProjectConfig(); + const metadata = { runRoot, mode: options.mode, device, abi, v8Receipt: options.reuse ? "reused APK" : requireV8Receipt() }; + writeJson(join(runRoot, "setup.json"), metadata); + if (!options.reuse) await generateFixture(); + const coverage = {}; + const timing = {}; + if (options.mode === "all" || options.mode === "coverage") { + const astc = options.reuse ? { apk: join(runRoot, "apk", names.astcCoverage) } : await buildArm(config, runRoot, "astc", "android", true, abi); + const basis = options.reuse ? { apk: join(runRoot, "apk", names.basisCoverage) } : await buildArm(config, runRoot, "basis", "web", true, abi); + if (!existsSync(astc.apk) || !existsSync(basis.apk)) fail(`TN_PRD568_COVERAGE_APK_MISSING:${runRoot}`); + coverage.astc = runCoverage(adbPath, device.serial, astc.apk, runRoot, "astc"); + coverage.basis = runCoverage(adbPath, device.serial, basis.apk, runRoot, "basis"); + writeJson(join(runRoot, "coverage", "result.json"), coverage); + } + if (options.mode === "all" || options.mode === "timing") { + const astc = options.reuse ? { apk: join(runRoot, "apk", names.astcTiming) } : await buildCurrentAstc(runRoot, abi); + const basis = options.reuse ? { apk: join(runRoot, "apk", names.basisTiming) } : await buildArm(config, runRoot, "basis", "web", false, abi); + if (!existsSync(astc.apk) || !existsSync(basis.apk)) fail(`TN_PRD568_TIMING_APK_MISSING:${runRoot}`); + timing.result = await runTiming(adbPath, device.serial, { astc: astc.apk, basis: basis.apk }, runRoot); + } + writeJson(join(runRoot, "result.json"), { metadata, coverage, timing }); + console.log(`TN_PRD568_READY:${runRoot}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); From 282fdf12a74251c4adfa5a872a5bfc678fbd6035 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 14:35:48 -0700 Subject: [PATCH 45/57] chore: untrack temporary PRD-568 harness --- .linchpin/prd568-v8-ac2.mjs | 479 ------------------------------------ 1 file changed, 479 deletions(-) delete mode 100644 .linchpin/prd568-v8-ac2.mjs diff --git a/.linchpin/prd568-v8-ac2.mjs b/.linchpin/prd568-v8-ac2.mjs deleted file mode 100644 index 75ae037b94..0000000000 --- a/.linchpin/prd568-v8-ac2.mjs +++ /dev/null @@ -1,479 +0,0 @@ -#!/usr/bin/env node - -import { createHash } from "node:crypto"; -import { execFileSync } from "node:child_process"; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { homedir } from "node:os"; -import { basename, dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const project = join(root, "examples/abyss-framework/vq-assets"); -const packageName = "com.threenative.vq01"; -const activity = "com.threenative.runtime.MystralActivity"; -const scenario = join(root, "examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json"); -const runtimeRoot = join(root, "packages/runtime-native"); -const threenativeCli = join(root, "packages/create-threenative/dist/threenative.js"); -const bundleSource = join(runtimeRoot, "scripts/bundle.mjs"); -const assetsPackage = join(root, "packages/assets/dist/index.js"); -const runner = join(root, "packages/playtest/dist/runner/cli.js"); -const receipt = join(runtimeRoot, "third_party/v8-android/build-receipt.json"); -const packageAndroidUrl = pathToFileURL(join(runtimeRoot, "scripts/package-android.mjs")).href; -const coldStartUrl = pathToFileURL(join(runtimeRoot, "scripts/measure-cold-start.mjs")).href; -const sourceEntry = join(project, "src/game.ts"); - -const names = { - astcCoverage: "astc-coverage.apk", - basisCoverage: "basis-coverage.apk", - astcTiming: "astc-timing.apk", - basisTiming: "basis-timing.apk", -}; - -function usage() { - console.log(`Usage: node --import tsx .linchpin/prd568-v8-ac2.mjs [options] - -Builds current V8 ASTC and Basis-control APKs, proves decoder-call coverage, and runs -three interleaved first-frame pairs. The default mode runs both lanes. - -Options: - --device SERIAL named Android device/emulator (required to run) - --output DIR ignored output directory - --coverage build/run only the always-armed counter coverage - --timing build/run only the uninstrumented timing sequence - --reuse use APKs already present under --output - --self-check validate the tracked bundle's instrumentation anchors only - --plan print exact build and launch commands without running them - --help show this message`); -} - -function parseArgs(argv) { - const options = { mode: "all", reuse: false, plan: false }; - for (let index = 0; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === "--help") { - options.help = true; - } else if (argument === "--coverage" || argument === "--timing") { - if (options.mode !== "all") throw new Error("TN_PRD568_MODE_CONFLICT: choose --coverage or --timing"); - options.mode = argument.slice(2); - } else if (argument === "--reuse") { - options.reuse = true; - } else if (argument === "--plan") { - options.plan = true; - } else if (argument === "--self-check") { - options.selfCheck = true; - } else if (["--device", "--output"].includes(argument)) { - const value = argv[++index]; - if (!value || value.startsWith("--")) throw new Error(`TN_PRD568_OPTION_VALUE_MISSING:${argument}`); - options[argument.slice(2)] = value; - } else { - throw new Error(`TN_PRD568_OPTION_UNKNOWN:${argument}`); - } - } - return options; -} - -function writeJson(file, value) { - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, "utf8"); -} - -function sha256(file) { - return createHash("sha256").update(readFileSync(file)).digest("hex"); -} - -function fail(message) { - throw new Error(message); -} - -function command(executable, args, options = {}) { - try { - return execFileSync(executable, args, { - cwd: options.cwd ?? root, - encoding: "utf8", - stdio: options.inherit ? "inherit" : ["ignore", "pipe", "pipe"], - timeout: options.timeout ?? 30_000, - env: options.env, - }); - } catch (error) { - const detail = error && typeof error === "object" - ? String(error.stderr ?? error.stdout ?? error.message ?? error) - : String(error); - fail(`TN_PRD568_COMMAND_FAILED:${executable} ${args.join(" ")}:${detail.trim()}`); - } -} - -function resolveAdb() { - const androidHome = process.env.ANDROID_HOME ?? join(homedir(), "Android/Sdk"); - const candidate = process.env.TN_PRD568_ADB ?? join(androidHome, "platform-tools/adb"); - if (existsSync(candidate)) return candidate; - try { - command("adb", ["version"]); - return "adb"; - } catch { - fail(`TN_PRD568_ADB_MISSING:${candidate}`); - } -} - -function adb(adbPath, serial, args, timeout = 30_000) { - return command(adbPath, ["-s", serial, ...args], { timeout }); -} - -function extractJsonMarkers(text, marker) { - const frames = []; - let cursor = 0; - while (true) { - const markerAt = text.indexOf(marker, cursor); - if (markerAt < 0) break; - const start = text.indexOf("{", markerAt + marker.length); - if (start < 0) fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); - let depth = 0; - let end = -1; - for (let index = start; index < text.length; index += 1) { - if (text[index] === "{") depth += 1; - if (text[index] === "}") { - depth -= 1; - if (depth === 0) { - end = index + 1; - break; - } - } - if (text[index] === "\n" && depth > 0) break; - } - if (end < 0) fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); - try { - frames.push(JSON.parse(text.slice(start, end))); - } catch { - fail(`TN_PRD568_MARKER_MALFORMED:${marker}`); - } - cursor = end; - } - const unique = new Map(frames.map((frame) => [JSON.stringify(frame), frame])); - return [...unique.values()]; -} - -function requireV8Receipt() { - if (!existsSync(receipt)) { - fail(`TN_PRD568_V8_PAYLOAD_MISSING:${receipt}; finish the existing V8 producer, then rerun`); - } - try { - const value = JSON.parse(readFileSync(receipt, "utf8")); - if (!value || typeof value !== "object" || typeof value.build?.version !== "string") - fail(`TN_PRD568_V8_RECEIPT_INVALID:${receipt}`); - return { path: receipt, version: value.build.version }; - } catch (error) { - if (error instanceof Error && error.message.startsWith("TN_PRD568_")) throw error; - fail(`TN_PRD568_V8_RECEIPT_INVALID:${receipt}`); - } -} - -function projectConfigFrom(moduleValue) { - const config = moduleValue.default; - if (!config || typeof config !== "object" || !config.assets) fail("TN_PRD568_CONFIG_INVALID: missing default.assets"); - return config; -} - -async function loadProjectConfig() { - if (!process.execArgv.some((entry) => entry === "tsx" || entry.endsWith("/tsx"))) - fail("TN_PRD568_TSX_REQUIRED: invoke with node --import tsx"); - return projectConfigFrom(await import(pathToFileURL(join(project, "threenative.config.ts")).href)); -} - -function instrumentBundleSource(source) { - const plugin = [ - " const prd568InstrumentationPlugin = {", - " name: 'threenative-prd568-instrumentation',", - " enforce: 'post',", - " transform(code, id) {", - " const sourceId = id.split('?')[0];", - " const replaceOnce = (text, needle, replacement, label) => {", - " if (!text.includes(needle)) throw new Error('TN_PRD568_INSTRUMENTATION_ANCHOR_MISSING:' + label);", - " return text.replace(needle, replacement);", - " };", - " if (sourceId.endsWith('/KTX2Loader.js')) {", - " let result = code;", - " result = replaceOnce(result, 'super( manager );', 'super( manager );\\n\\t\\tglobalThis.__TN_PRD568_INSTRUMENTED__ = true;', 'constructor');", - " result = replaceOnce(result, 'const worker = new Worker( this.workerSourceURL );', 'globalThis.__TN_PRD568_WORKERS__ = (globalThis.__TN_PRD568_WORKERS__ ?? 0) + 1;\\n\\t\\tconst worker = new Worker( this.workerSourceURL );', 'worker');", - " result = replaceOnce(result, 'const { type: messageType, error, data: { faces, width, height, format, type, dfdFlags } } = transcodeResult;', 'const { type: messageType, error, data: { faces, width, height, format, type, dfdFlags, __tnPrd568TranscodeCalls } } = transcodeResult;\\n\\t\\tglobalThis.__TN_PRD568_CALLS__ = (globalThis.__TN_PRD568_CALLS__ ?? 0) + (__tnPrd568TranscodeCalls ?? 0);', 'main-response');", - " result = replaceOnce(result, 'function transcode( buffer ) {', 'function transcode( buffer ) { let __tnPrd568TranscodeCalls = 0;', 'worker-transcode');", - " result = replaceOnce(result, 'const status = ktx2File.transcodeImage( dst, mip, layer, face, transcoderFormat, 0, - 1, - 1 );', 'const status = ktx2File.transcodeImage( dst, mip, layer, face, transcoderFormat, 0, - 1, - 1 ); __tnPrd568TranscodeCalls += 1;', 'transcode-call');", - " result = replaceOnce(result, \"self.postMessage( { type: 'transcode', id: message.id, data: { faces, width, height, hasAlpha, format, type, dfdFlags } }, buffers );\", \"self.postMessage( { type: 'transcode', id: message.id, data: { faces, width, height, hasAlpha, format, type, dfdFlags, __tnPrd568TranscodeCalls } }, buffers );\", 'worker-response');", - " return { code: result, map: null };", - " }", - ` if (sourceId === ${JSON.stringify(sourceEntry)}) {`, - " const anchor = 'ctx.state.set({ loadedModels: this.#models.length, texturedMeshes, vertices, entries: entry });';", - " const marker = `${anchor}\\n console.info(\"TN_PRD568_COUNTER:\" + JSON.stringify({ ready: true, armed: globalThis.__TN_PRD568_INSTRUMENTED__ === true, calls: globalThis.__TN_PRD568_CALLS__ ?? 0, workers: globalThis.__TN_PRD568_WORKERS__ ?? 0 }));`;", - " return { code: replaceOnce(code, anchor, marker, 'fixture-state'), map: null };", - " }", - " return null;", - " },", - " };", - ].join("\n"); - const insertion = " const mobileDecodersPlugin = {"; - if (!source.includes(insertion)) fail("TN_PRD568_BUNDLE_ANCHOR_MISSING:mobileDecodersPlugin"); - const withPlugin = source.replace(insertion, `${plugin}\n${insertion}`); - const pluginList = ": [importMetaPlugin, mobileDecodersPlugin, nativeEntryPlugin],"; - if (!withPlugin.includes(pluginList)) fail("TN_PRD568_BUNDLE_ANCHOR_MISSING:plugin-list"); - return withPlugin.replace(pluginList, ": [importMetaPlugin, prd568InstrumentationPlugin, mobileDecodersPlugin, nativeEntryPlugin],"); -} - -function makeInstrumentedBundleScript(output) { - mkdirSync(dirname(output), { recursive: true }); - writeFileSync(output, instrumentBundleSource(readFileSync(bundleSource, "utf8")), "utf8"); - return output; -} - -async function generateFixture() { - command(process.execPath, ["--import", "tsx", "-e", "const { generateNativeAssetFixture } = await import('./examples/abyss-framework/vq-assets/generate.ts'); await generateNativeAssetFixture('examples/abyss-framework/vq-assets/.generated/assets');"], { cwd: root, timeout: 120_000, inherit: true }); -} - -async function compileArm(config, output, platform, identity) { - rmSync(output, { force: true, recursive: true }); - const { compileAssets } = await import(pathToFileURL(assetsPackage).href); - await compileAssets({ - config: config.assets, - cwd: project, - output, - platform, - runtimeDecoders: { ktx2: true, meshopt: true, draco: false }, - runtimeIdentity: identity, - }); - if (!existsSync(join(output, "manifest.json"))) fail(`TN_PRD568_ASSET_MANIFEST_MISSING:${output}`); - return output; -} - -function bundleArm(output, instrumented) { - const script = instrumented ? makeInstrumentedBundleScript(join(dirname(output), "native-bundle.mjs")) : bundleSource; - command(process.execPath, [script, "--project", project, "--entry", "src/game.ts", "--target", "android", "--output", output, "--native-backend", "--decoders", "ktx2,meshopt"], { timeout: 180_000, inherit: true }); - if (!existsSync(output)) fail(`TN_PRD568_BUNDLE_MISSING:${output}`); - return output; -} - -async function packageArm(config, bundle, assets, apk, abi) { - const { packageAndroid } = await import(packageAndroidUrl); - const previous = process.env.THREENATIVE_GRADLE_ARGS; - process.env.THREENATIVE_GRADLE_ARGS = `-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`; - try { - await packageAndroid(bundle, apk, assets, undefined, config, { - allowSourceBuild: true, - format: "apk", - mode: "debug", - projectRoot: project, - runtimeRoot, - }); - } finally { - if (previous === undefined) delete process.env.THREENATIVE_GRADLE_ARGS; - else process.env.THREENATIVE_GRADLE_ARGS = previous; - } - if (!existsSync(apk)) fail(`TN_PRD568_APK_MISSING:${apk}`); - return apk; -} - -async function buildCurrentAstc(runRoot, abi) { - const sourceApk = join(project, "dist-native/vq-native-assets.apk"); - const apk = join(runRoot, "apk", names.astcTiming); - const previousRuntime = process.env.THREENATIVE_RUNTIME_SOURCE; - const previousGradle = process.env.THREENATIVE_GRADLE_ARGS; - process.env.THREENATIVE_RUNTIME_SOURCE = runtimeRoot; - process.env.THREENATIVE_GRADLE_ARGS = `-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`; - try { - command(process.execPath, [threenativeCli, "build", "--target", "android", "--allow-source-build"], { - cwd: project, - timeout: 900_000, - inherit: true, - }); - } finally { - if (previousRuntime === undefined) delete process.env.THREENATIVE_RUNTIME_SOURCE; - else process.env.THREENATIVE_RUNTIME_SOURCE = previousRuntime; - if (previousGradle === undefined) delete process.env.THREENATIVE_GRADLE_ARGS; - else process.env.THREENATIVE_GRADLE_ARGS = previousGradle; - } - if (!existsSync(sourceApk)) fail(`TN_PRD568_CURRENT_ASTC_APK_MISSING:${sourceApk}`); - mkdirSync(dirname(apk), { recursive: true }); - copyFileSync(sourceApk, apk); - return { apk, sha256: sha256(apk), source: "threenative build --target android" }; -} - -async function buildArm(config, runRoot, label, platform, instrumented, abi) { - const assets = join(runRoot, "assets", label); - const bundle = join(runRoot, "bundles", `${label}.js`); - const apk = join(runRoot, "apk", names[`${label}${instrumented ? "Coverage" : "Timing"}`]); - await compileArm(config, assets, platform, `android:v8:${label}`); - bundleArm(bundle, instrumented); - await packageArm(config, bundle, assets, apk, abi); - return { assets, bundle, apk, sha256: sha256(apk) }; -} - -function requireDevice(adbPath, serial) { - if (!serial) fail("TN_PRD568_DEVICE_REQUIRED: pass --device SERIAL"); - adb(adbPath, serial, ["get-state"]); - const qemu = adb(adbPath, serial, ["shell", "getprop", "ro.kernel.qemu"]).trim(); - return { serial, qemu: qemu || "unknown" }; -} - -function install(adbPath, serial, apk) { - adb(adbPath, serial, ["install", "-r", "--no-streaming", apk], 60_000); -} - -function runCoverage(adbPath, serial, apk, output, expected) { - const artifactDirectory = join(output, "coverage", expected); - rmSync(artifactDirectory, { force: true, recursive: true }); - install(adbPath, serial, apk); - command(process.execPath, [ - runner, - scenario, - "--target", "android", - "--device", serial, - "--package", packageName, - "--activity", activity, - "--adb", adbPath, - "--artifact", apk, - "--timeout", "120000", - "--artifacts", artifactDirectory, - ], { timeout: 180_000, inherit: true }); - const consoleFile = join(artifactDirectory, "console.json"); - if (!existsSync(consoleFile)) fail(`TN_PRD568_COUNTER_OBSERVATION_MISSING:${consoleFile}`); - const consoleText = readFileSync(consoleFile, "utf8"); - const counters = extractJsonMarkers(consoleText, "TN_PRD568_COUNTER:"); - if (counters.length !== 1) fail(`TN_PRD568_COUNTER_OBSERVATION_COUNT:${expected}:${counters.length}`); - const counter = counters[0]; - if (counter.ready !== true || counter.armed !== true || !Number.isInteger(counter.calls) || !Number.isInteger(counter.workers)) - fail(`TN_PRD568_COUNTER_OBSERVATION_INVALID:${expected}`); - const log = adb(adbPath, serial, ["logcat", "-d"], 30_000); - if (!/JS engine created:\s*V8/u.test(log)) fail(`TN_PRD568_V8_MARKER_MISSING:coverage:${expected}`); - if (log.includes("TN_ASSETS_ASTC_UNSUPPORTED")) fail(`TN_PRD568_ASTC_ADAPTER_REFUSED:coverage:${expected}`); - const expectedCalls = expected === "astc" ? 0 : 6; - const expectedWorkers = expected === "astc" ? 0 : 1; - if (counter.calls !== expectedCalls || counter.workers !== expectedWorkers) - fail(`TN_PRD568_COUNTER_EXPECTATION:${expected}:calls=${counter.calls},workers=${counter.workers}`); - return { expected, counter, consoleFile }; -} - -function parseTiming(log, label) { - if (!/JS engine created:\s*V8/u.test(log)) fail(`TN_PRD568_V8_MARKER_MISSING:${label}`); - const frames = extractJsonMarkers(log, "TN_STALL_SEGMENTS:"); - if (frames.length !== 1) fail(`TN_PRD568_STALL_OBSERVATION_COUNT:${label}:${frames.length}`); - const frame = frames[0]; - for (const field of ["toFirstFrameMs", "frameBeganAtMs", "gapMs", "attributedMs", "residualMs", "attributedShare"]) - if (!Number.isFinite(frame[field])) fail(`TN_PRD568_STALL_FIELD_MISSING:${label}:${field}`); - if (!frame.segments || typeof frame.segments !== "object" || Object.keys(frame.segments).length === 0) - fail(`TN_PRD568_STALL_SEGMENTS_MISSING:${label}`); - for (const [name, value] of Object.entries(frame.segments)) { - if (!value || !Number.isFinite(value.ms) || !Number.isInteger(value.calls)) fail(`TN_PRD568_STALL_SEGMENT_INVALID:${label}:${name}`); - } - return frame; -} - -async function runTiming(adbPath, serial, apks, output) { - const { breakdown, parseMarkers, SEGMENTS } = await import(coldStartUrl); - const sequence = ["basis", "astc", "basis", "astc", "basis", "astc"]; - const results = []; - for (let index = 0; index < sequence.length; index += 1) { - const label = `${String(index + 1).padStart(2, "0")}-${sequence[index]}`; - const apk = apks[sequence[index]]; - install(adbPath, serial, apk); - adb(adbPath, serial, ["shell", "am", "force-stop", packageName]); - adb(adbPath, serial, ["logcat", "-c"]); - adb(adbPath, serial, ["shell", "am", "start", "-n", `${packageName}/${activity}`]); - const deadline = Date.now() + 120_000; - let log = ""; - while (Date.now() < deadline) { - log = adb(adbPath, serial, ["logcat", "-d"], 30_000); - if (log.includes("TN_STALL_SEGMENTS:")) break; - await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); - } - if (!log.includes("TN_STALL_SEGMENTS:")) fail(`TN_PRD568_STALL_TIMEOUT:${label}`); - const stall = parseTiming(log, label); - const coldMarkers = parseMarkers(log); - const cold = breakdown(coldMarkers, SEGMENTS); - const logFile = join(output, "timing", `${label}.log`); - mkdirSync(dirname(logFile), { recursive: true }); - writeFileSync(logFile, log, "utf8"); - results.push({ label, arm: sequence[index], logFile, toFirstFrameMs: stall.toFirstFrameMs, gapMs: stall.gapMs, coldStartMs: cold.totalMs }); - console.log(`TN_PRD568_TIMING:${label}:toFirstFrameMs=${stall.toFirstFrameMs.toFixed(3)}:gapMs=${stall.gapMs.toFixed(3)}`); - } - const pairs = []; - for (let index = 0; index < 3; index += 1) { - const basis = results[index * 2]; - const astc = results[index * 2 + 1]; - if (!(astc.toFirstFrameMs <= basis.toFirstFrameMs)) - fail(`TN_PRD568_AC2_TIMING_FAIL:pair=${index + 1}:basis=${basis.toFirstFrameMs}:astc=${astc.toFirstFrameMs}`); - pairs.push({ pair: index + 1, basisMs: basis.toFirstFrameMs, astcMs: astc.toFirstFrameMs, pass: true }); - } - const result = { sequence, results, pairs, qualified: true }; - writeJson(join(output, "timing", "comparison.json"), result); - return result; -} - -function printPlan(runRoot, mode, abi) { - console.log(`TN_PRD568_PLAN_OUTPUT:${runRoot}`); - console.log(`TN_PRD568_PLAN_V8_RECEIPT:${receipt}`); - console.log(`TN_PRD568_PLAN_BUILD:compile android ASTC -> ${join(runRoot, "assets/astc")}`); - console.log(`TN_PRD568_PLAN_BUILD:compile web Basis control -> ${join(runRoot, "assets/basis")}`); - console.log(`TN_PRD568_PLAN_GRADLE:-PthreenativeAbis=${abi} -PthreenativeJsEngine=v8`); - if (mode === "all" || mode === "timing") console.log("TN_PRD568_PLAN_CURRENT_ASTC:node packages/create-threenative/dist/threenative.js build --target android --allow-source-build"); - if (mode === "all" || mode === "coverage") console.log("TN_PRD568_PLAN_COVERAGE:astc, basis positive control; expected calls 0, 6"); - if (mode === "all" || mode === "timing") console.log("TN_PRD568_PLAN_TIMING:basis, astc, basis, astc, basis, astc; compare TN_STALL_SEGMENTS.toFirstFrameMs"); -} - -async function main() { - const options = parseArgs(process.argv.slice(2)); - if (options.help) { - usage(); - return; - } - if (options.selfCheck) { - const generated = join(root, ".linchpin", "prd568-v8-ac2-selfcheck-bundle.mjs"); - try { - writeFileSync(generated, instrumentBundleSource(readFileSync(bundleSource, "utf8")), "utf8"); - command(process.execPath, ["--check", generated]); - console.log(`TN_PRD568_SELF_CHECK_OK:${generated}`); - } finally { - rmSync(generated, { force: true }); - } - return; - } - const runRoot = resolve(options.output ?? join(root, ".linchpin", "prd568-ac2-v8", new Date().toISOString().replaceAll(/[:.]/gu, "-"))); - if (options.plan) { - printPlan(runRoot, options.mode, "x86_64"); - return; - } - if (existsSync(runRoot) && !options.reuse) fail(`TN_PRD568_OUTPUT_EXISTS:${runRoot}; choose a new --output or pass --reuse`); - mkdirSync(runRoot, { recursive: true }); - const adbPath = resolveAdb(); - const device = requireDevice(adbPath, options.device); - const abi = "x86_64"; - const config = await loadProjectConfig(); - const metadata = { runRoot, mode: options.mode, device, abi, v8Receipt: options.reuse ? "reused APK" : requireV8Receipt() }; - writeJson(join(runRoot, "setup.json"), metadata); - if (!options.reuse) await generateFixture(); - const coverage = {}; - const timing = {}; - if (options.mode === "all" || options.mode === "coverage") { - const astc = options.reuse ? { apk: join(runRoot, "apk", names.astcCoverage) } : await buildArm(config, runRoot, "astc", "android", true, abi); - const basis = options.reuse ? { apk: join(runRoot, "apk", names.basisCoverage) } : await buildArm(config, runRoot, "basis", "web", true, abi); - if (!existsSync(astc.apk) || !existsSync(basis.apk)) fail(`TN_PRD568_COVERAGE_APK_MISSING:${runRoot}`); - coverage.astc = runCoverage(adbPath, device.serial, astc.apk, runRoot, "astc"); - coverage.basis = runCoverage(adbPath, device.serial, basis.apk, runRoot, "basis"); - writeJson(join(runRoot, "coverage", "result.json"), coverage); - } - if (options.mode === "all" || options.mode === "timing") { - const astc = options.reuse ? { apk: join(runRoot, "apk", names.astcTiming) } : await buildCurrentAstc(runRoot, abi); - const basis = options.reuse ? { apk: join(runRoot, "apk", names.basisTiming) } : await buildArm(config, runRoot, "basis", "web", false, abi); - if (!existsSync(astc.apk) || !existsSync(basis.apk)) fail(`TN_PRD568_TIMING_APK_MISSING:${runRoot}`); - timing.result = await runTiming(adbPath, device.serial, { astc: astc.apk, basis: basis.apk }, runRoot); - } - writeJson(join(runRoot, "result.json"), { metadata, coverage, timing }); - console.log(`TN_PRD568_READY:${runRoot}`); -} - -main().catch((error) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); From 8e7f385c64ea48f1f0f49c2ef1f3377eec33a2c9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 17:51:19 -0700 Subject: [PATCH 46/57] docs: record PRD-568 native coverage diagnosis --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 3a43769e88..eb4b4fc3bc 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -238,3 +238,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. **2026-10-13, current v4 qualification:** fresh full asset suite passes 625 tests, six skipped, 39 files passed/one skipped (exit 0, 488.05 s) on the laptop, with two workers and unchanged limits. The actual packaged checker decodes to the vertically reversed authored RGBA bytes with maximum channel error zero and all six mips. Root lint passes (exit 0; existing warnings). Root typecheck initially lacked generated terrain/procedural-animal outputs and the terrain horizon fixture; after building/baking those prerequisites, three test-typing errors in the core ASTC cases remain under repair. No full typecheck/test completion or AC-2 success is claimed. + +**2026-10-13, current source gates, native coverage and timing lifecycle:** the current candidate's fresh source gates pass with exit 0: `pnpm build`, `pnpm typecheck` and `pnpm lint`; `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` passes 23/23. Authoritative native coverage is recorded in the manager-owned `/home/joao/offload/threenative-engine/prd568/.linchpin/prd568-ac2-current-20261013-r4/coverage/result.json` and its `coverage/{astc,basis}/console.json`: ASTC reports `ready:true, armed:true, calls:0, workers:0`; Basis reports `ready:true, armed:true, calls:6, workers:1`; both original scenarios pass. This closes the coverage sub-proof only, not AC-2. The timing observer previously installed the next APK before force-stopping the running wgpu app; shipped `AdbAndroidDriver.prepare()` force-stops before launch. The ignored helper now force-stops before each install while preserving the exact `basis,astc` ×3 sequence, timers, log collection and pair assertions. `node --check` and the focused ordering check pass; helper SHA-256 is `0da694f51bde6d503d6a70347740081a3c81fdeb32d73cafc6f6c56ab14bd024`. The fresh timing attempt is not qualified: it recorded only `01-basis` at `2472.399 ms` before the emulator graphics process disappeared during ASTC installation. No timing pair is accepted; AC-2 and the final Phase 2 box remain open. The root full test remains pending on Debian. `packages/runtime-native/AGENTS.md` already states that Android V8 admits KTX2, so no AGENTS or mirror change was needed. From 5a8f30907d9f9875f278aef001680ea5ca3d2007 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:06:21 -0700 Subject: [PATCH 47/57] docs: record fresh PRD-568 visual and V8 timing evidence --- .../assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index eb4b4fc3bc..2c099aa158 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -240,3 +240,10 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, current v4 qualification:** fresh full asset suite passes 625 tests, six skipped, 39 files passed/one skipped (exit 0, 488.05 s) on the laptop, with two workers and unchanged limits. The actual packaged checker decodes to the vertically reversed authored RGBA bytes with maximum channel error zero and all six mips. Root lint passes (exit 0; existing warnings). Root typecheck initially lacked generated terrain/procedural-animal outputs and the terrain horizon fixture; after building/baking those prerequisites, three test-typing errors in the core ASTC cases remain under repair. No full typecheck/test completion or AC-2 success is claimed. **2026-10-13, current source gates, native coverage and timing lifecycle:** the current candidate's fresh source gates pass with exit 0: `pnpm build`, `pnpm typecheck` and `pnpm lint`; `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` passes 23/23. Authoritative native coverage is recorded in the manager-owned `/home/joao/offload/threenative-engine/prd568/.linchpin/prd568-ac2-current-20261013-r4/coverage/result.json` and its `coverage/{astc,basis}/console.json`: ASTC reports `ready:true, armed:true, calls:0, workers:0`; Basis reports `ready:true, armed:true, calls:6, workers:1`; both original scenarios pass. This closes the coverage sub-proof only, not AC-2. The timing observer previously installed the next APK before force-stopping the running wgpu app; shipped `AdbAndroidDriver.prepare()` force-stops before launch. The ignored helper now force-stops before each install while preserving the exact `basis,astc` ×3 sequence, timers, log collection and pair assertions. `node --check` and the focused ordering check pass; helper SHA-256 is `0da694f51bde6d503d6a70347740081a3c81fdeb32d73cafc6f6c56ab14bd024`. The fresh timing attempt is not qualified: it recorded only `01-basis` at `2472.399 ms` before the emulator graphics process disappeared during ASTC installation. No timing pair is accepted; AC-2 and the final Phase 2 box remain open. The root full test remains pending on Debian. `packages/runtime-native/AGENTS.md` already states that Android V8 admits KTX2, so no AGENTS or mirror change was needed. + + +**2026-10-13, resumed current-base audit:** merged `origin/develop` (`5f7143895`) without conflicts; candidate `5a2cd054e` passes fresh root typecheck after rebuilding core and root lint (existing warnings). The focused core asset, native KTX2 packaging, Android-console and decoder-free bundle suites pass 138 tests across four files (exit 0, 8.15 s). Full `pnpm test` on Debian fails at desktop native contracts because the V8/QuickJS host and contract executables are absent (1,549 native tests passed; 25 failed), not because those contracts were skipped. The earlier pnpm compiler-cache temporary-directory failure is avoided with its supported `DISABLE_V8_COMPILE_CACHE=1`; the unchanged orphan check then passes locally. The merged candidate's JavaScript qualification uses the same runtime-native package exclusion as CI; native qualification remains required separately. + +**2026-10-13, six fresh V8 timing launches:** the retained test APK signature mismatch was resolved inside the disposable emulator, and the ignored observer's logcat buffer was raised from Node's default 1 MB to 32 MB after an `ENOBUFS` failure. No timer, sequence or assertion changed. Idle CI containers holding the quiet lock were verified idle against GitHub, stopped for this measurement, and laptop scheduling was restored afterward. The complete uninstrumented `basis,astc` ×3 sequence on Debian's host-GPU 16 KB Android emulator yields Basis→ASTC: **1018.744→1069.397 ms (FAIL)**, **1074.511→1038.860 ms (PASS)**, **1186.618→982.085 ms (PASS)**. The observer exits 1; all six raw logs are retained locally at `.linchpin/prd568-ac2-debian-timing-r4/timing/`. AC-2 and the final Phase 2 box stay OPEN. Pair 1 differs mostly inside the first presented frame (143.459→186.717 ms), unlike the historical diagnosis; neither cause nor a performance fix is established. A bounded independent worker is inspecting the comparator and clock boundaries before further changes. + +**2026-10-13, fresh preserved-capture judge:** all four labeled images are retrievable from orphan branch `prd568-screenshots`, immutable commit `096e8fd42f8c09ecacb460371bf31c40d1a30917`. A fresh read-only `gpt-5.6-sol` judge opened every image and returned **IMPROVEMENT** for pair 1, pair 2 and overall: the standalone checker upper-left cell changes from blue to authored green; cube/torus textures, poses, geometry, framing, shadows and colours are preserved, with no pose mismatch. This independently revalidates AC-1's visual verdict; it makes no V8 timing or physical-device claim. From cdb35dfa42d35b25d45bc9f45108e8e63501c88e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:17:55 -0700 Subject: [PATCH 48/57] fix(core): reject unsupported raw ASTC at startup --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 9 +- packages/core/__tests__/assets.spec.ts | 91 +++++++++++++++ packages/core/src/assets.ts | 109 ++++++++++++++++-- 3 files changed, 196 insertions(+), 13 deletions(-) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 2c099aa158..d07f449bd4 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -229,8 +229,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. -- [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts` - Verified unit-scoped proof: public `assets.texture()` tests parse actual raw vk157/vk158 fixtures with the installed `KTX2Loader` across WebGL/WebGPU capability maps. BC-only and ETC2-only support rejects with `TN_ASSETS_ASTC_UNSUPPORTED` naming the asset URL and disposes the texture once; ASTC support retains the mip payloads. Historical RED: eight failed/five passed/69 skipped, exit 1, 1.82 s (`/tmp/prd568-core-astc-guard-red.log`); GREEN: 13 passed/69 skipped, exit 0, 855 ms (`/tmp/prd568-core-astc-guard-green.log`), with original exit attribution retained in `/tmp/prd568-core-astc-guard-8vsdw86a/actual.patch`. Current focused `-t 'ASTC|astc' --maxWorkers=1` proof passed 28 tests (54 deselected), exit 0, 7.82 s (`/tmp/prd568-core-astc-requirement-audit.log`). This completes the specified unit-test proof; it claims no unsupported-adapter native process run or visual result. AC-1 and AC-2 remain open. +- [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts -t 'refuses .*raw ASTC at startup|allows raw ASTC startup' --maxWorkers=1` followed by the full focused asset spec. + Verified startup-boundary proof: the first RED had 4 startup refusal cases fail and the supported-ASTC control pass (exit 1, 0.97 s); `compressedTextures.ready` resolved on BC-only support. GREEN has 88/88 asset tests passing (exit 0, 1.29 s), including standalone `format: astc4x4`, embedded `EXT_texture_astc`, shared-image ASTC codec, and generated-lightmap ASTC metadata. Each refusal case runs against both BC-only and ETC2-only renderer stubs and names the first logical and output path with `TN_ASSETS_ASTC_UNSUPPORTED`; the ASTC-supported control resolves. The existing raw-texture guard, custom-loader, no-manifest, Basis and no-renderer tests remain green. Core typecheck exits 0; focused Biome exits 0 with the three pre-existing complexity warnings. This is unit-scoped only: no unsupported-adapter native process or visual result is claimed. AC-1 and AC-2 remain open. - [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. - [x] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: current v4 APK build/install, two original eight-assertion emulator runs, and two matched labeled visual pairs; fresh independent verdict **IMPROVEMENT** per pair and overall, as recorded in AC-1. - [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and native zero-call coverage with positive Basis control pass; the first uninstrumented timing pair fails and the remaining two pairs are unrun. See the latest dated decision; this box remains OPEN. @@ -247,3 +247,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, six fresh V8 timing launches:** the retained test APK signature mismatch was resolved inside the disposable emulator, and the ignored observer's logcat buffer was raised from Node's default 1 MB to 32 MB after an `ENOBUFS` failure. No timer, sequence or assertion changed. Idle CI containers holding the quiet lock were verified idle against GitHub, stopped for this measurement, and laptop scheduling was restored afterward. The complete uninstrumented `basis,astc` ×3 sequence on Debian's host-GPU 16 KB Android emulator yields Basis→ASTC: **1018.744→1069.397 ms (FAIL)**, **1074.511→1038.860 ms (PASS)**, **1186.618→982.085 ms (PASS)**. The observer exits 1; all six raw logs are retained locally at `.linchpin/prd568-ac2-debian-timing-r4/timing/`. AC-2 and the final Phase 2 box stay OPEN. Pair 1 differs mostly inside the first presented frame (143.459→186.717 ms), unlike the historical diagnosis; neither cause nor a performance fix is established. A bounded independent worker is inspecting the comparator and clock boundaries before further changes. **2026-10-13, fresh preserved-capture judge:** all four labeled images are retrievable from orphan branch `prd568-screenshots`, immutable commit `096e8fd42f8c09ecacb460371bf31c40d1a30917`. A fresh read-only `gpt-5.6-sol` judge opened every image and returned **IMPROVEMENT** for pair 1, pair 2 and overall: the standalone checker upper-left cell changes from blue to authored green; cube/torus textures, poses, geometry, framing, shadows and colours are preserved, with no pose mismatch. This independently revalidates AC-1's visual verdict; it makes no V8 timing or physical-device claim. + + +**2026-10-13, independent review reopens startup support proof:** fresh read-only review finds `compressedTextures.ready` only requires any compressed-format support. A BC/ETC-only renderer can therefore finish engine startup with an ASTC manifest, then fail later in `assets.texture()`. The prior unit proof covers load-time rejection, not the required startup boundary. That Phase 2 box is reopened; the shared loader owns the repair, preserving its single capability probe and naming the first ASTC texture. AC-2 stays OPEN. The separate timing diagnosis confirms identical JS/native runtime payloads and model bytes between timing APKs; it finds no established raw-ASTC performance cause and recommends a same-path diagnostic pair with existing asset trace. + +**2026-10-13, startup refusal repair verified:** `packages/core/src/assets.ts` now uses the existing shared loader promise and `hasAstcSupport` to reject `compressedTextures.ready` for the emitted standalone `format: astc4x4`, embedded/shared `EXT_texture_astc`, and generated-lightmap ASTC manifest shapes. The error names the first logical and output path. The new BC/ETC2 startup RED reproduced 4 missing refusals; the focused assets suite is GREEN at 88/88, including the supported-ASTC control and existing custom-loader, no-manifest, Basis, no-renderer and load-time guard contracts. The Phase 2 startup box is now verified and checked; no native or visual claim is made. AC-2 remains OPEN: the six timing logs still contain one failed pair and do not establish a performance fix. diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 803238fa89..4770a4450a 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -834,6 +834,7 @@ describe("IAssetLoader compressed textures", () => { } const S3TC = { WEBGL_compressed_texture_s3tc: true }; + const ETC2 = { WEBGL_compressed_texture_etc: true }; // Actual encoder/emitter capture at e49bb087f, shared with native-ktx2.spec.ts: // separate UNORM/sRGB 8x8 RGBA [240,20,40,128] UASTC cooks, all four mip levels. @@ -1196,6 +1197,96 @@ describe("IAssetLoader compressed textures", () => { await expect(assets.compressedTextures?.ready).rejects.toThrow(/webgl2/u); }); + it.each([ + { + entry: { format: "astc4x4", output: "checker.ktx2" }, + logicalPath: "checker.png", + output: "checker.ktx2", + }, + { + entry: { + embeddedTextures: { formats: { albedo: "astc4x4" } }, + extensions: ["EXT_texture_astc"], + output: "prop.12345678.glb", + }, + logicalPath: "prop.glb", + output: "prop.12345678.glb", + }, + { + entry: { + extensions: ["EXT_texture_astc"], + output: "prop.12345678.glb", + sharedImages: [{ codec: "astc4x4", output: "shared/images/albedo.astc4x4.ktx2" }], + }, + logicalPath: "prop.glb", + output: "shared/images/albedo.astc4x4.ktx2", + }, + { + entry: { + lightmaps: [ + { + format: "astc4x4", + materialTargets: ["Floor"], + output: "room.lightmap.12345678.ktx2", + texCoord: 1, + }, + ], + output: "room.12345678.glb", + }, + logicalPath: "room.glb", + output: "room.lightmap.12345678.ktx2", + }, + ] as const)( + "refuses $logicalPath raw ASTC at startup on BC/ETC2-only renderers", + async ({ entry, logicalPath, output }) => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + manifestResponse({ + version: 1, + entries: { + [logicalPath]: entry, + "later.png": { format: "astc4x4", output: "later.ktx2" }, + }, + }), + ), + ); + for (const formats of [S3TC, ETC2]) { + const assets = createAssetLoader({ + basePath: "/assets", + renderer: webglRenderer(formats), + }); + + await expect(assets.compressedTextures?.ready).rejects.toThrow( + /TN_ASSETS_ASTC_UNSUPPORTED/u, + ); + await expect(assets.compressedTextures?.ready).rejects.toThrow(logicalPath); + await expect(assets.compressedTextures?.ready).rejects.toThrow(output); + } + }, + ); + + it("allows raw ASTC startup when the renderer supports ASTC", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + manifestResponse({ + version: 1, + entries: { "checker.png": { format: "astc4x4", output: "checker.ktx2" } }, + }), + ), + ); + const assets = createAssetLoader({ + basePath: "/assets", + renderer: { + hasFeature: (name: string) => name === "texture-compression-astc", + isWebGPURenderer: true, + }, + }); + + await expect(assets.compressedTextures?.ready).resolves.toBeUndefined(); + }); + it("should configure the shared loader from the real detected support", async () => { const assets = createAssetLoader({ basePath: "/assets", diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index 52644a1433..cbd89f81bc 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -190,21 +190,83 @@ interface ICompiledLightmap { } const MANIFEST_VERSION = 1; +const ASTC_FORMAT = "astc4x4"; function entryUsesKtx2(entry: unknown): boolean { if (!isRecord(entry)) return false; if (typeof entry.output === "string" && /\.ktx2$/iu.test(entry.output)) return true; - return ( - Array.isArray(entry.lightmaps) && - entry.lightmaps.some( - (lightmap) => - isRecord(lightmap) && - typeof lightmap.output === "string" && - /\.ktx2$/iu.test(lightmap.output), + if ( + Array.isArray(entry.extensions) && + entry.extensions.some( + (extension) => extension === "KHR_texture_basisu" || extension === "EXT_texture_astc", + ) + ) + return true; + const embeddedTextures = entry.embeddedTextures; + if ( + isRecord(embeddedTextures) && + isRecord(embeddedTextures.formats) && + Object.values(embeddedTextures.formats).some( + (format) => typeof format === "string" && format !== "none", ) + ) + return true; + return [entry.lightmaps, entry.sharedImages].some( + (outputs) => + Array.isArray(outputs) && + outputs.some( + (item) => + isRecord(item) && typeof item.output === "string" && /\.ktx2$/iu.test(item.output), + ), ); } +interface IManifestAstcAsset { + readonly logicalPath: string; + readonly output: string; +} + +function manifestOutput(entry: Record, fallback: string): string { + return typeof entry.output === "string" ? entry.output : fallback; +} + +function firstRawAstcAsset(logicalPath: string, entry: unknown): IManifestAstcAsset | undefined { + if (!isRecord(entry)) return undefined; + const output = manifestOutput(entry, logicalPath); + if (entry.format === ASTC_FORMAT) return { logicalPath, output }; + if (Array.isArray(entry.lightmaps)) { + const lightmap = entry.lightmaps.find((item) => isRecord(item) && item.format === ASTC_FORMAT); + if (isRecord(lightmap)) return { logicalPath, output: manifestOutput(lightmap, output) }; + } + if (Array.isArray(entry.sharedImages)) { + const sharedImage = entry.sharedImages.find( + (item) => isRecord(item) && item.codec === ASTC_FORMAT, + ); + if (isRecord(sharedImage)) return { logicalPath, output: manifestOutput(sharedImage, output) }; + } + const embeddedTextures = entry.embeddedTextures; + if ( + isRecord(embeddedTextures) && + isRecord(embeddedTextures.formats) && + Object.values(embeddedTextures.formats).some((format) => format === ASTC_FORMAT) + ) + return { logicalPath, output }; + if (Array.isArray(entry.extensions) && entry.extensions.includes("EXT_texture_astc")) { + return { logicalPath, output }; + } + return undefined; +} + +function firstRawAstcManifestAsset( + manifest: IAssetManifest | undefined, +): IManifestAstcAsset | undefined { + for (const [logicalPath, entry] of Object.entries(manifest?.entries ?? {})) { + const asset = firstRawAstcAsset(logicalPath, entry); + if (asset !== undefined) return asset; + } + return undefined; +} + function compiledLightmaps(entry: unknown, logicalPath: string): readonly ICompiledLightmap[] { if (!isRecord(entry) || entry.lightmaps === undefined) return []; if (!Array.isArray(entry.lightmaps)) { @@ -518,6 +580,32 @@ function hasAstcSupport(loader: IKTX2LoaderLike | undefined): boolean { ); } +function astcUnsupported(asset: IManifestAstcAsset): Error { + return new Error( + `TN_ASSETS_ASTC_UNSUPPORTED: '${asset.logicalPath}' uses raw ASTC 4x4 output '${asset.output}', but the renderer lacks ASTC texture-compression support.`, + ); +} + +async function prepareCompressedTextures( + manifest: IAssetManifest | undefined, + loader: Promise, +): Promise { + const astcAsset = firstRawAstcManifestAsset(manifest); + if (astcAsset !== undefined) { + try { + const shared = await loader; + if (!hasAstcSupport(shared)) throw astcUnsupported(astcAsset); + } catch (error) { + if (error instanceof Error && error.message.startsWith("TN_ASSETS_KTX2_UNSUPPORTED:")) { + throw astcUnsupported(astcAsset); + } + throw error; + } + return; + } + if (Object.values(manifest?.entries ?? {}).some(entryUsesKtx2)) await loader; +} + /** * Widen every quantized `POSITION` a loaded model carries back to float32. * @@ -696,10 +784,9 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad ? undefined : (() => { const loader = createKtx2Loader({ basePath, renderer: options.renderer }); - const ready = manifestOnce().then(async (manifest) => { - const requiresKtx2 = Object.values(manifest?.entries ?? {}).some(entryUsesKtx2); - if (requiresKtx2) await loader; - }); + const ready = manifestOnce().then((manifest) => + prepareCompressedTextures(manifest, loader), + ); loader.catch(() => undefined); ready.catch(() => undefined); return { loader, ready }; From daecd4c8b6ce0945b9bedb9be2f90f3a24c9081d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:23:02 -0700 Subject: [PATCH 49/57] test(create-threenative): materialize inspected model fixtures --- .../assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ packages/create-threenative/__tests__/build.spec.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index d07f449bd4..90e6e7b1a8 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -252,3 +252,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, independent review reopens startup support proof:** fresh read-only review finds `compressedTextures.ready` only requires any compressed-format support. A BC/ETC-only renderer can therefore finish engine startup with an ASTC manifest, then fail later in `assets.texture()`. The prior unit proof covers load-time rejection, not the required startup boundary. That Phase 2 box is reopened; the shared loader owns the repair, preserving its single capability probe and naming the first ASTC texture. AC-2 stays OPEN. The separate timing diagnosis confirms identical JS/native runtime payloads and model bytes between timing APKs; it finds no established raw-ASTC performance cause and recommends a same-path diagnostic pair with existing asset trace. **2026-10-13, startup refusal repair verified:** `packages/core/src/assets.ts` now uses the existing shared loader promise and `hasAstcSupport` to reject `compressedTextures.ready` for the emitted standalone `format: astc4x4`, embedded/shared `EXT_texture_astc`, and generated-lightmap ASTC manifest shapes. The error names the first logical and output path. The new BC/ETC2 startup RED reproduced 4 missing refusals; the focused assets suite is GREEN at 88/88, including the supported-ASTC control and existing custom-loader, no-manifest, Basis, no-renderer and load-time guard contracts. The Phase 2 startup box is now verified and checked; no native or visual claim is made. AC-2 remains OPEN: the six timing logs still contain one failed pair and do not establish a performance fix. + +**2026-10-13, create-threenative fixture repair verified:** the focused build test first failed at `inspectMobileModel` with `ENOENT` for `public/models/hero.12345678.glb`, before the intended `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED` assertion. The test now writes the existing minimally valid `buildFixtureGlb({ textured: false })` payload to both manifest output paths, preserving actual model inspection and all Meshopt/Draco decoder-gate assertions. The focused test passes 1/1 (69 skipped, exit 0); `pnpm --filter create-threenative typecheck` exits 0; focused Biome exits 0 with one pre-existing complexity warning. No production validator or core file changed. AC-2 remains OPEN. diff --git a/packages/create-threenative/__tests__/build.spec.ts b/packages/create-threenative/__tests__/build.spec.ts index b01f97d5be..61b5a69365 100644 --- a/packages/create-threenative/__tests__/build.spec.ts +++ b/packages/create-threenative/__tests__/build.spec.ts @@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { TraceMap, encodedMappings } from "@jridgewell/trace-mapping"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildFixtureGlb } from "../../../test-support/generate-fixture-model.js"; import { rgbaPng } from "../../../test-support/png.js"; import { makeTempDir } from "../../../test-support/temp-dir.js"; import { @@ -1338,6 +1339,10 @@ cpSync("public", out, { recursive: true }); const config = { assets: { output: "public" } } as Parameters< typeof assertNativeAssetsCompatible >[2]; + const model = await buildFixtureGlb({ textured: false }); + await mkdir(path.join(root, "public", "models"), { recursive: true }); + await writeFile(path.join(root, "public", "models/hero.12345678.glb"), model); + await writeFile(path.join(root, "public", "models/rock.87654321.glb"), model); await writeFile( manifestPath, JSON.stringify({ From 1498bafab34eb8f9f674f5837f4c2f25240e202d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:27:22 -0700 Subject: [PATCH 50/57] fix(core): remove redundant ASTC loader cast --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ packages/core/src/assets.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 90e6e7b1a8..e2bfa3bad3 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -254,3 +254,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, startup refusal repair verified:** `packages/core/src/assets.ts` now uses the existing shared loader promise and `hasAstcSupport` to reject `compressedTextures.ready` for the emitted standalone `format: astc4x4`, embedded/shared `EXT_texture_astc`, and generated-lightmap ASTC manifest shapes. The error names the first logical and output path. The new BC/ETC2 startup RED reproduced 4 missing refusals; the focused assets suite is GREEN at 88/88, including the supported-ASTC control and existing custom-loader, no-manifest, Basis, no-renderer and load-time guard contracts. The Phase 2 startup box is now verified and checked; no native or visual claim is made. AC-2 remains OPEN: the six timing logs still contain one failed pair and do not establish a performance fix. **2026-10-13, create-threenative fixture repair verified:** the focused build test first failed at `inspectMobileModel` with `ENOENT` for `public/models/hero.12345678.glb`, before the intended `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED` assertion. The test now writes the existing minimally valid `buildFixtureGlb({ textured: false })` payload to both manifest output paths, preserving actual model inspection and all Meshopt/Draco decoder-gate assertions. The focused test passes 1/1 (69 skipped, exit 0); `pnpm --filter create-threenative typecheck` exits 0; focused Biome exits 0 with one pre-existing complexity warning. No production validator or core file changed. AC-2 remains OPEN. + +**2026-10-13, core quality suppression repair verified:** the new `as unknown as` at the EXT_texture_astc `loadTextureImage` handoff was replaced with the existing `GLTFLoader.setKTX2Loader` parameter type narrowed through `NonNullable`; the inherited bridge at `assets.ts:1074` was not changed. `pnpm --silent quality --json` exits 0 with no new suppression finding, core typecheck exits 0, and `packages/core/__tests__/assets.spec.ts` passes 88/88. This is type-only and changes no runtime behavior or security validation. AC-2 remains OPEN with the strict first-surface metric; asset-readiness tracing remains diagnostic only. diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index cbd89f81bc..e5d18d3966 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -1094,7 +1094,7 @@ export function createAssetLoader(options: IAssetLoaderOptions = {}): IAssetLoad return parser.loadTextureImage( textureIndex, extension.source, - astcLoader as unknown as Parameters[2], + astcLoader as NonNullable[0]>, ); }, }; From 04ca4327a902557b13429c152d5ae379686b33de Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:35:52 -0700 Subject: [PATCH 51/57] docs: retain strict PRD-568 first-surface acceptance --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index e2bfa3bad3..19c29645b1 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -256,3 +256,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, create-threenative fixture repair verified:** the focused build test first failed at `inspectMobileModel` with `ENOENT` for `public/models/hero.12345678.glb`, before the intended `TN_NATIVE_MESH_COMPRESSION_UNSUPPORTED` assertion. The test now writes the existing minimally valid `buildFixtureGlb({ textured: false })` payload to both manifest output paths, preserving actual model inspection and all Meshopt/Draco decoder-gate assertions. The focused test passes 1/1 (69 skipped, exit 0); `pnpm --filter create-threenative typecheck` exits 0; focused Biome exits 0 with one pre-existing complexity warning. No production validator or core file changed. AC-2 remains OPEN. **2026-10-13, core quality suppression repair verified:** the new `as unknown as` at the EXT_texture_astc `loadTextureImage` handoff was replaced with the existing `GLTFLoader.setKTX2Loader` parameter type narrowed through `NonNullable`; the inherited bridge at `assets.ts:1074` was not changed. `pnpm --silent quality --json` exits 0 with no new suppression finding, core typecheck exits 0, and `packages/core/__tests__/assets.spec.ts` passes 88/88. This is type-only and changes no runtime behavior or security validation. AC-2 remains OPEN with the strict first-surface metric; asset-readiness tracing remains diagnostic only. + +**2026-10-13, owner confirms strict first-surface acceptance:** AC-2 keeps the original first empty surface boundary and requires ASTC ≤ Basis in each of three interleaved pairs. The trace-only pair (894.972→795.425 ms) shows checker texture settlement after first surface in both arms; it is diagnostic only and closes no box. No loaded-frame substitution, tolerance or aggregate comparison is authorized. The repaired scaffolder build suite passes 70/70 (exit 0, 78.07 s). After pinning the existing pnpm 10.25.0 toolchain and reinstalling dependencies, the seven previously affected bootstrap/packaging suites pass 160/160 (exit 0, 27.76 s), without relaxing their assertions. Fresh APKs from candidate `1498bafab` use the same official cook/bundle/package path in both arms; final JavaScript qualification and current-candidate Android acceptance remain in progress. From ebcc9f92414e5de097e4095300430a2b21a14747 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 18:58:05 -0700 Subject: [PATCH 52/57] docs: record current ASTC qualification failures --- .../assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 19c29645b1..f4a9ef9817 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -1,7 +1,7 @@ # PRD-568 — Mobile builds ship GPU-ready ASTC, so no phone transcodes or uploads RGBA8 **Status:** PARTIAL -**Priority:** P2 — Mobile ASTC cooking and packaging are verified; native/visual acceptance proof remains open. +**Priority:** P2 — Mobile ASTC cooking, packaging and QuickJS visual acceptance are verified; strict V8 first-surface acceptance remains open. **Complexity:** 4 (MEDIUM) — 1–5 implementation files (`packages/assets` texture pass, `create-threenative` build gate, the asset loader route) (+1); the cook output crosses into the native package (+2); one new output variant (+1); risk override: none **Owner:** João **Depends on:** [PRD-VQ-01](../../done/PRD-VQ-01-native-asset-capabilities.md) (owns runtime-aware decoder admission; this PRD adds a format that needs no decoder) @@ -258,3 +258,7 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, core quality suppression repair verified:** the new `as unknown as` at the EXT_texture_astc `loadTextureImage` handoff was replaced with the existing `GLTFLoader.setKTX2Loader` parameter type narrowed through `NonNullable`; the inherited bridge at `assets.ts:1074` was not changed. `pnpm --silent quality --json` exits 0 with no new suppression finding, core typecheck exits 0, and `packages/core/__tests__/assets.spec.ts` passes 88/88. This is type-only and changes no runtime behavior or security validation. AC-2 remains OPEN with the strict first-surface metric; asset-readiness tracing remains diagnostic only. **2026-10-13, owner confirms strict first-surface acceptance:** AC-2 keeps the original first empty surface boundary and requires ASTC ≤ Basis in each of three interleaved pairs. The trace-only pair (894.972→795.425 ms) shows checker texture settlement after first surface in both arms; it is diagnostic only and closes no box. No loaded-frame substitution, tolerance or aggregate comparison is authorized. The repaired scaffolder build suite passes 70/70 (exit 0, 78.07 s). After pinning the existing pnpm 10.25.0 toolchain and reinstalling dependencies, the seven previously affected bootstrap/packaging suites pass 160/160 (exit 0, 27.76 s), without relaxing their assertions. Fresh APKs from candidate `1498bafab` use the same official cook/bundle/package path in both arms; final JavaScript qualification and current-candidate Android acceptance remain in progress. + +**2026-10-13, final JavaScript qualification and isolated cache repair:** candidate `1498bafab` completes docs, build and package-test phases. The root unit phase reports 10,385 passed, one failed and 49 skipped across 774 passed/one failed/four skipped files (749.53 s), and the unchanged orphan check passes; overall command exits 1. The sole failure is the published creature compiler rejecting its shared extracted cache inventory. A diagnostic copy exposes `TOOLCHAIN_UNAVAILABLE`; comparison with the pinned 0.8.0 package archive finds exactly one missing file, `example/wolf.glb`, and no extras. Restoring that original 299,516-byte payload (SHA256 `f74677b776a458d7aeb0e6ec6d20ffca99c5e4a2e424f4f4b81b1418c2709baa`) repairs the environment without changing production code or assertions. The entire original scaffold MCP suite then passes 27/27 (exit 0, 10.32 s). The failed full-run status is retained rather than relabeled green; desktop native qualification and AC-2 remain pending. + +**2026-10-13, current same-path V8 qualification fails strict AC-2:** freshly rebuilt `1498bafab` timing APKs have identical JavaScript (SHA256 `d49b1ee440a6385e9aa01a338e672df5b265a0db5a1ccd3d743e3866648ea8ac`) and native libraries. After a quiet-lock-protected host-GPU emulator boot and a fixed 45-second settling interval, the complete original Basis→ASTC sequence reports **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**. Exit 1; all six raw logs are retained under `.linchpin/prd568-ac2-candidate-1498/timing/`. No timing rerun follows without a causal product change. The same session proves current ASTC always-armed zero-call coverage and original scenario assertions, but its emulator crashes during the subsequent Basis control install; that coverage attempt is incomplete. The ignored coverage helper now force-stops the prior app before installing, matching the already repaired timing lifecycle; a separate coverage-only run is pending. AC-2 stays OPEN and the PR remains draft; no merge or speed improvement is claimed. From 5bebb08e9384240c3ad47bc9f01cf14e1b45d975 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:03:32 -0700 Subject: [PATCH 53/57] docs(prd568): refresh ASTC acceptance evidence --- .../PRD-568-mobile-builds-ship-gpu-ready-astc.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index f4a9ef9817..399cea897d 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -199,11 +199,11 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- ## Acceptance Criteria - [x] AC-1 [local]: An Android QuickJS build of the VQ-01 fixture ships ASTC KTX2 instead of PNG, packages without `TN_NATIVE_KTX2_UNSUPPORTED`, and renders both authored checker colours on the emulator. proof: the existing VQ-01 Android scenario — current bottom-up v4 APK SHA256 `1fb2c7c145dc4c2a67246477adea54e4e3de6de6f66b3be576ea632b51b22ad7` packages successfully and passes all eight original assertions twice on the 16 KB Android emulator, Intel Iris Xe/Mesa Vulkan with ASTC support. `artifacts/prd568-v4-visual-pair/after-{1,2}/` retains both captures and runner observations. Fresh read-only independent judge: **IMPROVEMENT** for both matched v3-before/v4-after pairs and overall; both restore the authored green upper-left checker square, with other colours, geometry, framing and shadows preserved and no unmatched poses. Large BEFORE/AFTER labels are in `judge-labeled/`; the original PNG capture is an appearance reference only because its adapter differs. The optional supplemental raw logcat dump after run 2 stalled when the emulator disappeared; its reader was stopped, while both completed runner diagnostic observations and zero exit codes remain intact. The subsequent PNG control installation failed because the emulator had exited; it is not claimed as a completed control run. -- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` — Evidence: native always-armed ASTC zero-call coverage with a six-call Basis positive control passes; the first separately uninstrumented timing pair fails 946.213→1036.165 ms, and the remaining two pairs were not run. Completed Opus diagnosis localizes the difference before `frameBeganAtMs` without establishing its cause; a same-clock breakdown there remains missing. The separate fixed BAAB diagnostic changes direction and does not qualify acceptance. See the latest dated decisions; AC-2 stays OPEN. +- [ ] AC-2 [local]: The same fixture on Android V8 loads every texture without a transcoder call, and its first-frame load time is no worse than the Basis build in 3 interleaved launches. proof: the same runner with V8, comparing `TN_STALL_SEGMENTS` at the strict original first-empty-surface metric, with ASTC ≤ Basis required in every pair and no tolerance or aggregation — current-source `1498bafab` yields Basis→ASTC **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**; the run exits 1 and all six raw logs are retained at `.linchpin/prd568-ac2-candidate-1498/timing/`. The separate green coverage-only result is recorded below; AC-2 stays OPEN. ## Blocked on -- **AC-2 remains open.** Fresh current-candidate V8 zero-call and three interleaved first-frame comparisons are still required; the historical failed timing pair remains recorded above. +- **AC-2 remains open.** Current-source V8 zero-call coverage is green, but two of the three strict first-empty-surface timing pairs fail; no tolerance or aggregation is allowed. - **Physical-device VRAM follow-up (Pixel 8), outside the explicit Android-emulator acceptance boxes.** Resident texture bytes against PNG remain unverified; encoded texture/APK sizes do not prove VRAM savings. - **iOS JSC runtime follow-up, outside the explicit Android acceptance boxes.** The mobile cook has unit coverage, but same-path iOS execution remains unverified (`BLOCKED/requires-ios-ecossystem`). @@ -223,19 +223,19 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- - [x] A mobile-target cook writes ASTC 4×4 KTX2 with the right `vkFormat`, full mips and no supercompression; web and desktop output is byte-identical to before. proof: red-green `pnpm exec vitest run packages/assets/__tests__` Verified: historical format-conversion and missing-mobile-lightmap RED cases are recorded above; six new full-capability standalone/embedded/lightmap cases reproduce the old Basis selection and pass after the all-mobile target correction. Fresh full assets qualification passes 627 tests (two skipped), 39 files passed/one skipped, exit 0, 594.681 s elapsed / 593.87 s Vitest duration, on Node 20.19.6 with `--maxWorkers=1 --bail=1` and unchanged test limits (`/tmp/prd568-lightmap-baseline/full-assets-all-mobile-workers1.log`, `full-assets-all-mobile-workers1-terminal.json`). Existing cases verify raw ASTC formats, full mips, NONE supercompression, decoder maps including V8-equivalent capabilities, workers, cache behavior and explicit-none controls. Six original/current web/desktop fixture pairs retain exact manifests, bake receipts and output trees (22 primary and 44 receipt-owned payload matches; `/tmp/prd568-original-byte-identity-ylzadj_6/corrected/comparison-summary.json`); the latest generated-lightmap increment preserves exact web/desktop model and atlas bytes in two further comparisons (`/tmp/prd568-lightmap-baseline/byte-identity.log`). Byte identity is scoped to those fixtures/options. Upper-mip perceptual comparison, linear-lightmap/normal/data semantic certification and native/visual acceptance remain open. - [x] The packaging gate admits raw-block KTX2 on QuickJS and still refuses Basis KTX2 there. proof: red-green `pnpm exec vitest run packages/create-threenative/__tests__/native-ktx2.spec.ts` — 23 passed, exit 0 (17.99 s), including embedded/shared payload inspection and Basis refusal; actual QuickJS APK packaging/install succeeded in `artifacts/prd568-quickjs-20261011T0323Z/run.log`. - Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); the latest paired native assertions and PNG/ASTC size comparison are recorded above. AC-1 remains open for visual acceptance. + Verified packaging only: the checker cook emitted 1,696 bytes of ASTC KTX2 (vkFormat 158, 32×32, all six mips, NONE supercompression) and the 25,670,666-byte APK installed. The pre-fix startup attempt failed at `TN_ASSETS_KTX2_UNPROBED` (runner exit 2); that historical package failure is superseded by the current AC-1 APK's successful two-run visual proof and independent **IMPROVEMENT** verdict recorded above. AC-1 is verified; AC-2 remains open. #### Phase 2: Load and check on the host **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. - [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts -t 'refuses .*raw ASTC at startup|allows raw ASTC startup' --maxWorkers=1` followed by the full focused asset spec. - Verified startup-boundary proof: the first RED had 4 startup refusal cases fail and the supported-ASTC control pass (exit 1, 0.97 s); `compressedTextures.ready` resolved on BC-only support. GREEN has 88/88 asset tests passing (exit 0, 1.29 s), including standalone `format: astc4x4`, embedded `EXT_texture_astc`, shared-image ASTC codec, and generated-lightmap ASTC metadata. Each refusal case runs against both BC-only and ETC2-only renderer stubs and names the first logical and output path with `TN_ASSETS_ASTC_UNSUPPORTED`; the ASTC-supported control resolves. The existing raw-texture guard, custom-loader, no-manifest, Basis and no-renderer tests remain green. Core typecheck exits 0; focused Biome exits 0 with the three pre-existing complexity warnings. This is unit-scoped only: no unsupported-adapter native process or visual result is claimed. AC-1 and AC-2 remain open. + Verified startup-boundary proof: the first RED had 4 startup refusal cases fail and the supported-ASTC control pass (exit 1, 0.97 s); `compressedTextures.ready` resolved on BC-only support. GREEN has 88/88 asset tests passing (exit 0, 1.29 s), including standalone `format: astc4x4`, embedded `EXT_texture_astc`, shared-image ASTC codec, and generated-lightmap ASTC metadata. Each refusal case runs against both BC-only and ETC2-only renderer stubs and names the first logical and output path with `TN_ASSETS_ASTC_UNSUPPORTED`; the ASTC-supported control resolves. The existing raw-texture guard, custom-loader, no-manifest, Basis and no-renderer tests remain green. Core typecheck exits 0; focused Biome exits 0 with the three pre-existing complexity warnings. This is unit-scoped only: no unsupported-adapter native process or visual result is claimed by this box. AC-1 is verified by the current APK and accepted visual proof above; AC-2 remains open. - [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. - [x] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: current v4 APK build/install, two original eight-assertion emulator runs, and two matched labeled visual pairs; fresh independent verdict **IMPROVEMENT** per pair and overall, as recorded in AC-1. -- [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; V8 provisioning and native zero-call coverage with positive Basis control pass; the first uninstrumented timing pair fails and the remaining two pairs are unrun. See the latest dated decision; this box remains OPEN. +- [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; current-source `1498bafab` coverage is green, while the strict original first-empty-surface metric yields Basis→ASTC **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**; all six raw logs are retained at `.linchpin/prd568-ac2-candidate-1498/timing/`. No tolerance or aggregation is allowed; this box remains OPEN. -**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, PRD status owner `/root/prd568_status`, base `origin/develop` at `2d212479255cf725f64343dc72aa2eafe9b7b92d`; retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. +**Current lane:** `.worktrees/prd568`, branch `feat/prd568-mobile-astc`, coordinator `/root`, base `origin/develop` at `5f7143895` (merged develop); retained for active PR #488 implementation/review. This status update is local, with no push, merge or cleanup. **2026-10-13, current v4 qualification:** fresh full asset suite passes 625 tests, six skipped, 39 files passed/one skipped (exit 0, 488.05 s) on the laptop, with two workers and unchanged limits. The actual packaged checker decodes to the vertically reversed authored RGBA bytes with maximum channel error zero and all six mips. Root lint passes (exit 0; existing warnings). Root typecheck initially lacked generated terrain/procedural-animal outputs and the terrain horizon fixture; after building/baking those prerequisites, three test-typing errors in the core ASTC cases remain under repair. No full typecheck/test completion or AC-2 success is claimed. @@ -262,3 +262,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, final JavaScript qualification and isolated cache repair:** candidate `1498bafab` completes docs, build and package-test phases. The root unit phase reports 10,385 passed, one failed and 49 skipped across 774 passed/one failed/four skipped files (749.53 s), and the unchanged orphan check passes; overall command exits 1. The sole failure is the published creature compiler rejecting its shared extracted cache inventory. A diagnostic copy exposes `TOOLCHAIN_UNAVAILABLE`; comparison with the pinned 0.8.0 package archive finds exactly one missing file, `example/wolf.glb`, and no extras. Restoring that original 299,516-byte payload (SHA256 `f74677b776a458d7aeb0e6ec6d20ffca99c5e4a2e424f4f4b81b1418c2709baa`) repairs the environment without changing production code or assertions. The entire original scaffold MCP suite then passes 27/27 (exit 0, 10.32 s). The failed full-run status is retained rather than relabeled green; desktop native qualification and AC-2 remain pending. **2026-10-13, current same-path V8 qualification fails strict AC-2:** freshly rebuilt `1498bafab` timing APKs have identical JavaScript (SHA256 `d49b1ee440a6385e9aa01a338e672df5b265a0db5a1ccd3d743e3866648ea8ac`) and native libraries. After a quiet-lock-protected host-GPU emulator boot and a fixed 45-second settling interval, the complete original Basis→ASTC sequence reports **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**. Exit 1; all six raw logs are retained under `.linchpin/prd568-ac2-candidate-1498/timing/`. No timing rerun follows without a causal product change. The same session proves current ASTC always-armed zero-call coverage and original scenario assertions, but its emulator crashes during the subsequent Basis control install; that coverage attempt is incomplete. The ignored coverage helper now force-stops the prior app before installing, matching the already repaired timing lifecycle; a separate coverage-only run is pending. AC-2 stays OPEN and the PR remains draft; no merge or speed improvement is claimed. + +**2026-10-13, current-source coverage-only qualification:** the same APK source `1498bafab` now has a green coverage-only result: ASTC `ready:true, armed:true, calls:0, workers:0`; Basis `ready:true, armed:true, calls:6, workers:1`; both original scenarios pass. Evidence is retained in `.linchpin/prd568-ac2-candidate1498-coverage/coverage/result.json`, `.linchpin/prd568-ac2-candidate1498-coverage/coverage/astc/console.json`, and `.linchpin/prd568-ac2-candidate1498-coverage/coverage/basis/console.json`. Offload job `prd568-pr488-coverage1498-stop-before-install` exits 0 and the emulator is stopped. This supersedes the incomplete coverage attempt above without deleting it; it closes only the zero-call coverage sub-proof, while AC-2 and the strict first-empty-surface timing proof remain OPEN. From 297065bf95b9e2e76efb16d39ec73d78aa043dd2 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:09:37 -0700 Subject: [PATCH 54/57] fix(core): allow optional pass-through ASTC fallback startup --- ...D-568-mobile-builds-ship-gpu-ready-astc.md | 6 ++-- packages/core/__tests__/assets.spec.ts | 31 +++++++++++++++++++ packages/core/src/assets.ts | 3 -- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 399cea897d..0eda1e2e9d 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -229,8 +229,8 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **Status:** PARTIAL **Files:** `packages/runtime-native/scripts/bundle.mjs` (generates the decoder-free raw KTX2 route in `native-bundle.mjs`), `packages/core/src/assets.ts` (installed `KTX2Loader` raw texture path and ASTC adapter guard), `packages/core/__tests__/` **Verification:** AC-1 and AC-2 close this phase. -- [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts -t 'refuses .*raw ASTC at startup|allows raw ASTC startup' --maxWorkers=1` followed by the full focused asset spec. - Verified startup-boundary proof: the first RED had 4 startup refusal cases fail and the supported-ASTC control pass (exit 1, 0.97 s); `compressedTextures.ready` resolved on BC-only support. GREEN has 88/88 asset tests passing (exit 0, 1.29 s), including standalone `format: astc4x4`, embedded `EXT_texture_astc`, shared-image ASTC codec, and generated-lightmap ASTC metadata. Each refusal case runs against both BC-only and ETC2-only renderer stubs and names the first logical and output path with `TN_ASSETS_ASTC_UNSUPPORTED`; the ASTC-supported control resolves. The existing raw-texture guard, custom-loader, no-manifest, Basis and no-renderer tests remain green. Core typecheck exits 0; focused Biome exits 0 with the three pre-existing complexity warnings. This is unit-scoped only: no unsupported-adapter native process or visual result is claimed by this box. AC-1 is verified by the current APK and accepted visual proof above; AC-2 remains open. +- [x] A package with ASTC on an adapter without `texture-compression-astc` fails at startup with a named error. proof: red-green `pnpm exec vitest run packages/core/__tests__/assets.spec.ts -t 'allows optional pass-through ASTC startup' --maxWorkers=1`, the existing startup-boundary focus, and the full focused asset spec. + Verified startup-boundary proof: the first RED had 4 startup refusal cases fail and the supported-ASTC control pass (exit 1, 0.97 s); `compressedTextures.ready` resolved on BC-only support. GREEN has 89/89 asset tests passing (exit 0, 1.96 s), including standalone `format: astc4x4`, embedded `EXT_texture_astc`, shared-image ASTC codec, generated-lightmap ASTC metadata, and the pass-through optional-extension control. Each refusal case runs against both BC-only and ETC2-only renderer stubs and names the first logical and output path with `TN_ASSETS_ASTC_UNSUPPORTED`; the ASTC-supported and optional-fallback startup controls resolve. The existing raw-texture guard, custom-loader, no-manifest, Basis and no-renderer tests remain green. Core typecheck exits 0; focused Biome exits 0 with the three pre-existing complexity warnings. This is unit-scoped only: no unsupported-adapter native process or visual result is claimed by this box. AC-1 is verified by the current APK and accepted visual proof above; AC-2 remains open. - [x] The texture bytes and APK size of the ASTC build against the PNG build are recorded here. proof: the AC-1 runner's artifact listing — matched current-candidate QuickJS/x86_64 builds in `artifacts/prd568-quickjs-byte-pair-20261011T071109Z/pair-result.json`: ASTC/PNG APKs 25,678,858/25,674,761 bytes; encoded checkers 1,696/187 bytes, respectively. ASTC increases APK size by 4,097 bytes and encoded texture size by 1,509 bytes; no VRAM or visual-quality claim. - [x] Complete AC-1's Android QuickJS ASTC packaging and both-authored-colour proof, including the required fresh visual judge. proof: current v4 APK build/install, two original eight-assertion emulator runs, and two matched labeled visual pairs; fresh independent verdict **IMPROVEMENT** per pair and overall, as recorded in AC-1. - [ ] Complete AC-2's Android V8 zero-transcoder-call proof and first-frame comparison against Basis in three interleaved launches. proof: the existing AC-2 runner and `TN_STALL_SEGMENTS`; current-source `1498bafab` coverage is green, while the strict original first-empty-surface metric yields Basis→ASTC **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**; all six raw logs are retained at `.linchpin/prd568-ac2-candidate-1498/timing/`. No tolerance or aggregation is allowed; this box remains OPEN. @@ -264,3 +264,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, current same-path V8 qualification fails strict AC-2:** freshly rebuilt `1498bafab` timing APKs have identical JavaScript (SHA256 `d49b1ee440a6385e9aa01a338e672df5b265a0db5a1ccd3d743e3866648ea8ac`) and native libraries. After a quiet-lock-protected host-GPU emulator boot and a fixed 45-second settling interval, the complete original Basis→ASTC sequence reports **886.242→769.891 ms (PASS), 770.560→795.287 ms (FAIL), 784.477→791.116 ms (FAIL)**. Exit 1; all six raw logs are retained under `.linchpin/prd568-ac2-candidate-1498/timing/`. No timing rerun follows without a causal product change. The same session proves current ASTC always-armed zero-call coverage and original scenario assertions, but its emulator crashes during the subsequent Basis control install; that coverage attempt is incomplete. The ignored coverage helper now force-stops the prior app before installing, matching the already repaired timing lifecycle; a separate coverage-only run is pending. AC-2 stays OPEN and the PR remains draft; no merge or speed improvement is claimed. **2026-10-13, current-source coverage-only qualification:** the same APK source `1498bafab` now has a green coverage-only result: ASTC `ready:true, armed:true, calls:0, workers:0`; Basis `ready:true, armed:true, calls:6, workers:1`; both original scenarios pass. Evidence is retained in `.linchpin/prd568-ac2-candidate1498-coverage/coverage/result.json`, `.linchpin/prd568-ac2-candidate1498-coverage/coverage/astc/console.json`, and `.linchpin/prd568-ac2-candidate1498-coverage/coverage/basis/console.json`. Offload job `prd568-pr488-coverage1498-stop-before-install` exits 0 and the emulator is stopped. This supersedes the incomplete coverage attempt above without deleting it; it closes only the zero-call coverage sub-proof, while AC-2 and the strict first-empty-surface timing proof remain OPEN. + +**2026-10-13, round-2 optional-extension startup repair:** the finding is real: `packages/assets/src/compile.ts:2026` records pass-through GLB `extensionsUsed` in the manifest entry's `extensions`, so that field does not prove raw ASTC output or `extensionsRequired`. The core fix removes only that false-positive startup classification; exact `format: astc4x4`, shared-image `codec: astc4x4`, embedded `formats`, and lightmap metadata still produce the named `TN_ASSETS_ASTC_UNSUPPORTED` rejection. The new emitted/pass-through-shaped manifest control (`kind: model`, hashed `.glb` output, `passes: []`, `extensions: ["EXT_texture_astc"]`) first failed 1/1 (88 skipped, exit 1, 1.51 s) and then passed 1/1 (88 skipped, exit 0, 1.62 s) for both BC-only and ETC2-only renderer stubs; the full assets spec passes 89/89 (exit 0, 1.96 s). Core typecheck exits 0 and focused Biome exits 0 with the same three pre-existing complexity warnings. No runtime-native, Android, timing rerun, or visual proof was run; current V8 timing remains FAIL in 2/3 strict pairs and coverage remains ASTC 0 calls / Basis 6 calls, so AC-2 stays OPEN. diff --git a/packages/core/__tests__/assets.spec.ts b/packages/core/__tests__/assets.spec.ts index 4770a4450a..4af15ce72a 100644 --- a/packages/core/__tests__/assets.spec.ts +++ b/packages/core/__tests__/assets.spec.ts @@ -1266,6 +1266,37 @@ describe("IAssetLoader compressed textures", () => { }, ); + it("allows optional pass-through ASTC startup with a PNG fallback on BC/ETC2 renderers", async () => { + // This is the compiler's pass-through model entry: `extensions` preserves extensionsUsed, + // while no cooked ASTC format is recorded in the manifest. + vi.stubGlobal( + "fetch", + vi.fn(async () => + manifestResponse({ + version: 1, + entries: { + "optional.glb": { + bytes: 512, + extensions: ["EXT_texture_astc"], + kind: "model", + output: "optional.12345678.glb", + passes: [], + }, + }, + }), + ), + ); + + for (const formats of [S3TC, ETC2]) { + const assets = createAssetLoader({ + basePath: "/assets", + renderer: webglRenderer(formats), + }); + + await expect(assets.compressedTextures?.ready).resolves.toBeUndefined(); + } + }); + it("allows raw ASTC startup when the renderer supports ASTC", async () => { vi.stubGlobal( "fetch", diff --git a/packages/core/src/assets.ts b/packages/core/src/assets.ts index e5d18d3966..f22fbc1471 100644 --- a/packages/core/src/assets.ts +++ b/packages/core/src/assets.ts @@ -251,9 +251,6 @@ function firstRawAstcAsset(logicalPath: string, entry: unknown): IManifestAstcAs Object.values(embeddedTextures.formats).some((format) => format === ASTC_FORMAT) ) return { logicalPath, output }; - if (Array.isArray(entry.extensions) && entry.extensions.includes("EXT_texture_astc")) { - return { logicalPath, output }; - } return undefined; } From ff3e20b0b704e58f270750b237c314f724b39822 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:14:54 -0700 Subject: [PATCH 55/57] docs: record final ASTC repair game coverage --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 0eda1e2e9d..1eac1e06a9 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -266,3 +266,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, current-source coverage-only qualification:** the same APK source `1498bafab` now has a green coverage-only result: ASTC `ready:true, armed:true, calls:0, workers:0`; Basis `ready:true, armed:true, calls:6, workers:1`; both original scenarios pass. Evidence is retained in `.linchpin/prd568-ac2-candidate1498-coverage/coverage/result.json`, `.linchpin/prd568-ac2-candidate1498-coverage/coverage/astc/console.json`, and `.linchpin/prd568-ac2-candidate1498-coverage/coverage/basis/console.json`. Offload job `prd568-pr488-coverage1498-stop-before-install` exits 0 and the emulator is stopped. This supersedes the incomplete coverage attempt above without deleting it; it closes only the zero-call coverage sub-proof, while AC-2 and the strict first-empty-surface timing proof remain OPEN. **2026-10-13, round-2 optional-extension startup repair:** the finding is real: `packages/assets/src/compile.ts:2026` records pass-through GLB `extensionsUsed` in the manifest entry's `extensions`, so that field does not prove raw ASTC output or `extensionsRequired`. The core fix removes only that false-positive startup classification; exact `format: astc4x4`, shared-image `codec: astc4x4`, embedded `formats`, and lightmap metadata still produce the named `TN_ASSETS_ASTC_UNSUPPORTED` rejection. The new emitted/pass-through-shaped manifest control (`kind: model`, hashed `.glb` output, `passes: []`, `extensions: ["EXT_texture_astc"]`) first failed 1/1 (88 skipped, exit 1, 1.51 s) and then passed 1/1 (88 skipped, exit 0, 1.62 s) for both BC-only and ETC2-only renderer stubs; the full assets spec passes 89/89 (exit 0, 1.96 s). Core typecheck exits 0 and focused Biome exits 0 with the same three pre-existing complexity warnings. No runtime-native, Android, timing rerun, or visual proof was run; current V8 timing remains FAIL in 2/3 strict pairs and coverage remains ASTC 0 calls / Basis 6 calls, so AC-2 stays OPEN. + +**2026-10-13, final optional-ASTC repair qualification:** manager independently reruns the core assets suite at `297065bf9`: 89/89 pass (exit 0, 1.10 s); core build/publint, root typecheck and root lint exit 0 (lint retains warnings). Fresh same-path V8 APKs from this source pass 16 KB alignment and both original game scenarios. Always-armed coverage at `.linchpin/prd568-ac2-candidate-2970/coverage/result.json` reports ASTC `ready:true, armed:true, calls:0, workers:0` and Basis `ready:true, armed:true, calls:6, workers:1`; job `prd568-pr488-coverage2970-final` exits 0 and the owned emulator is stopped. Round 2 identified the optional-extension false rejection and stale live PRD text; both have focused verified repairs. No third review is claimed. The preceding uninstrumented strict timing sequence still fails two pairs; no timing rerun follows this admission-only repair without a causal performance change. AC-2, full native qualification and merge remain open. From 5e52ea73416229111172a1c034138907dfa097c0 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 19:29:21 -0700 Subject: [PATCH 56/57] docs: attribute first-surface delay before texture loading --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 1eac1e06a9..5ba282eb8a 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -268,3 +268,5 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, round-2 optional-extension startup repair:** the finding is real: `packages/assets/src/compile.ts:2026` records pass-through GLB `extensionsUsed` in the manifest entry's `extensions`, so that field does not prove raw ASTC output or `extensionsRequired`. The core fix removes only that false-positive startup classification; exact `format: astc4x4`, shared-image `codec: astc4x4`, embedded `formats`, and lightmap metadata still produce the named `TN_ASSETS_ASTC_UNSUPPORTED` rejection. The new emitted/pass-through-shaped manifest control (`kind: model`, hashed `.glb` output, `passes: []`, `extensions: ["EXT_texture_astc"]`) first failed 1/1 (88 skipped, exit 1, 1.51 s) and then passed 1/1 (88 skipped, exit 0, 1.62 s) for both BC-only and ETC2-only renderer stubs; the full assets spec passes 89/89 (exit 0, 1.96 s). Core typecheck exits 0 and focused Biome exits 0 with the same three pre-existing complexity warnings. No runtime-native, Android, timing rerun, or visual proof was run; current V8 timing remains FAIL in 2/3 strict pairs and coverage remains ASTC 0 calls / Basis 6 calls, so AC-2 stays OPEN. **2026-10-13, final optional-ASTC repair qualification:** manager independently reruns the core assets suite at `297065bf9`: 89/89 pass (exit 0, 1.10 s); core build/publint, root typecheck and root lint exit 0 (lint retains warnings). Fresh same-path V8 APKs from this source pass 16 KB alignment and both original game scenarios. Always-armed coverage at `.linchpin/prd568-ac2-candidate-2970/coverage/result.json` reports ASTC `ready:true, armed:true, calls:0, workers:0` and Basis `ready:true, armed:true, calls:6, workers:1`; job `prd568-pr488-coverage2970-final` exits 0 and the owned emulator is stopped. Round 2 identified the optional-extension false rejection and stale live PRD text; both have focused verified repairs. No third review is claimed. The preceding uninstrumented strict timing sequence still fails two pairs; no timing rerun follows this admission-only repair without a causal performance change. AC-2, full native qualification and merge remain open. + +**2026-10-13, bounded first-surface attribution and prepared physical diagnostic:** retained pair 2 logs bracket SDL-window creation at approximately 520/526 ms and surface configuration at 45/62 ms (Basis/ASTC), before game execution. Native libraries and the V8 snapshot have identical bytes and APK offsets; game JavaScript is identical but its APK offset differs. The existing asset trace starts before candidate resolution/loading; its durations and log timestamps place checker-operation start approximately 45.4 ms (Basis) and 16.7 ms (ASTC) after first surface (about 1 ms timestamp precision). Checker decoding therefore cannot explain that earlier surface difference; no ASTC-specific performance fix is established. Four current-source `ff3e20b0b` ARM64 APKs (instrumented coverage and uninstrumented timing for both arms) are prepared under `.linchpin/prd568-ac2-candidate-arm64-ff3e/apk/`, with 16 KB alignment verified. A physical-device diagnostic is pending Pixel 8 connection and an exclusive five-minute window; no physical run or acceptance pass is claimed. It will retain the original metric and one fixed three-pair sequence, preserving all emulator failures. From ed1aa3ebce16527987fddd1188fbe4fa833286a3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 13 Oct 2026 20:20:28 -0700 Subject: [PATCH 57/57] docs(prd568): retain failed physical first-surface check --- .../Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md index 5ba282eb8a..d4408159cb 100644 --- a/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md +++ b/docs/PRDs/Unreal/assets/PRD-568-mobile-builds-ship-gpu-ready-astc.md @@ -270,3 +270,6 @@ A done PRD made the opposite trade on purpose. [PRD-349](../../done/PRD-349-the- **2026-10-13, final optional-ASTC repair qualification:** manager independently reruns the core assets suite at `297065bf9`: 89/89 pass (exit 0, 1.10 s); core build/publint, root typecheck and root lint exit 0 (lint retains warnings). Fresh same-path V8 APKs from this source pass 16 KB alignment and both original game scenarios. Always-armed coverage at `.linchpin/prd568-ac2-candidate-2970/coverage/result.json` reports ASTC `ready:true, armed:true, calls:0, workers:0` and Basis `ready:true, armed:true, calls:6, workers:1`; job `prd568-pr488-coverage2970-final` exits 0 and the owned emulator is stopped. Round 2 identified the optional-extension false rejection and stale live PRD text; both have focused verified repairs. No third review is claimed. The preceding uninstrumented strict timing sequence still fails two pairs; no timing rerun follows this admission-only repair without a causal performance change. AC-2, full native qualification and merge remain open. **2026-10-13, bounded first-surface attribution and prepared physical diagnostic:** retained pair 2 logs bracket SDL-window creation at approximately 520/526 ms and surface configuration at 45/62 ms (Basis/ASTC), before game execution. Native libraries and the V8 snapshot have identical bytes and APK offsets; game JavaScript is identical but its APK offset differs. The existing asset trace starts before candidate resolution/loading; its durations and log timestamps place checker-operation start approximately 45.4 ms (Basis) and 16.7 ms (ASTC) after first surface (about 1 ms timestamp precision). Checker decoding therefore cannot explain that earlier surface difference; no ASTC-specific performance fix is established. Four current-source `ff3e20b0b` ARM64 APKs (instrumented coverage and uninstrumented timing for both arms) are prepared under `.linchpin/prd568-ac2-candidate-arm64-ff3e/apk/`, with 16 KB alignment verified. A physical-device diagnostic is pending Pixel 8 connection and an exclusive five-minute window; no physical run or acceptance pass is claimed. It will retain the original metric and one fixed three-pair sequence, preserving all emulator failures. + + +**2026-10-13, fixed Pixel 8 retry fails strict AC-2:** user-requested retry ran the prepared ARM64 V8 APKs once in the original Basis→ASTC order, three pairs, on Pixel 8 `37251FDJH0037Z` (Mali-G715). First-empty-surface times: **857.305→895.424 ms (FAIL), 901.100→847.573 ms (PASS), 867.382→824.077 ms (PASS)**; timing exits 1 and all six raw logs remain in `.linchpin/prd568-ac2-candidate-arm64-ff3e/timing/`. Doctor observed thermal status 0, battery 31.4 °C and skin 31.9 °C; the phone was USB charging at 43% battery, so this is a confounded physical diagnostic, not a power or clean thermal qualification claim. The separate coverage run exits 0: both original eight-assertion scenarios pass, ASTC always-armed counters show 0 calls/0 workers, and Basis shows 6 calls/1 worker; receipt: `.linchpin/prd568-ac2-candidate-arm64-ff3e/coverage/result.json`. The first failed pair begins its first frame 42.358 ms later with ASTC, while its frame gap is 4.240 ms shorter; the observation does not establish an ASTC decoding cause. Full CI/native qualification passed at `5e52ea734` in run `38501630341`. AC-2 remains OPEN; no unchanged timing rerun, acceptance relaxation, speed claim or merge follows.