Skip to content

feat(assets): consolidate native capabilities and high-quality cook - #396

Merged
jonit-dev merged 43 commits into
developfrom
fix/vq01-native-asset-capabilities
Oct 4, 2026
Merged

jonit-dev merged 43 commits into
developfrom
fix/vq01-native-asset-capabilities

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Consolidates native asset capability work (#396 / VQ-01) and the high-quality default asset cook (#425 / PRD-485) into one coherent CI board. Exact published source heads are preserved through ordinary merge ancestry; decoder capability/cache identity handling and source-unit/static-only quantization/reordering safeguards remain intact.

Validation: independent source review, 140 focused asset cook/decoder/clustered-mesh/native-proof CPU tests across nine suites, whole-workspace typecheck, declared JavaScript builds, normal lint/docs/instruction/scaffold drift checks, and unchanged repository budgets. Exact-head hosted CI remains required after publication.

Original acceptance is preserved: the recorded native GPU lifecycle growth and remaining Android/cook/game scenarios are unresolved. This consolidation does not certify those criteria or change their checkboxes. Original owner checkouts were untouched; the clean latest #425 head was recorded in a stable task-owned manifest. Source branches remain, and source PR closure requires published ancestry verification and a cross-link.

Original #396 scope and checklist — `4949c2530cc3df939e75d2f8545af24699bea5df`

Actual packaged native screenshot

Packaged Linux QuickJS fallback: Meshopt-authored cube, Draco-authored torus and PNG panel

Original 960×640 native framebuffer from successful run 36998104447, source 46a759ad885cede29e0fbb2c719ed75151c5baec. The Meshopt/Draco sources were normalized by the ordinary decoder-free cook before execution. This does not claim compressed-codec support in QuickJS.

The native mailbox captured the actual packaged executable via device.screenshot on named Mesa llvmpipe (LLVM 20.1.2, 128 bits). Eight live assertions passed, startup reached ready with compilation settled, strict native host-console checks passed and report diagnostics are empty. The PNG was visually inspected, preserved unchanged, and its immutable public URL verified HTTP 200 with SHA-256 1ad43cda8e816431b0ab9993066b087c9283351f304ade2c1765eebf8e349c81 (71,626 bytes).

Proof scope and provenance · Machine-readable provenance · Unchanged runner summary

Implementation

  • Resolve/hash the exact desktop packaging runtime before cooking; unknown/failed probes take the decoder-free path.
  • Carry the same decision through cooking, asset/bundle guards and native-backend bundling. Desktop QuickJS/unknown engines omit decoder modules; existing desktop V8 behavior is preserved.
  • Refuse unavailable standalone, embedded and shared KTX2 plus Meshopt/Draco declarations with actionable diagnostics.
  • Include selected runtime identity and decoder capabilities in cook keys.
  • Exercise actual compressed source fixtures, decoded positions/images/animation, exact runtime prefix in the packaged executable, and authored-KTX2 refusal preserving the previous package byte-for-byte.

Verification

  • Hosted normal pull_request lane built the actual ARM64 QuickJS/wgpu host through the repository's locked provisioner, built the workspace, packaged the fixture and passed native capture qualification.
  • Local focused capability/fixture/capture suites: 34/34. CI structure/needs: 141/141. Adjacent Draco/native-KTX2: 11/11. Fixture/verifier types and actual decoder-free bundle passed.
  • Fail-closed regressions cover unknown capabilities/cache/bundling, transparent RGB, real native bridge preflight, missing readiness, empty console and GPU/JS/loader errors even if labelled log.
  • Earlier bounded repair checks: build suite 29/29; template-native/consumer CLI 16/16; live desktop caller checks 3/3; assets/CLI builds and scoped types passed.
  • Documentation links and the direct evidence-budget checker passed. Prose tests: 29/31; two evidence-budget CLI-spawn cases fail at tsx socket EPERM and reproduce with unchanged baseline source.

Still partial, remains draft

The screenshot request is satisfied for the bounded Linux QuickJS decoder-free fallback. Full VQ-01 remains open: Android/iOS selected artifact/cohort metadata, actual decoder versions and block formats, manifest-versus-payload checks, native compressed-codec admission, peak load memory and full repeated-scene lifecycle.

The diagnostics assertion family is web-only. The native fixture uses supported startup/resource assertions and mandatory verifier checks of complete native host-console evidence, readiness, native adapter/capture provenance and opaque specimen pixels. No engine target guards changed. This is hosted software-rendering correctness, not hardware performance.

Root tests remain locally blocked by tsx IPC EPERM; root typecheck previously exited 137; two broader Android consumer checks require missing jar and reproduce unchanged on baseline. Main full CI is draft-skipped. No aggregate board green or PRD completion is claimed. The raw summary's decodedGeometryBytes counts all decoded glTF accessors including animation; it is CPU accessor bytes, not measured GPU allocation or a claimed compression saving.

Canonical checklist (prd:0%)

  • Implement capability resolution from the selected artifact, including unknown-artifact handling and the full target/engine table. proof: pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts.
  • Wire the same capability record into cooking and compatibility validation; preserve decoder-free rollback. proof: pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts.
  • Make bundling and loading retain exactly the declared decoders and invalidate stale cook-cache entries. proof: pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts.
  • Create separate tiny textured/animated fixtures for KTX2, Meshopt and Draco; assert decoded texture content and geometry rather than only a successful import. proof: pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts packages/create-threenative/__tests__/vq-native-fixture.spec.ts.
  • The unchanged web fixture renders each codec correctly, with named adapter and no loader/GPU diagnostics. proof: node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --url ${VQ_URL:?} --browser-recipe webgpu.
  • The packaged Android V8 fixture decodes the admitted codecs on an emulator; unsupported codecs remain explicitly refused and the QuickJS decoder-free fixture still runs. proof: node packages/playtest/dist/runner/cli.js examples/abyss-framework/playtests/vq-native-asset-capabilities.playtest.json --target android --device ${VQ_ANDROID_DEVICE:?}.
  • All admitted codecs are demonstrated through the packaged loader, not merely through WASM availability; all refused combinations retain actionable build errors. proof: the completed Phase 3 scenario outputs, with the named fixture, package/cohort and adapter recorded inline here.
  • The feature preserves its documented inactive/fallback path and releases owned resources after repeated lifecycle transitions. proof: pnpm exec vitest run packages/create-threenative/__tests__/vq-native-asset-capabilities.spec.ts plus the Phase 3 lifecycle scenario.

Canonical PRD: docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md. One implementation PR for this PRD, from planning #391. Keep draft until remaining qualification and required checks are satisfied.

Original #425 scope and checklist — `7876e4b7bc0caf853650965ed3b0513deb877506`

TL;DR — runbook row B2 (PRD-485): Fab/Megascans-quality assets go through the default cook instead of around it. Phase 1 box 1 is in: an embedded EXT_texture_webp image now cooks like PNG — to KTX2 on a decoding target, to a capped PNG on the decoder-free path — and the extension leaves the output. That removes the WebP reason lumen-hall gave for models: "none".

PRD: docs/PRDs/open-world/PRD-485-high-quality-assets-go-through-the-cook.md.

Done

  • decodeImageBytes decodes WebP with sharp (already declared in packages/assets, previously unused; loads in the pass worker, stays external in dist, no new binary for games — @gltf-transform/cli already brings it). EXT_texture_webp disposed once no WebP image remains. Spec red → green 3/3.

Missing

  • lumen-hall web build without models: "none" + its asset scenario (sandbox repo, GPU run).
  • Phase 2 (Fab Hornbeam default cook, AutoLOD chain ≤10%), Phase 3 (Android V8 after VQ-01, metahuman-lab, docs).
  • 🎨👁 pnpm visuals:ab --raters 3 once a template-visible change lands.

Evidence

  • packages/assets 474 passed / 2 skipped; pnpm typecheck, pnpm lint, assets build + publint green.

Partial implementation. Preserve decoder-free fallback and existing desktop V8 behavior; no newly admitted codec. Real runtime screenshot and full native loader qualification remain blocked.
@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Oct 2, 2026 — with ChatGPT Codex Connector
Exercise real selected runtime, decoder-free normalization, bundled bytes, native mailbox screenshots and named adapter provenance. Prepared capture lane; no unrun native PASS claimed.
The actual QuickJS bridge does not expose runtime.diagnostics. Keep native readiness and console errors, require complete real host logs, and fail closed on GPU/device-loss/JavaScript/loader failures even when classified as log. Engine guards unchanged.
Exercise actual bridge target admission locally. Only native-supported startup/resource families belong in the scenario. Native console cleanliness, ready/compile-settled state and device screenshot provenance remain required in the verifier. No engine target guard changed.
Preserve the original native framebuffer and runner summary from successful run 36998104447. Record exact source/runtime/package/adapter/image identities, verified digests and bounded decoder-free fallback scope. Full VQ01/mobile/codec acceptance remains open.
jonit-dev and others added 9 commits October 2, 2026 07:06
# Conflicts:
#	docs/PRDs/batch-2026-10-01-visual-quality/PRD-VQ-01-native-asset-capabilities.md
…flow, per AGENTS.md)

