BLOBBO/docs/ASSET-SLOTS.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

222 lines
10 KiB
Markdown

# Custom assets — how the slots work
Drop a `.glb` into a named slot and the game uses it. No code changes.
With no manifest at all, the game builds exactly what it builds today.
## The one URL rule
```ts
import.meta.env.BASE_URL + 'assets/manifest.json'
```
Never `/assets/...` (that drops the `/blobbo/` deploy prefix) and never a bare
`assets/...` (that resolves against the *page* directory, which breaks on the
demo pages one level down). `assetUrl()` in `src/assets/manifest.ts` does this
for you.
## Where files live
| What | Path | Ships? |
|---|---|---|
| The manifest | `public/assets/manifest.json` | yes → `dist/assets/manifest.json` |
| A shipped asset pack | `public/assets/live/*.glb` | yes → `dist/assets/live/*.glb` |
| Raw farm output | `assets/meshes/*.glb` | **no** — outside the build |
`public/` is vite's default publicDir, so everything under it is copied into
`dist/` verbatim with the base path already applied. Before this lane, nothing
copied `assets/` into `dist/` at all — every asset URL would have 404'd live.
The four farm GLBs are ~43 MB of raw output with embedded textures. Stage them
for local work with `./scripts/stage-farm-assets.sh` (gitignored). Compress them
offline before any of them goes into `public/assets/live/``deploy.sh` rsyncs
and `docker cp`s the whole of `dist/` on every deploy.
## Manifest format
```json
{
"machine.boot": {
"url": "assets/live/boot.glb",
"offset": [0, 0.2, 0],
"rotationDeg": [0, 90, 0],
"scale": 1.5
}
}
```
Keys starting with `_` (the editor writes `_readme` and `_generatedBy`) are
metadata and are ignored silently. `idleClip` is accepted by the schema but
currently ignored by the runtime — see "Rigged bodies are not supported" below.
Everything except `url` is optional. A bad field is dropped with a warning and
the rest of the entry still loads; a bad entry is dropped and the rest of the
pack still loads; a file that isn't JSON at all falls back to `{}`. A GLB that
fails to load leaves the built-in version in place and logs one warning.
Load order, later wins: `public/assets/manifest.json`, then the editor's
IndexedDB override (`blobbo-workshop`). An override url may be `idb:<key>`,
which resolves to a blob stored in the same database — that is what makes a
custom asset testable on the live site with zero deploys.
## Slots
| Slot id | Replaces | Stays procedural |
|---|---|---|
| `blob.body` | the paintable body | — |
| `blob.face` | the googly eyes | — |
| `ghost.body` | the ghost racer (defaults to `blob.body`) | — |
| `cannon.base` | the cannon's stand | — |
| `cannon.barrel` | the tube | the aiming pivot |
| `machine.plate` | the plate frame | the pressed pad (it lights up) |
| `machine.boot` | pad + coils | — |
| `machine.bucket` | the shell | the paint fill (it is re-tinted) |
| `machine.arch` | posts, bar, bubbles | — |
| `machine.belt` | the belt slab | the scrolling chevrons |
| `machine.fan` | the housing | the spinning blades |
| `machine.seesaw` | the plank | the fulcrum |
| `course.scenery.cereal` | the giant cereal box | — |
| `course.scenery.block` | the purple block | — |
| `course.finish` | the pink finish podium | — |
| `course.tunnel` | the MINI tunnel roof slab | the warning bar and the posts |
| `course.tramp` | the orange gap launcher pad | — |
| `fx.puddle` | the puddle slab | — |
Sub-parts that move every frame stay procedural on purpose: a custom model can't
accidentally stop the belt scrolling, the fan spinning or the plate lighting up,
and those motions are what make each machine readable.
`course.tramp` is built in `src/game.ts`, which is frozen for the workshop
lanes; the id and label exist so the editor can offer it, and integration wires
the hook. Until it does, dropping a file on that slot changes nothing.
## One slot, many objects
Most slots are instanced more than once — nine `fx.puddle` strips, six cannons,
every machine of a given kind. **Each instance gets its own clone of the asset
at its own pose, with its own materials.** That is the contract both the runtime
and the editor follow: dropping one puddle model reskins all nine puddles, and
tinting one of them (each puddle keeps its zone colour) cannot bleed into the
others. Verified in `src/assets/harden.test.ts`.
Single-instance slots — the ones the game builds exactly once — are listed in
`SINGLE_INSTANCE_SLOTS` in `src/assets/slots.ts`: `blob.body`, `blob.face`,
`ghost.body`, `course.finish`, `course.tunnel` and the two `course.scenery.*`.
## What gets rejected, and what only gets a warning
| Asset | Outcome |
|---|---|
| GLB fails to load / 404s | slot keeps its built-in version, one warning |
| GLB takes longer than 10 s | boot continues on the built-in version, one warning |
| GLB contains no mesh (armature/empty/camera only) | slot keeps its built-in version — the primitive is **never** hidden for a replacement that isn't there |
| `blob.body` with a skeleton/armature | **rejected**, built-in blob kept (see below) |
| `blob.body` with no UVs, UVs outside 0..1, or >1 material | rejected, built-in blob kept |
| `blob.body` whose UVs are an auto-atlas | **accepted with a warning** — it paints, just messily |
### Rigged bodies are not supported
A `blob.body` that is a `SkinnedMesh` is rejected and the built-in blob is kept.
This is a real limitation, not a missing check: `createBlob` re-parents the body
mesh alone into its own group, so the GLB's bones are left outside the scene
graph and never get a `matrixWorld` update — the pose would never advance. And
the body's geometry is re-centred and re-scaled to the collider radius, which
invalidates the skeleton's bind matrices, so any deformation that did resolve
would be offset. `idleClip` is ignored for the same reason and no animation
mixer is created — animating an invisible skeleton in the fixed step would cost
CPU for nothing. Export the body with the armature applied.
### The atlas warning
`blob.body` reports `uvCharts` (connected UV islands) and `seamRatio`. UVs
inside 0..1 with one material is **not** enough: the farm's own
`blobbo-base.glb` passes both and still paints wrong, because its unwrap is a
1140-island auto-atlas (29% of its vertices sit on a seam) and a splat disc in
UV space therefore speckles a thousand unrelated triangles. Above 24 islands the
report says so. For reference, three's `SphereGeometry` is 3 islands and a
`BoxGeometry` is 6 — a hand unwrap is nowhere near the threshold.
## Modelling conventions
Y-up, metres, one material, embedded textures ≤ 2048².
- **`blob.body`** is the only slot that is not purely cosmetic. It must be a
single mesh with a single material and one UV island inside 0..1 — the paint
is stamped through those UVs. It is automatically re-centred and re-scaled to
the 1.0u body, because `boundingRadius` is a gameplay number (the puddles
compute belly contact from it). `scale` is ignored for this slot; `offset` and
`rotationDeg` are baked into the geometry before the refit. If the mesh fails
the paint checks the built-in blob is kept and the reason is logged — paint
never breaks silently.
- **`machine.bucket`** — origin at the tipping edge, pours toward +X.
- **`machine.boot`** — origin at the base; it shakes about its own origin.
- **`machine.fan`** — faces +Z.
- **`machine.seesaw`** — plank only, long axis along X, origin at the centre.
- **`ghost.body`** — if you leave it empty the ghost borrows `blob.body` and is
put through the exact same fit, so it always matches the blob's size. Fill it
only when you want the ghost to look like something else; then its own
`offset`/`rotation`/`scale` apply normally.
- **`course.tunnel`** — roof slab only. Model its underside FLAT at the bottom
of the model: the clearance a MINI blob squeezes through is the collider's and
never changes, so a model that hangs lower than the collider will look like it
blocks a gap it does not.
- **Course boxes** keep their collider whatever you drop in, so a custom prop
can never open a hole in the course or grow an invisible wall. Fill the volume.
- Materials are converted to `MeshStandardMaterial` on load if they aren't one
already: the machine danger-flash only drives standard materials, so a Basic
or Phong export would silently kill the telegraph.
## Wiring (integration)
`src/main.ts` needs one line before `installGame(world)`:
```ts
await initAssets()
```
Without it the registry stays empty and every slot falls back — which is safe,
but nothing ever swaps. It must be awaited: the frozen `game.ts` captures
`blob.mesh` by reference the moment `createBlob` returns, so the body asset has
to be in the cache before the game is built.
`initAssets()` cannot hang the boot: every url in the manifest is raced against
a 10 s deadline and settled independently, so a stalled host costs one warning
and the built-in prop rather than a game that never starts.
`course.tramp` still needs one line in `game.ts`, next to where the trampoline
pad mesh is built:
```ts
assets().attachSlot('course.tramp', trampMesh, {
onSwap: () => { (trampMesh.material as THREE.Material).visible = false },
})
```
## Checking your work
```
npm run build
node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/manifest.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/registry.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts
node --import ./scripts/ts-resolve.mjs --experimental-strip-types scripts/farm-assets.check.ts
```
`harden.test.ts` is the regression net for the failure modes that used to be
silent: a mesh-less GLB blanking a slot, a throw escaping the swap path, a
stalled url hanging the boot, an oversized ghost, a rigged body being accepted,
and one dropped asset serving nine puddles.
The sacred property — empty manifest builds today's game — has its own check:
```
./node_modules/.bin/esbuild scripts/sacred-parity.check.ts --bundle \
--platform=node --format=esm --outfile=/tmp/sacred.mjs && node /tmp/sacred.mjs
```
It fingerprints every node of the scene the course, zones, blob and a cannon
build with an empty registry. The number must not move.
The farm check parses the real farm GLBs, runs the paintability check on each and
fires PaintSkin's own outside-in raycast against the fitted blob body, so you can
confirm a splat would land without opening a browser.