BLOBBO/lanes/LANE-I-asset-runtime.md
Claude e34773d35f harden asset runtime: guard the swap path, bound the boot, reconcile slots
The three that stopped a pack shipping:
 - a mesh-less GLB (armature-only export) fired onSwap anyway, hiding the
   primitive and adding nothing — an invisible prop with a live collider.
   Mesh count is now checked BEFORE the scene is touched, in one guard that
   covers every consumer including slotObject/blob.face.
 - the fulfilment path had no try/catch, so a throw became an unhandled
   rejection. Split in two: a build failure bails out before onSwap can hide
   anything; an onSwap failure keeps the replacement parented, because
   consumers hide their primitive on onSwap's first line and removing the fit
   node there would manufacture the one forbidden state.
 - preload() awaited every url forever. Each is now raced against a 10s
   deadline with allSettled semantics: a stalled host costs one warning and a
   fallback prop, not a game that never boots.

Also:
 - instantiate/instanceSync never throw — createBlob's call site is frozen and
   unguarded, so a throw there is a black screen.
 - ghost and blob share fitBodyToRadius(); a borrowed blob.body was rendering
   the ghost 2.17x oversized with the farm mesh.
 - skinned blob.body is rejected loudly instead of silently half-working; the
   idle-clip mixer is gone (it animated an orphaned skeleton in the fixed step).
 - paintableInfo counts UV islands: blobbo-base.glb passes every other check
   and still paints wrong at 1140 charts. Warning, not rejection.
 - slots.ts gains cannon.base, course.finish, course.tunnel, course.tramp, all
   hooked except tramp (built in frozen game.ts).
 - manifest ignores _-prefixed metadata keys instead of calling them typos.

Empty-manifest parity verified byte-for-byte: scripts/sacred-parity.check.ts
fingerprints the built scene and reports 49df4f20 on main and on this branch.
2026-07-19 12:12:07 +10:00

4.4 KiB

Lane I — Asset runtime (manifest, registry, slot hooks)

Owns: src/assets/, assets/manifest.json, demos/lane-i.html, src/demo/lane-i.ts, PLUS surgical slot-hook edits inside src/blob/createBlob.ts, src/paint/cannon.ts (barrel build only), src/machine/parts.ts (mesh builds only), src/course/greybox.ts (scenery only). Those four are OTHER lanes' files: your edits there must be strictly additive-visual — replace direct mesh construction with a registry hook that DEFAULTS to the existing construction. Never touch physics/colliders/logic in them, and never edit contracts/world/ game/main/package.json.

Mission

Custom GLB assets drop into named slots without code changes; an empty manifest produces today's game exactly. See docs/WORKSHOP-PLAN.md for the slot table — implement every slot listed there except the UI rows.

Deliverables

  1. src/assets/manifest.ts — schema + loader:
    interface SlotEntry { url: string; offset?: [n,n,n]; rotationDeg?: [n,n,n];
      scale?: number | [n,n,n]; idleClip?: string }
    type Manifest = Record<SlotId, SlotEntry>
    
    Load order (later wins): assets/manifest.json (fetch, may 404 → {}) → IndexedDB override (blobbo-workshop db: manifest + blobs stores; GLB entries may be idb:<key> urls resolved to object URLs).
  2. src/assets/registry.tsAssetRegistry:
    • slotObject(slot, buildFallback: () => THREE.Object3D): THREE.Object3D — synchronous placeholder pattern: returns a Group immediately containing the fallback; if the manifest has the slot, async-load the GLB (GLTFLoader from three/examples/jsm — already in the three package, no new deps), then swap the group's child in place, applying offset/rotation/scale.
    • Cache by url (clone via SkeletonUtils.clone for skinned, .clone() else).
    • Load failure → keep fallback + one console.warn (never throw).
    • paintableInfo(object) — for blob.body: find first Mesh/SkinnedMesh, report { uvOk (uv attr exists & 0..1 bounded), triCount, materialCount }.
  3. Blob slot specifics (in your createBlob hook):
    • The delivered mesh's material map is replaced by PaintSkin's canvas texture at integration (skin binds to whatever mesh is present); ensure the hook exposes the swapped-in paintable mesh as blob.mesh (the skin and buffs read it).

      NOT BUILT — SkinnedMesh + idleClip was authored here and does not work. createBlob re-parents the body MESH alone into its own group, leaving the GLB's bones outside the scene graph with no matrixWorld update, so the clip animates nothing while burning fixed-step CPU; and the body's geometry is re-centred/re-scaled to the collider radius, which invalidates the skeleton's bind matrices. paintableInfo therefore REJECTS a skinned blob.body (ok requires !skinned), idleClip is ignored with a warning and no AnimationMixer is created. Making it real means keeping the GLB root in the scene graph and fitting via a parent node instead of baking into the geometry — a design change, not a bug fix.

    • UV warning: if paintableInfo.uvOk is false, console.warn and keep the procedural sphere instead (paint must never silently break).

  4. assets/manifest.json — ship EMPTY {} (packs come later via integration).
  5. Demo demos/lane-i.html + src/demo/lane-i.ts: a stage that builds every slot twice — fallback vs a test manifest pointing at the four existing farm GLBs (assets/meshes/*.glb) — side by side, with per-slot load status text and the paintability report for blob.body.

Acceptance

  • npm run build green. With {} manifest: pixel-identical construction paths (verify: no registry code path alters any existing geometry parameters — the fallback builders are the ORIGINAL code moved, not rewritten).
  • Demo loads all four farm GLBs into sensible slots (boot, bucket, toaster scenery, blob body) with fit transforms committed in the demo's test manifest.
  • Blob body swap: paint stamps visibly land on the swapped mesh in the demo (drive a scripted splat at a known point).
  • Failure paths: bad url → fallback + single warn; malformed manifest → {}.
  • Slot-hook edits to the four foreign files are mesh-construction-only (diff must show no collider/physics/logic lines changed).

Contract friction

Report, never edit frozen files. If a slot needs data the hook can't reach, document it for integration rather than widening interfaces.