The `native-assets` lane joins the shared `paths` gate as an anchored ERE
translated from the old workflow's `paths:` globs, and `packaged-quickjs` keeps
its steps, env, timeout and artifact names on the same ARM64 QuickJS lane behind
`needs: paths`. Editing integration.yml keeps the lane's gate true, exactly as
editing integration-native-assets.yml used to. The PRD record now names the lane
instead of the deleted file.
Pure biome formatting of docs/verification/vq01/quickjs-native-summary.json,
committed on this branch in f13f1ad and untouched since; every value is
identical.
Develop moved the PRDs into contextual folders (#419) and rebuilt the
integration lane selector around scripts/ci-integration-scope.mjs.

Conflicts resolved:

- docs/PRDs/assets/PRD-VQ-01-native-asset-capabilities.md: kept this
  branch's completion record and hosted evidence. Develop's sibling
  PRDs at the new paths drop the deleted batch EXECUTE.md link, so the
  surviving sentence uses the same plain-text form.
- .github/workflows/integration.yml: took develop's TN_LANE_FILTERS
  selector and its capture/fluid jobs, then re-applied this branch's
  native-assets lane: the paths output, its source filter (without the
  now-redundant self-reference on the workflow, which develop's
  selector derives from job diffs), and the packaged-quickjs job. That
  job had lost its steps when the standalone lane was folded in, so the
  steps come back from 8d6cd88.
…he merge

Each Phase 1 and Phase 2 box names a unit proof command. Re-ran them at
the merge commit c29a210: the capability suite passes 19/19 and the
capability plus fixture pair passes 21/21. The results sit beside each
box; the existing partial notes stay, so the open Android artifact
resolution, decoder versions, KTX2 block-format validation and
manifest-versus-payload work are still recorded as open. Phase 3 and the
acceptance boxes are untouched — their proofs are the browser and
Android scenarios, which did not run.

Also repoint the verification README at the PRD's new assets/ path,
which is what pnpm check:docs flagged after develop's folder move.
…lter

The re-applied native-assets filter still claimed all of
packages/assets/src, which develop's world-capture lane now owns.
scripts/__tests__/ci-integration-scope.spec.ts proves a
packages/assets/src/index.ts change selects world-capture alone, and it
failed on the overlap.

Narrow the claim to the two modules this lane's proof actually depends
on, compile.ts and report.ts, the decoder-free cook and the build report.
Selection checked against the real parser: build.ts and compile.ts select
native-assets, index.ts still selects world-capture alone.
…fixture

The root tsconfig mapped the bare `@threenative/core` specifier to
`packages/core/src` and left the `./playtest` subpath to fall through
`node_modules` to `packages/core/dist`. The VQ-01 fixture at
`examples/abyss-framework/vq-assets/src/game.ts` puts both in one expression
(`plugins: [playtest()]` beside `defineGame`), so nominal types from two copies
met and `pnpm typecheck` failed with `#private` missing on `RenderChain`.

Map the subpath to its source entry like the bare specifier, the way a sibling
example's own program already resolves both through the package exports.
@jonit-dev jonit-dev added prd:50% PRD half the phases verified and removed prd:0% PRD implementation has no verified phase yet labels Oct 3, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Runbook B1 (2026-10-03): merged origin/develop (#419 moved this PRD to docs/PRDs/assets/; the integration lane's packaged-quickjs job steps restored after the fold). Phase 1 ×2 and Phase 2 ×2 ticked on their own proof commands at c29a21031 (capability spec 19/19; capability + fixture 21/21). pnpm typecheck fixed with one root paths entry (@threenative/core/playtest → src, e62bd7a9e). create-threenative suite 963/963, lint exit 0. Phase 3 (web codec render, Android V8 emulator) open.

decodeImageBytes reads WebP through sharp — already declared in this package's
dependencies and unused until now, so no dependency and no new native binary
reaches a game (every template already ships the same one via @gltf-transform/cli).
An embedded WebP now cooks like a PNG: KTX2 on a target that decodes it, a capped
PNG on the decoder-free path, and the pass disposes EXT_texture_webp once no image
is left in it because the glTF writer emits every attached extension.

The pass's "never grow a native dependency" rule changes with it: this is a
Node-only build-time package and sharp is the only native thing in it.

Gates: red before (TN_ASSETS_TEXTURE_CONTAINER: 'character.glb#checker' is not a
PNG or JPEG), green after — 26/26 model-texture-pass, 474 passed / 2 skipped across
packages/assets/__tests__, pnpm typecheck, pnpm lint, assets build + publint.
The Phase 3 web box names a browser command but nothing could serve the
fixture to it: examples/abyss-framework/vq-assets had no web entry at all,
and `threenative build` refused it with UNRESOLVED_ENTRY.

- index.html + src/main.ts: the minimal template's own canvas mount, so the
  web build has the entry page every Vite build needs and the unchanged game
  and cooked assets are what render.
- scripts/vq01-web-proof.sh: generates the authored Meshopt/Draco GLBs and PNG
  through the same function the native verifier uses, cooks the fixture for
  web, and runs the PRD's proof command against a managed preview server on
  127.0.0.1:5193.

The box stays open. This lane has no browser, so only the parts a browser
does not need were verified: the build publishes dist/ (exit 0), the scenario
loads through loadPlaytestScenario, the server command answers 200 for the
page and for the 1034430-byte game bundle, and the fixture still typechecks.
…an empty icon

Without an icon link the browser requests /favicon.ico, which 404s and
fails noConsoleErrors. Phase 3 box 1 ticked with the run.
@jonit-dev jonit-dev added prd:75% PRD phases in, acceptance open and removed prd:50% PRD half the phases verified labels Oct 3, 2026
The web proof writes .generated/; biome reads only the root .gitignore,
so the nested ignore did not keep it out of the formatter.
…ator

The Android box now has a runner and a result. The box names a playtest
command, but that runner launches an already installed app and never builds
or installs one, so scripts/vq01-android-proof.sh adds the missing half the
web lane already had: it cooks and packages examples/abyss-framework/vq-assets
for Android V8 through the maintainer source build, installs that APK on one
named serial, and then runs the canonical proof command against it.

Result on the emulator (not a phone): serial emulator-5554, AVD
threenative_api35, API 35, x86_64, -gpu host. Exit 0, 8/8 assertions -
loadedModels 2, texturedMeshes 2, vertices >= 100, frames changed,
maxPoseDelta >= 0.05, startup entered 955.7 ms / ready 1055.1 ms with
compilation settled, and diagnostics with zero console and zero runtime
entries. logcat reports "JS engine created: V8"; the adapter is
native:vulkan/NVIDIA/NVIDIA GeForce RTX 2080/615.71.09 and the capture is
device.screenshot. The after-frame carries each specimen's authored checker.

Android's admitted codec set is still empty, and the box says so rather than
implying otherwise. resolveRuntimeAssetCapabilities reports
android:unresolved with an unknown engine, so the cook refuses each
compressed source by name - TN_ASSETS_COMPRESSION_SKIPPED for model and for
texture - and the APK carries the decoder-free rewrites (draco 4,956 ->
20,240 B, meshopt 4,096 -> 3,908 B, checker.png 187 B). What this proves is
that the packaged loader decodes what was packaged and that the refusal path
works on a device; no qualified mobile compressed decoder is claimed.

No executable code changes, so the QuickJS decoder-free fixture is untouched
and stays green on hosted run 36998104447.

Verified: sh scripts/vq01-android-proof.sh artifacts/vq01-android-verify
emulator-5554 exits 0 with the same 8/8; pnpm check:docs clean; the doc,
evidence, CI-structure, CI-needs and primary-docs suites pass 248/248.
…t scene

AC-2 owed a Phase 3 scenario that repeats the transition: the fixture now counts its own
enters and, after a 30-frame settle window, leaves through the engine's scene-change API
(ctx.goto) for an empty scene that returns one frame later, four enters in all. Each enter
re-derives the renderer's live GPU tally (ctx.renderer.info.memory.geometries/textures) and
publishes it beside the growth against the first enter's baseline, so a resource the exit
path forgets to dispose shows up as growth instead of as a missing number.

examples/abyss-framework/playtests/vq-native-asset-lifecycle.playtest.json gates on
state.settled 2 -> 3 -> 4 and then asserts entries 4, loadedModels/texturedMeshes back to 2/2
and geometryGrowth/textureGrowth 0, on web and Android like the single-load scenario.
scripts/vq01-lifecycle-proof.sh <artifact-dir> web|android <serial> is that scenario behind
the same cook as the two existing VQ-01 proof scripts.

Unrun: no browser or device run of the new scenario here. Validated through the repository's
loadPlaytestScenario, tsc on the fixture, pnpm lint, pnpm check:docs and the six doc-contract
specs; the AC-2 spec still passes 19/19.
jonit-dev and others added 15 commits October 3, 2026 16:48
…85 phase 1)

lumen-hall's lady.glb and sanctuary.glb cook with `compression skipped:
not-smaller` three times each, so the shipped GLBs kept `images:
image/webp` and required `EXT_texture_webp` — a container the native
desktop/Android/iOS decoder-free paths cannot read.

Retention is now conditioned on the container as well as on size: only
`UNIVERSAL_IMAGE_CONTAINERS` (PNG, JPEG) may keep authored bytes, so a
WebP always converts — to KTX2 on a decoding target even when that is
larger, or to the capped PNG on the decoder-free path. `EXT_texture_webp`
then always leaves the output, and the size increase is reported by the
existing `bytes before -> after` row of the build report (+621% on
lady.glb), with no new marker. PNG/JPEG behaviour is unchanged.

`keepSmallerSource` moves from `true` to `"universal-containers"` in the
model pass's cache key and in the shared-image store settings: a warm cook
keyed on the old rule would otherwise keep shipping the WebP-requiring
models this replaces.

Verified: red (519 -> 519 bytes retained, extension still required) then
green in the `embedded EXT_texture_webp` block of
`packages/assets/__tests__/model-texture-pass.spec.ts`, whose new PNG leg
pins that a PNG whose KTX2 would be larger is still kept; file 28/28,
`packages/assets` 476 passed, `pnpm typecheck` and `pnpm lint` clean.
three clones through `new this.constructor().copy(source)`, so `ClusteredMesh`
died in its own constructor's table check before a game drew anything: the
default cook puts a cluster DAG in every model, and lumen-hall's authored
statues stood a second copy of one.

`clone()` now builds from the source's own arguments, and `copy()` completes
the job the three way: the baked table and the vertex attributes are shared —
one copy of the vertices on the GPU, since three keys attributes on the
attribute — while the cut is the copy's own, because the cut lives in the
geometry's index buffer and two meshes on one geometry would overwrite each
other's every frame. `ClusteredBatchRoot.clone()` keeps its batch, so a copied
root is still cut instead of freezing on the cut it was cloned with.

Red: 5 failing specs on `packages/core/__tests__/clustered*.spec.ts`, the
game's TypeError. Green: 49 in those four files, 2322 across packages/core,
plus typecheck, lint and build.
…rement artefact

ca1bd9c made the fixture leave and re-enter its own scene unconditionally, which broke the
original single-load scenario (state.frames stopped advancing after the first leave) and produced
geometryGrowth 0 -> 6 with ownedGeometries 1 -> 7 on a web WebGPU run. Neither is a leak.

The cycle is now armed by one key. The lifecycle scenario's first step presses it, and both runners
deliver a `press` step as the same keydown — page.keyboard.down on the browser lane and the device
bridge's input.keyDown, which synthesises keydown with `code` into document, window and canvas
(packages/playtest/src/three/device.ts:220). A URL query was not an option: the native host's
document.location is a fixed file:///game.html (packages/runtime-native/src/runtime.cpp:3436).
Nothing else presses it, so vq-native-asset-capabilities.playtest.json runs the fixture exactly as
it did before ca1bd9c, and its required capabilities are unchanged.

The growth was the measurement. renderer.info.memory is three's initialised-upload tally: geometries
++ on a geometry's first draw and -- on that geometry's own dispose event
(three.webgpu.js:31404,31841), and it is not reset per frame. The run's baseline read 1 geometry and
2 textures for a scene that holds six geometries (three stage cylinders, one plane, one mesh per GLB)
and samples three — a value below the scene's own live set can only be a sample taken mid-upload,
and every later enter's larger sample then reads as growth. The enter is now read at a tally that has
held still, and the leave follows that read rather than the first measured frame, so growth is a
difference between two settles and not between two arbitrary frames.

Evidence, none of it a GPU run:
- The engine path releases everything. packages/core/__tests__/asset-lifetime.spec.ts drives the real
  defineGame/ctx.goto/ctx.assets mechanism — load two models through ctx.assets, enter, leave via
  ctx.goto, come back — for three cycles and counts 0 live geometries and 0 live textures against a
  render-frame tally built the way three's backends build it. Its counter-probe, the same scene with
  an exit that forgets, reports 12 and 3, so the guard is known to see a leak when there is one.
  A scene change is scene.exit then clearScene (packages/core/src/game.ts:849,864,541), which drops
  references without disposing: the loader's cache is game-lifetime (game.ts:1018) and release/clear
  are its disposal API (packages/core/src/assets.ts:130,173).
- The fixture released everything it owned (vq-assets/src/game.ts:150-165): the four stage meshes'
  geometry and materials, its own configured copy of the PNG, both model entries and the cached
  texture, with disposeModel disposing each model's geometry, materials and material textures once
  (assets.ts:1058).
- The artefact reproduces in node. A tally still climbing through the measurement window reported
  geometryGrowth 5 against an unchanged settled live set; it now reports 0, and the paired case
  where a later enter really does leave more live still reports 6 and 3.

Red first, then green, in packages/create-threenative/__tests__/vq-native-fixture.spec.ts, which
imports the fixture and re-imports it per run because the enter ledger is module scope:
  "stays in its scene unless the lifecycle scenario asks it to leave" failed with left [void] on
  enter 1; "leaves and re-enters once the lifecycle scenario presses its key" failed with the
  leaves on the wrong enters; "reads an enter only once its tally has settled" failed with
  geometryGrowth 5. "still reports growth when a later enter leaves more live" passed before and
  after, which is what keeps the fix from being a measurement that never moves.

Gates: pnpm exec vitest run over the two VQ specs, asset-lifetime, scene-warning, game, assets and
native-asset-capture-proof — 47 and 108 respectively, all passed. pnpm typecheck, pnpm lint (0
errors) and pnpm build passed; pnpm check:docs passed. The scenario still loads through
loadPlaytestScenario and now requires browser.input on both targets, which the Android transport
advertises.

Unrun: no browser or device run of the corrected scenario here, so AC-2 stays open in the PRD with
the verdict and this evidence recorded against it.
The Hornbeam needed no engine fix: on the tarballs packed from this branch the
default cook exits 0 in 163 s (127 MB -> 27 MB, 6 embedded PNGs to KTX2, 0
uncooked bytes) and its trunk primitive gets a 3-rung TN_discrete_lod chain with
a 1,500-triangle far rung. Box 3 (browser + native scenarios) stays open, and
the PRD now records that the 745k-triangle MASK foliage took TN_virtual_geometry
with stalled cluster levels rather than a chain.
…D (PRD-485)

The Hornbeam's two MASK foliage primitives (225,310 + 520,018 = 745,328 of
755,677 triangles) shipped with no coarse representation at all. The cluster
builder simplifies every group with `LockBorder` (dag.ts:259) and every edge of a
disconnected alpha-cutout card is a border, so the first fold simplifies nothing
(dag.ts:461), dag.ts:319 records `stalled`, and every level reports the source
count. The primitive was then `virtual-owned` (eligibility.ts:182), so AutoLOD
declined it and the asset's only chain was the 10,349-triangle trunk.

`bakeVirtualGeometry` now declines a primitive whose DAG stopped `stalled` while
still holding more than 80% of its triangles: no extension is attached, so the
primitive is unowned and the discrete ladder's card + terminal levels — the path
built for exactly this shape (generate.ts:851) — take it. Measured, not assumed: a
DAG that stalls after real folds has shed its triangles and keeps the primitive.

Red: on a 16,384-card MASK fixture the cook emitted `TN_virtual_geometry`,
`lod.reasons ["virtual-owned"]` and no chain; the report named the bake as "no
primitive was dense enough". Green: no extension, `lod.reasons []`, a 3-level card
chain whose far rung is 1,638 triangles = 5.0% of LOD0 with `cellCoverage 1` on
every level, and the alpha cutoff the asset shipped unchanged.

Sandbox proof (fab-import-proof, repacked assets tarball): build exit 0, `lod
...: 3 primitive(s) to 3 level(s), 755677 -> 38767 triangles, 0 skipped`,
`TN_virtual_geometry` gone from extensionsUsed, far total 38,767 = 5.13%, cooked
GLB 26,990,484 -> 26,428,684 B.

Gates: packages/assets 480 passed, pnpm typecheck 0, pnpm lint 0 (1032 warnings,
unchanged), pnpm check:docs 0.
Phase 3 box 3. `finding-assets.md` already said the cook picks imported
GLBs up with no configuration; it now says what the cook does with them and
why `models: "none"` / `textures: "none"` is the wrong repair: a converted
Fab pack's embedded PNG/WebP maps become KTX2, its attributes are quantized
and Meshopt-compressed, and a `MASK` primitive gets a discrete LOD chain.
Turning a pass off ships every source byte verbatim and counts it all
against `assets.budget.uncooked`, which is what raises
TN_ASSETS_BUDGET_EXCEEDED. Measured on the Hornbeam: 126,934,280 ->
26,428,684 B, six embedded PNGs 45.4 MB -> 16.4 MB, 755,677 triangles to a
5.1% far rung.

Also names the three cook outputs a content-addressed glob cannot match
(`public/basis/`, `public/shared/`, `public/bake.receipt.json`), found by
building metahuman-lab's 301 MB tree through the cook.

The Phase 3 box 2 build leg is recorded beside its box, unticked: the cook
is exit 0 with 0 uncooked bytes, and the playtest is for the browser lane.

Proof: `pnpm exec vitest run scripts/__tests__/primary-docs.spec.ts` 7/7,
`pnpm check:docs` 2,229 links, plus check-doc-links/sync-agent-docs/
evidence-citations 30/30. No template file changed, so no scaffold hash moved.
…ok (PRD-485)

`packages/assets/src/passes/model.ts:925` called the library's `reorder` on the
whole document, which renumbers the vertices of every primitive it finds. That is
the cheapest transmission-size win in the cook and it is invisible until
something outside the file holds a vertex index: the metahuman-lab's
`brows.skin.json` names the head vertex each brow strand root rides and reads its
position back every frame, and after the cook it indexed a different vertex
(`brow root 0 indexes head vertex 11498, which is not where it was prepared`).
Measured on the 26.3 MB Ada specimen, 87 of 24408 head-primitive slots survived
the reorder.

The cook cannot know who holds an index, so the rule is the broader one: **any
primitive whose vertex indices can be addressed from outside is undeclarable**,
and deforming — skinned, morph-targeted, or joint-bound — is the measurable proxy,
because it is the one case detectable from the file alone and a rig is where
index-addressed data comes from. `deforming()` in `lod/eligibility.ts` is now the
single definition of that word, shared with the discrete-LOD and joined-rung
rules that already refused the same geometry.

`reorder-static.ts` runs the remap per shared index buffer and skips any group
holding a deforming primitive. Compression is untouched:
`EXT_meshopt_compression` encodes whatever order it is handed, and `quantize`
renumbers nothing — `specimen.glb` cooks 16,104,464 -> 16,114,804 B (+0.06%), the
price of leaving the head's 24408 vertices where the sidecar expects them.
`MODEL_REORDER_VERSION` joins the pass configuration, or a warm compile entry
would keep serving the renumbered files this replaces.

Red/green in `packages/assets/__tests__/model-pass.spec.ts`: a skinned primitive
and a morph-targeted one come out of the cook with position k exactly where it
started and the same vertex count, while a static mesh in the same document is
still reordered (and still only reordered). `packages/assets/__tests__` 481
passed, `pnpm typecheck` and `pnpm lint` clean.
…through the cook (PRD-485)

`packages/assets/src/passes/model.ts:964` handed the whole document to the library's
`quantize`, which rewrites POSITION into normalized int16 snapped to a grid whose step is
the mesh's own bounding volume and then folds that shift into the node holding the mesh, the
skin's inverse bind matrices, GPU instancing batches and any volumetric material. Exact for a
static mesh; for a rigged one it breaks the invariant this repo holds, one metre is one metre.
The metahuman-lab's `brows.skin.json` names the head vertex each brow strand root rides and
reads its position back every frame, and the sidecar was prepared against the source file to
1e-5 m — the 16-bit grid over a head-sized volume is micrometres away from every vertex it
recorded, and the shift that would undo it lives in a node transform the sidecar never sees.

So the same `deforming()` rule that stopped the cook renumbering a rigged vertex now stops it
quantizing one: a deforming mesh keeps POSITION and its morph-target POSITION deltas as the
float32 values the author wrote, at every requested depth. `quantize-static.ts` holds those
meshes across the stage and spends the window with each mesh detached from its nodes, so the
library's node, skin, instancing and thickness compensation is never applied and there is
nothing to undo; normals, UVs, colours and every static primitive still quantize, and
`EXT_meshopt_compression` still encodes whatever the accessors hold (its `QUANTIZE` encoder
method only names the pre-processing, which is this stage). The unit of source space is the
mesh, because the compensation is per mesh — one deforming primitive holds the whole thing.
An index buffer the stage replaced fails the cook by name rather than shipping a mesh whose
vertices have quietly moved, and `snapPositions` skips rigged meshes for the same reason it
exists to snap them. `MODEL_QUANTIZE_VERSION` joins the pass configuration so a warm compile
entry cannot keep serving the quantized files this replaces.

Red/green in `packages/assets/__tests__/model-pass.spec.ts`: a skinned primitive that also
carries a morph target comes out of the cook with float32 POSITION equal to the source within
1e-6, float32 deltas, and its skin's inverse bind matrices untouched, while a static mesh in
the same document still quantizes to normalized int16 under KHR_mesh_quantization. The 4-bit
floor test now states what is true of it: that request used to land on the fixture's skinned
head and trip the self-verify at 0.167% drift; it no longer reaches deforming geometry at all,
and the fail-closed comparison is covered directly by the drift-kind test. `packages/assets/
__tests__` 482 passed | 2 skipped, `pnpm typecheck` clean, biome clean on the four files.
@jonit-dev jonit-dev changed the title fix(build): fail closed on unknown native asset capabilities (VQ-01) feat(assets): consolidate native capabilities and high-quality cook Oct 4, 2026
@jonit-dev
jonit-dev marked this pull request as ready for review October 4, 2026 06:37
@jonit-dev
jonit-dev enabled auto-merge October 4, 2026 06:37
jonit-dev and others added 4 commits October 4, 2026 00:33
The leak guard requires every test-owned temporary directory to go through
test-support/temp-dir, and this spec called mkdtemp directly with its own
roots array. Use the helper, which registers the cleanup the guard reads.
@jonit-dev
jonit-dev added this pull request to the merge queue Oct 4, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 4, 2026
Resolve the integration workflow conflict keeping both intents:
- paths: keep the PR's native-assets filter row and develop's added
  `exposure` alternation in the tone render path filter.
- jobs: keep the PR's packaged-quickjs native asset fallback job and
  develop's exposure and cold-boot auto-exposure jobs. No develop job
  removed.
@jonit-dev
jonit-dev enabled auto-merge October 4, 2026 12:27
@jonit-dev
jonit-dev added this pull request to the merge queue Oct 4, 2026
Merged via the queue into develop with commit 089690c Oct 4, 2026
73 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:75% PRD phases in, acceptance open

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant