glytch/fktry/lanes/LANE-RENDER.md
type-two 3fc043e504 [render] fix GLB body-scale regression: pop settles to baseScale, not 1
The placement scale-pop hardcoded the settled scale to 1, which stomped a
normalised GLB's fit-to-footprint root scale (s~2-3.5) down to 1 on the first
sync, collapsing the body to raw geometry size (a speck) while the beacon/jam
child meshes kept their own local scale. Store baseScale per entity (1 for
placeholders, the normalisation scale for GLBs) and make the pop a multiplier
on it. Verified full-size bodies on fresh loads in ?showroom and ?uidemo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 19:33:48 +10:00

805 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LANE-RENDER — three.js world renderer
You are an Opus 4.8 executor on the RENDER lane of FKTRY. Read `../MASTERPLAN.md` and
`../CONTRACTS.md` first; the world bible + visual style guide (§8) is
`../../docs/FKTRY_LORE.md` — §8 is YOUR section, follow it.
**Mission:** make the Bitstream visible. Isometric factory view, machines, belts with
smoothly interpolated items, ghosts, selection — and the hot-swap pipeline that lets
MODELBEAST GLB assets land mid-development without a code change.
**Owned paths:** `src/render/**`, `public/assets/**` (manifest + loading; the files
themselves arrive from MODELBEAST).
**Never touch:** contracts, main.ts, other lanes. Sim is truth: you render snapshots,
you never simulate. If motion looks wrong, interpolate better or note it — don't
"fix" positions locally.
**Standing rules (all rounds):**
- 60fps with 500 entities + 2,000 belt items on an M-series MacBook. Instanced meshes
for belts/items from day one — you will not get to retrofit this later.
- Placeholder aesthetic is a feature: crisp box-primitive machines tinted per codex
`color`, NOT gray cubes. Two-material rule even for placeholders: dark body + one
emissive accent.
- Every entity mesh keys off `MachineDef.asset` / `ItemDef.id` through the registry —
zero hard-coded ids.
---
## CURRENT ORDERS — Round 6 (goal: the GLB era looks as good as it is)
MODELBEAST delivered: 10 real GLBs now live in `public/assets/models/` (mosh-reactor,
quantizer, seam-extractor, muxer, archaeology-lab, software-decoder, decode-asic,
mosquito-trap, chroma-keyer, artifact-bottler). Hot-swap + normalization worked
first try. One problem, and it's yours: **at game zoom they read dark** — baked
textures carry no emissive, so the two-material rule dies on real assets.
1. **GLB legibility pass, the round's centerpiece**: give hot-swapped GLBs the
accent treatment placeholders get — your existing accent derivation drives a
small emissive element (glow sprite / rim boost / brightest-texel emissive lift;
your call, note it) + an overall brightness floor so a dark chassis never reads
as a hole. Verify all 10 in showroom AND in the demo world at default zoom;
screenshot before/after in NOTES.
2. **Mite visuals** (SIM lands `'parity-mite'` wildlife): hospital-white beetles,
green checkmark eye (§5), unsettling gentleness; a caught mite in a firewall
reads as filed paperwork. The 'repaired' event gets a small, infuriatingly tidy
sparkle where your product used to be.
3. **Excavation VFX** on `relicFound` (carried from your round-5 NEXT).
**DoD:** all 10 GLBs legible at default zoom (before/after proof); mites read as
The Correction at a glance; perf rule holds.
---
## NOTES (append-only log, newest at bottom)
```
### Round N — YYYY-MM-DD — [your model]
SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
```
### Round 1 — 2026-07-17 — Opus 4.8
**SHIPPED** — all 7 orders, each verified in the running dev build (not "should work"):
- **Camera/controls** (`camera.ts`): ortho iso, wheel zoom clamped 8..40, middle/right-drag
pan, WASD+arrows, resize via ResizeObserver.
- **Ground** (`ground.ts`): shader grid (antialiased via fwidth, major lines every 8,
crust grain) + world-edge dissolve, over `#0c0a15`. Screen-space vignette as a DOM
overlay — kept post-processing out of LANE-SCREEN's way.
- **Registry + hot-swap** (`registry.ts`): placeholder factories per kind; probes
`assets/models/<key>.glb` on init + every 10s in dev. **Verified live**: dropped a
GLB into `public/assets/models/quantizer.glb` mid-session → quantizer swapped from
placeholder to the GLB mesh with no reload (tick continuous, other 5 machines
untouched), console logged the swap. Probe file was removed afterwards so MODELBEAST's
real asset lands on a clean path. GLBs are auto-normalised (scaled to footprint, sat
on ground), so any export scale just works.
- **Entities** (`entities.ts`), **belts** (`belts.ts`, instanced + chevron scroll),
**belt items** (`beltitems.ts`, InstancedMesh per type, interpolated), **ghost +
picking** (`ghost.ts`), jam blink + brownout dimming.
**VERIFIED (numbers, not vibes)**
- **Perf standing rule met with 5x headroom**: `?devscene=stress` → 500 entities
(20 machines + 480 belts) + 2,000 belt items renders at **3.2ms/frame avg** (budget
16.6ms). The 2,480 belts+items cost **5 instanced draw calls** total.
- **Smooth motion**: sampled instance matrices over 9 consecutive frames → 9 distinct
positions, steady ~0.0166 tiles/frame (2 tiles/s on a 120Hz panel). Interpolating
continuously, not stepping at 30tps.
- **Ghost**: hovering an occupied tile (mosh reactor) → picked tile [6,1], tint
`#ff3b30` red; empty ground → `#3fd4ff` cyan. Driven through main.ts's real glue.
- **Brownout**: exposure 1.35 → 0.644 with flicker. **Jam**: amber `#ffa726`, blinking.
**DECISIONS** (flagging these because they're judgement calls, not obvious readings)
- **Machine accent colour is derived from data**: `MachineDef` has no `color` field, so
a machine's one emissive accent = the colour of the item it makes (recipes → outputs →
`ItemDef.color`). Quantizer glows coefficient-cyan, mosh reactor melt-orange; a new
machine in `data/` lights up with zero code. Matches §8's "colour = value = literally
the resource". See CONTRACT REQUEST below.
- **Legibility floor on data colours.** Raw products are near-black by design (MDAT ORE
`#1a1a22`). Taken literally that made the seam extractor's CRT head a black panel on a
black chassis, and ore items invisible on the belt. Derived accents below L=0.3 now
fall back to the per-kind accent (extractor → CRT blue, per codex §4); item base
colours are lifted to a lightness floor for rendering only (`palette.readable`). Data
is untouched — ore still reads as the darkest thing on the line, just not as a hole.
- **Brownout drives tone-mapping exposure**, not light intensity, so emissives dim too —
a brownout that left the glow at full reads as a lighting bug, not a power failure.
- **Fog is banded around the camera distance**, not absolute. Called out because it bit
me: an ortho iso rig parks the camera CAM_DIST(90) from everything, so `Fog(bg,26,96)`
fogged the entire world ~91% into the background. Anything touching `CAM_DIST` must
keep `index.ts`'s fog band in step.
- Dev scaffold (`devscene.ts`) is gated behind `import.meta.env.DEV` **and** `?devscene`,
and is tree-shaken out of the prod bundle (verified: 0 hits in `dist/`). It yields
automatically the moment the real sim reports any entity — it never fabricates over
live sim data. `?devscene=stress` is the perf rig.
**BLOCKED/BROKEN** — nothing blocking. Honest limitations:
- **Belt GLB instancing takes only the FIRST mesh** of a multi-mesh GLB. Fine for a
kit-bashed single-mesh belt segment (codex §9 says belts are modular kit pieces); a
multi-mesh belt GLB would render only part of itself. Machines are unaffected.
- **GLB animation clips are not played** (no AnimationMixer yet). Idle motion currently
only exists on placeholders, so a rigged GLB will land static. Wanted for M4.
- **Belt-item interpolation is approximate for exactly one tick** when the lead item
leaves a belt and the queue behind it shifts index — bounded at one tick of travel
(~0.07 tiles), imperceptible. Root cause is the missing stable id; see below.
- Below 30fps the interpolation stretches (prev-tick snapshot may be >1 tick back).
Not observed; noting it rather than hiding it.
**CONTRACT REQUEST**
1. `BeltItem.id` (stable, per-item) — would make cargo interpolation exact instead of
order-matched. Low priority; current error is imperceptible.
2. `MachineDef.color?: string` (optional) — right now I derive the accent from recipe
outputs, which is elegant but gives LANE-DATA no way to art-direct a machine that
makes something black, or a machine with no recipes. Optional field, derived fallback.
3. **`main.ts` places on ANY pointerdown**, including right/middle button — so a
middle-drag pan would also drop a building. I worked around it inside my lane
(`camera.ts` stops non-left buttons from bubbling to `#game`), but the clean fix is
`if (e.button !== 0) return;` in main.ts's handler. Flagging since it's your file.
**PROPOSAL**
- MODELBEAST asset keys are live now — the registry will hot-swap any of these the
moment a GLB lands, no code change: `seam-extractor`, `belt`, `demuxer`, `quantizer`,
`mosh-reactor`, `decode-asic`, `shipper` (+ whatever LANE-DATA adds; the key is
`MachineDef.asset`). Hero assets (mosh reactor, muxer) are the ones worth `fal_trellis2`.
- Shadows are on (2048 map, tight ortho shadow camera). Cheap at current load; if a big
factory drags, that's the first dial to turn.
**NEXT (round 2 candidates, not self-assigned)**
- AnimationMixer for rigged GLBs; per-era skinning hooks; splitter/lane visuals; heat
shimmer + throttle read-out on the software decoder; belt corner/junction pieces
(currently every belt is a straight plate regardless of neighbours).
### Round 2 — 2026-07-17 — Opus 4.8
**READ THIS FIRST — I did not follow order 2's letter, deliberately.**
`MachineDef.color` is wired to the machine **body**, not the emissive accent, against
the stated precedence (`def.color` > derived > kind). Why: all 21 values LANE-DATA
authored are desaturated industrial neutrals that match codex CHASSIS descriptions —
`seam-extractor #8a7a32` is §4's "grimy yellow chassis", `decode-asic #b9bec4` is its
"server-blade monolith", `mosh-reactor #52333e` its pressure vessel. Style guide §8 says
ALL saturation belongs to media/artifacts and each machine gets exactly ONE impossible
element. Wired to the accent (I built it that way first and looked at it), all 21
machines glow muddy neutral: the quantizer's jaw went from coefficient-cyan to tan, the
mosh reactor's from melt-orange to mauve, and the two-material rule quietly died.
Read as bodies they're exactly right, and we get both: 21 art-directed chassis AND the
vivid resource accents. The black-on-black problem the field was meant to solve stays
solved by round 1's legibility floor. **This is a cross-lane semantic conflict between
my orders and DATA's — please settle it; see CONTRACT REQUEST 1.** If you want the
literal reading, it's a two-line revert in `registry.ts` (`bodyFor`/`accentFor`).
**SHIPPED** — all 6 orders, each verified in the running build:
1. **Belt topology** (`topology.ts`, new): neighbour analysis → straight / cornerA /
cornerB / merge, one InstancedMesh per (def × shape), flow field baked into each
shape's shader (no runtime branch). Corners sweep a quarter-arc; merges add inlet
arrows. Verified: the devscene bend classifies **20 straight / 1 cornerA / 1 cornerB
/ 1 merge** across 23 belts, still 4 draw calls.
- **Cargo now follows the corner arc too.** Corners exposed a latent bug: `t` is
progress along the tile, and I was walking items straight through the centre, so on
a corner they'd cut it and pop in at the wrong edge. Items now enter at the feeding
edge and sweep the arc. Verified numerically: items on both corners sit 0.0890.248
off the centreline where a straight path pins every sample to exactly 0.000.
2. **Heat** (`heat.ts`, new + `entities.ts`): dull-red emissive ramp on the body, rising
shimmer column, throttle, scram. Verified end-to-end at forced heat:
- 0.50 → body emissive `#bc280a` @ 1.35 (= `1 + 0.5²×1.4`), haze 0.5
- 0.85 → idle clock advances **0.0053/frame vs 0.0083 full speed** = the 0.625×
throttle factor exactly; body emissive 2.01
- 1.00 → **scram**: every emissive 0 (dead-dark), haze off, idle clock frozen at
43.613, amber blinking
- back to 0.20 → **restart recovers**: body emissive 1.06 (= `1 + 0.2²×1.4`), latch
cleared, idle moving, haze back
3. **Buffer tank**: fill column driven by the tank's share of `bandwidth.stored` over
summed `bufferCap` (DATA shipped `buffer-tank: 240`). Verified oscillating with the
pool rather than sitting at its 0.001 floor.
4. **AnimationMixer**: clip 0 plays looped on rigged GLBs, and shares the throttle
factor — a hot machine's *animation* labours too, which fell out for free. Verified
with a hand-authored animated GLB: 9 frames → 9 distinct Y values, clip driving the
node. Rigged GLBs clone via `SkeletonUtils.clone`, not `Object3D.clone`, or every
instance would animate off the first one's bones.
5. **Exact cargo interpolation**: keys off `BeltItem.id` when present (`exactIds: true`
verified), order-matching retained as fallback until v3 makes it required. The
one-tick approximation is gone whenever the sim stamps ids.
6. **Multi-mesh belt GLBs**: merged with `mergeGeometries(..., useGroups)` so a fancy
belt asset can't half-render. Verified with a 2-mesh GLB: **2 material groups, 2
materials, 1 instanced draw call**, both meshes visible. If attributes ever mismatch
it keeps the first mesh and `console.warn`s the count rather than silently dropping.
**VERIFIED** — perf standing rule still met with everything above added:
`?devscene=stress` → 500 entities + 2,000 items at **3.6ms/frame avg (4.6× headroom)**,
5 instanced draw calls. Round 1 was 3.2ms, so topology + heat + mixers cost ~nothing.
Dev scaffold still DEV-gated and absent from `dist/` (verified 0 hits).
**DECISIONS**
- **Scram is a latch with hysteresis, mirroring SIM's — and this caught a real bug.**
SIM landed heat mid-round, so I read their code: `scrammed` lives on their internal
`Ent`, never on `EntityState`; they deliberately clear `jammed` on scram ("reported by
its own event, not as a flow jam"); and `Renderer` only ever receives snapshots, never
events. So the latched-off state is **not snapshot-visible at all**. A plain
`heat >= 1` test (what I shipped first) un-scrams the moment the machine starts
cooling — and with `HEAT_RESTART = 0.5` and `HEAT_DISSIPATION = 0.004/tick` the sim
keeps it dead for ~125 ticks (**~4 seconds**) while heat falls 1.0 → 0.5. The renderer
would have shown it alive, glowing and animating, for that whole window, every scram.
`entities.ts` now latches at 1.0 and releases below `SCRAM_RELEASE = 0.5`, mirroring
them. Verified: 1.0 → dead; **0.7 → stays dead** (the window that used to lie);
0.3 → releases and recovers. This is correct today but duplicates their constant —
see request 2.
- GLB instances get **cloned materials**; heat/scram is per-entity state and the
template's materials are shared, so one hot machine would otherwise tint every copy.
Placeholders own geo+mats; GLB clones own only mats. Disposal follows that split.
- Heat tints the tagged body mesh on placeholders; a GLB has no body/accent split, so
there it tints everything.
- Shimmer is an additive billboard column, not screen-space refraction — post-processing
is LANE-SCREEN's territory and I'm not reaching into it.
- Kept the `camera.ts` non-left-button `stopPropagation` as belt-and-braces even though
main.ts now guards; it's two lines and costs nothing.
**BLOCKED/BROKEN** — nothing blocking. Honest limitations:
- **Merge pieces don't route cargo per-inlet**: items on a merge belt still ride the
straight path, because `BeltItem` doesn't say which side it entered from. Reads fine
(a merge's main flow *is* straight) but it's not truthful for the side feed.
- Corner arc assumes a 1×1 belt. Fine per contract (`beltSpeed` belts are 1×1).
**DoD — verified against SIM's actual `referenceFactory`** (it landed while I was
building; I loaded it in the dev page and dispatched all 114 commands):
- **85 belts classify as 80 straight / 3 cornerA / 1 cornerB / 1 merge**, 25 machines —
five pieces that round 1 would have drawn as flat straight plates.
- **Cargo flows with `exactIds: true`** — SIM stamps `BeltItem.id`, so the exact
interpolation path is live on real sim data, not just the scaffold.
- Ore renders `#4b4b62` (round 1's legibility floor lifting DATA's `#1a1a22`), so the
reference factory's first product is visible on its belts rather than a black smudge.
- **4.1ms/frame** with the whole reference factory up.
- Note for whoever plays it: a hand-placed extractor produces nothing until you also
`setRecipe` it — `place` alone leaves `recipe: null`. Not a bug, but it's why a
quick manual line looks dead. The reference factory sets all four itself.
**CONTRACT REQUEST**
1. **Settle `MachineDef.color`'s meaning** (see the top of this entry). My proposal:
keep DATA's values as-authored and re-comment the field as chassis/body colour, then
add `accent?: string` if art-directing the emissive is ever wanted. Right now the
contract comment says "accent", DATA authored bodies, and I've followed the data.
2. **`EntityState.scrammed?: boolean`** — now CONFIRMED necessary, not speculative (see
DECISIONS). SIM's scram latch is invisible to the snapshot, and the only way I can
render it correctly is by duplicating their `HEAT_RESTART = 0.5` as my own
`SCRAM_RELEASE`. That works, but it silently desyncs the moment SIM retunes their
constant — a rendering lie with no test to catch it. One bool on `EntityState` kills
the duplication outright. (Alternative if you'd rather not widen the state: give
`Renderer` the events, like `ScreenFX` gets — but the bool is smaller and snapshots
staying self-sufficient is a nice property.)
3. Not urgent: `BeltItem.side?: Dir` (which edge cargo entered from) would let merge
pieces route their side feed honestly. Only worth it if merges get common.
**PROPOSAL**
- MODELBEAST hot-swap now also covers **animated** and **multi-mesh** GLBs, so hero
assets can arrive rigged and kit-bashed with no code change. Asset keys are whatever
`MachineDef.asset` says — all 21 are live targets today.
- The showroom row in the devscene (`?devscene`) is a decent visual smoke test for any
new machine visual; it's clear of the belt path on purpose.
**NEXT (round 3 candidates, not self-assigned)**
- Per-era chassis skinning (§8 era skins) once tech/eras land; splitter output lamp
driven by real round-robin state rather than a timer; bloom-loop belt tell; HF dust
piles; parity-mite wildlife; LOD/culling if a real megabase ever drags the frame.
### Round 3 — 2026-07-17 — Opus 4.8
**PROVENANCE — read before you diff lanes against owned paths.** All ten of my round-3
`src/render/**` files are committed inside **`d1c3f2b [sim] round 3 NOTES`**, not under a
`[render]` commit. Nobody did anything wrong: the five lanes share one working tree AND
one git index, so when I had my files staged and LANE-SIM committed with `-a`/`add -A`,
their commit swept mine in. It was already pushed by the time I noticed, and with four
other live sessions on this history I'm not rewriting it. **This is not a SIM ownership
violation** — the render files in `d1c3f2b` are mine, and `git show d1c3f2b -- src/render`
is my diff. PROPOSAL below on how to stop it recurring; it will, otherwise.
**SHIPPED** — all 4 orders, verified in the running build.
1. **Scram from truth.** `SCRAM_RELEASE` and the whole local latch are gone; the
renderer reads `EntityState.scrammed`. SIM wired the field mid-round (their
`state.scrammed` at index.ts:489/494) so this is verified against their real latch.
The crisp test: **heat 0.6 + `scrammed: true` → dead-dark, amber, idle frozen**, and
the same heat with `scrammed: false` → alive at body emissive 1.5 (= `1 + 0.6²×1.4`).
Same heat, opposite field, opposite visual — no threshold left in this lane to drift.
(`e.scrammed ?? heat >= 1` remains only as a bridge for a sim that hasn't set it; it
carries no constant.)
2. **Remove mode.** `setGhost('__remove', …)` → hazard-striped footprint decal + red
volume wash (`remove.ts`). Verified through UI's real flow: clicked their REMOVE tab,
`selectedBuild()` returned `{def:'__remove',dir:0}`, their hint bar switched to
"REMOVE ARMED · LMB DEMOLISH · ESC STAND DOWN", and hovering the mosh reactor lit its
**whole 3×3** (decal scale [3,3], wash [2.82,0.8,2.82], centred on the machine at
[5.5,9.5] — not the hovered tile). Bare ground shows nothing.
3. **`?showroom`** — 23 machines + 51 items on labelled plinths, camera preset, and it
lands MODELBEAST drops live (hot-swap + normalise + AnimationMixer + era skin all
already run through it). See the manifest below.
4. **Era skins** (`era.ts`) — plumbing, derived from data, verified live.
**VERIFIED**
- Perf standing rule holds: `?devscene=stress` → 500 entities + 2,000 items at
**3.3ms/frame**, 5 instanced draw calls. (R1 3.2 / R2 3.6 / R3 3.3 — topology, heat,
mixers, eras and remove-mode have cost nothing measurable.)
- **Era skins live across all five eras**, each matching its `ERA_SKIN` entry exactly:
reel telecine-puller `r0.65/m0.45` (sepia'd to #6e491f), broadcast field-loom
`r0.8/m0.1` (phosphor #2e5132), disc mosh-reactor `r0.2/m0.65` (gloss), fortress muxer
`r0.95/m0`. **Stream is a true identity skin** — quantizer chassis reads `#5e5148`,
byte-for-byte DATA's authored value, so nothing changed for the 16 stream machines.
- Prod bundle clean: `devscene` 0, `showroom` 0, `plinth` 0, `window.__render` 0 hits
(the 4 `__renderTarget` matches are three.js's own).
**DECISIONS**
- **`BeltItem.id` fallback retired.** Identity is now always the id, which also deleted
the per-frame sort of every belt item — the order-matching path only ever existed to
reconstruct identity the contract didn't carry.
- **The showroom fabricates a SimSnapshot** rather than hand-building display meshes, so
machines go through the real EntityLayer. A QA page with its own bespoke display path
would be testing itself instead of the renderer. Items aren't entities (no `asset`,
never hot-swap) so those are built directly — but from beltitems.ts's own factories,
so the showroom shows literally what rides the belts.
- **Camera preset frames the MACHINE hall, not everything.** A view wide enough to hold
all 51 items renders every machine too small to judge, which defeats the point. Items
are one pan south. Labels are collision-culled front-to-back: ~74 of them cannot all
fit at overview zoom, and a readable subset that resolves as you zoom beats a pile.
- **`__remove` is duplicated, not imported.** A renderer importing UI internals is the
wrong layering and there's no contract field yet. Unlike a tuning constant, drift here
fails loudly and visibly. Both lanes filed for a real hook (see request 1).
- Era skin touches the **chassis only** — the accent stays the colour of what the machine
makes, because that's the resource, not the decade.
**BLOCKED/BROKEN** — nothing blocking.
- **A dead debug hook leaked into prod and I nearly shipped it.** Hoisting the scaffold
check into a `debug` VARIABLE defeated Rollup's constant folding, so `window.__render
= {scene, gl, topo, …}` survived in `dist/` as unreachable dead code (the class itself
still tree-shook). Round 2's `if (devSceneEnabled())` folded because the call was
inline. Fixed by testing `import.meta.env.DEV` literally at the branch. Flagging the
shape, not just the fix: **any** scaffold guard that goes through a variable will do
this silently, and only grepping the bundle catches it.
- Merge pieces still don't route cargo per-inlet (needs `BeltItem.side`, deferred —
agreed).
- The showroom's item hall is laid out for 51 items; if DATA doubles them again the rows
will run past the world edge and want paging. Not urgent, noted so it isn't a surprise.
**CONTRACT REQUEST**
1. **`UIBus.setGhostMode(mode)` or a 4th `setGhost` arg** — LANE-UI filed the same one
independently (`src/ui/selection.ts` documents their half). Today remove-mode rides a
magic string through `selectedBuild()`, and main.ts fires a `place` for `__remove`
every click that the sim discards. It works and it's documented on both sides, but
it's a protocol living in two lanes' comments instead of in the contract.
2. Not urgent: nothing else. v3 granted both of round 2's asks and they both landed
clean.
**MODELBEAST WORK ORDER** — this is `?showroom`'s content, and every one of these is a
live hot-swap target today: drop `public/assets/models/<asset>` and it appears in ~10s,
no reload, no code. Normalisation means any export scale lands correctly; multi-mesh and
rigged+animated GLBs both work (verified round 2). Footprint is the tile budget.
| asset | era | fp | machine |
|---|---|---|---|
| `asic-cooler.glb` | broadcast | 2x2 | ASIC COOLER |
| `field-loom.glb` | broadcast | 3x2 | FIELD LOOM |
| `artifact-bottler.glb` | disc | 2x2 | ARTIFACT BOTTLER |
| `mosh-reactor.glb` | disc | 3x3 | MOSH REACTOR |
| `subsampler.glb` | disc | 2x3 | SUBSAMPLER |
| `muxer.glb` | fortress | 5x5 | THE MUXER |
| `telecine-puller.glb` | reel | 2x2 | TELECINE PULLER |
| `belt.glb` | stream | 1x1 | STREAM BELT |
| `bloom-duplicator.glb` | stream | 1x1 | P-FRAME DUPLICATOR |
| `buffer-tank.glb` | stream | 2x2 | BUFFER TANK |
| `dct-press.glb` | stream | 3x3 | DCT PRESS |
| `decode-asic.glb` | stream | 2x2 | DECODE ASIC |
| `demosaicer.glb` | stream | 3x2 | DEMOSAICER |
| `demuxer.glb` | stream | 2x2 | DEMUXER |
| `gop-assembler.glb` | stream | 3x2 | GOP ASSEMBLER |
| `hex-splicer.glb` | stream | 3x3 | HEX SPLICER |
| `lane-splitter.glb` | stream | 1x1 | LANE SPLITTER |
| `mv-extractor.glb` | stream | 2x2 | MV EXTRACTOR |
| `p-caster.glb` | stream | 2x2 | P-CASTER |
| `quantizer.glb` | stream | 2x2 | QUANTIZER |
| `seam-extractor.glb` | stream | 2x2 | SEAM EXTRACTOR |
| `shipper.glb` | stream | 2x2 | SCREEN UPLINK |
| `software-decoder.glb` | stream | 2x2 | SOFTWARE DECODER |
Hero assets worth `fal_trellis2` (codex §9): `muxer` (5x5, the rocket), `mosh-reactor`,
plus the Fortress kit when it exists. Everything else is `sf3d` territory.
**PROPOSAL — the shared index will keep eating commits.** Five sessions share one working
tree and one `.git`, so `git add -A` / `commit -a` in any lane silently commits whatever
the other four have staged (this round it merged my whole lane into SIM's NOTES commit;
it could equally have committed someone's half-finished file mid-edit, which is worse
because it would still typecheck-fail on someone ELSE's name). Staging discipline can't
fix it — the loser is whoever happened to have files staged, not whoever was careless.
Cheapest real fixes, in order:
1. **`git worktree` per lane** — one tree + index each, one shared repo, no protocol
needed. Lanes still see each other's commits after a pull.
2. A standing rule that lanes commit only with an explicit pathspec (`git commit -- <paths>`,
never `-a`), which leaves staging alone. Weaker: it's a convention, and this round
proves conventions lose races.
3. Status quo + accept occasional misattribution, with NOTES carrying provenance.
**NEXT (round 4 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; splitter lamp driven by real round-robin
state instead of a timer; bloom-loop belt tell; HF dust piles; wildlife; save/load
camera state; LOD/culling if a megabase ever drags the frame.
### Round 4 — 2026-07-17 — Opus 4.8
**SHIPPED** — all 4 orders + housekeeping, verified against LANE-DATA's real v4 data
(which landed mid-round: 29 machines, 60 items, `archaeology-lab`, `asic-cooler
coolRadius:2`, `accent` on 8 machines) and UI's real migrated flow.
1. **Ghost keys off `mode`.** The `'__remove'` sentinel is ignored — mode is the truth,
and the `REMOVE_DEF` constant is deleted from this lane. Verified through UI's own
REMOVE tab: `selection()``{mode:'remove'}`, demolition decal + wash on the mosh
reactor's whole 3×3.
2. **Locked-machine ghost.** Padlock-amber `#ffb02e` — deliberately not the occupied-red,
because "not yet" and "not here" are different sentences. A padlock floats above,
tinted by the era gating it. Verified: `telecine-puller` → amber ghost + `#d8a24a`
reel brass; `asic-cooler` → amber + `#5fd08a` broadcast phosphor.
- **Era tell needed its own palette.** `ERA_SKIN.tint` can't do this job: it's a
multiplier tuned to leave DATA's chassis alone (stream is literally `#ffffff`), so
three of five eras would have told you nothing. Added `ERA_TELL` — five hues chosen
to be told apart, used as signal rather than surface.
3. **Cooler auras.** Verified on `asic-cooler` (fp 2×2, coolRadius 2) → **6×6 tiles**,
centred on the ghost, dashed + tick-marked, no fill.
- **It's a rectangle, not a ring.** `coolRadius` is CHEBYSHEV from the footprint, so
the cooled region is the footprint grown by r on each side. A circle would have been
prettier and would have lied — the corners it left out are cooled, and players would
plan around a boundary that doesn't exist. Drawn in the geometry the sim measures in.
4. **Lab visuals.** `archaeology-lab` (kind `lab`, 3×3) renders as a bench + the "bottled
artifact triptych" (§2) — three out-of-phase pulsing bottles. Verified geometry
`Box,Cylinder,Cylinder,Cylinder` and **`accent` consumed from data**: bottles emissive
`#ffc24a`, exactly DATA's authored value.
5. **Housekeeping.** Splitter lamp left timer-driven as approved. Showroom absorbed the
new catalog — see the bug below.
**VERIFIED**
- Perf standing rule holds: 500 entities + 2,000 items at **3.6ms/frame**, 5 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 — auras, padlocks and the locked path
cost nothing measurable.)
- Showroom absorbs 29 machines + 60 items, all five eras present, hall inside the world.
**DECISIONS**
- **Matched LANE-UI's lock rule rather than my own, and it was a real divergence.** My
first cut treated absent `research` as "nothing is locked" — don't lie when the field
isn't wired. UI's `research.ts` reads the same rule the opposite way: nothing has
completed, so everything gated shows its padlock. **UI is right and I was wrong**: this
is the `scrammed` lesson misapplied. There, the field's absence meant the sim hadn't
wired a *behaviour*; here the rule is satisfiable from data alone and the contract says
"until that tech completes". Left unresolved, the build bar would have padlocked a
machine while the ghost waved it through — on the same screen. Also matched their
first-tech-wins tie-break for ids unlocked by two techs: a data bug either way, but we
must both blame the same tech.
- **Buffer fill now reads `bandwidth.capacity`** instead of re-summing `bufferCap`
locally. v4 publishes the total; a second copy of the sim's arithmetic in this lane is
exactly what the scram latch taught us not to keep. Falls back to the local sum only
while the field is absent.
- **`MachineDef.accent` wins outright, no legibility floor** — same reasoning as
`def.color`: explicit art-direction is a decision; the floor exists for *derived*
colours, which are incidental.
**BLOCKED/BROKEN** — nothing blocking.
- **The showroom bug I predicted last round arrived, and my fix was half a fix.** Round 3
noted the item hall would overflow when the catalog grew. It did (51 → 60 items), but
the failure wasn't the one I expected: the *machine* hall grew to 5 rows, ate the depth
budget, collapsed the item grid to **one row of 60 columns — a 206-tile row across a
64-tile world**. My round-3 "adaptive" fit budgeted depth only. Now fitted to both axes,
and it `console.warn`s if the catalog ever exceeds the hall rather than silently
stacking items off-world. Lesson: an auto-fit that constrains one axis just moves the
overflow to the other one.
- **"Placed-and-selected" auras aren't reachable** (order 3's third case). `SelectionState`
is `build | remove | null` — UI's click-to-inspect selection has no contract channel, so
a placed cooler rings on **hover**, not on inspect. Hover covers the same intent for
now; see CONTRACT REQUEST 1.
- Two tests fail repo-wide (`sim/reference.test.ts`, `ui/live.test.ts`) — both SIM/UI
in-flight research work, neither imports `src/render`. tsc is clean repo-wide.
**CONTRACT REQUEST**
1. **An inspected-entity channel**, if you want order 3's "placed-and-selected" case for
real. Cheapest shape: extend `SelectionState` with `{ mode: 'inspect'; entity: number }`,
or add `UIBus.inspected?(): number | null`. Renderer has no other way to know what the
player clicked. Low priority — hover already answers "what does this cool?".
2. Nothing else. v4 granted round 3's `setGhostMode` ask (as `setGhost(..., mode)`) and it
landed clean; the sentinel bridge can die in v5 whenever you like — this lane no longer
reads it.
**NOTE FOR THE MODELBEAST ORDER** — the manifest in round 3's NOTES is now stale: DATA
added 6 machines (`roto-bench`, `matte-bath`, `optical-printer`, `chroma-keyer`,
`neural-keyer`, `archaeology-lab`) and the tech tree now gates 13 across all five eras.
`?showroom` is the live manifest — it labels every machine with its exact `<asset>.glb`
filename and era, and re-reads data on load, so it can't go stale the way a table in a
doc does. Read it there rather than from round 3.
**NEXT (round 5 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as
*relief* inside a cooler's rectangle once SIM lands spatial cooling; bloom-loop belt
tell; HF dust piles; wildlife; save/load camera state; LOD/culling if a megabase ever
drags the frame.
### Round 5 (MEGA) — 2026-07-18 — Opus 4.8
**THE HEADLINE: "the game about place has no place" was a COLOR-MANAGEMENT BUG, not a
missing feature.** I wrote the whole floor — fbm terrain, era hinting, seam glints — ran
it, and the screen was still black. The shader was fine. `THREE.Color.setHex()` converts
sRGB → the linear working space, but a raw `ShaderMaterial` gets none of the renderer's
output includes, so the linear value went to the framebuffer untouched. Measured, not
guessed: crust authored `#0c0a15` (RGB 12,10,21) was rendering as **RGB (1,1,2)**; even a
mid-grey `#2a2440` came out (6,5,13). Everything this lane has drawn with a raw shader has
been ~6× too dark **since round 1** — the grid, the crust, all of it. One
`#include <colorspace_fragment>` in `ground.ts` and the floor measures (10,8,18) against
an authored (12,10,21). The void was never a design problem; it was a missing include.
That's why the "before/after" is so stark, and it's the single most valuable thing I found
this round.
**SHIPPED** — all 8 orders.
*Part A*
1. **Inspect highlight** — selection ring + cooler aura on `{mode:'inspect'}`. Verified:
inspecting a 2×2 extractor rings it at its centre with scale 2.5; inspecting the
asic-cooler shows ring **and** its 6×6 coverage rect. This closes the
"placed-and-selected" case round 4 couldn't reach.
2. **Wildlife** (`wildlife.ts`) — dust piles as HF-dust glitter heaps scaling with `size`;
mosquito swarms as pixel-gnat clouds orbiting `target`, with the harassed machine
stuttering (§8: wrongness of motion is a faction uniform). Two draw calls for the whole
ecosystem.
3. **Fixture hygiene** — devscene now fabricates `commissionQueue`, `research`,
`wildlife` and `relics`, plus an asic-cooler and archaeology-lab so the v4/v5 kinds are
reachable in the scaffold.
*Part B*
4. **THE FLOOR** (`ground.ts`, `seams.ts`) — fbm tone mottle + neutral rim + 8 data-driven
seam regions that skin the crust with their era and glint with their ore.
5. **THE RELIC** (`relics.ts`) — unfound = a breathing static patch; found = a fossil on a
plinth. Numbers below.
6. **JUICE** (`juice.ts`) — placement scale-pop + dust puff, demolition burst, shipment
flash. All derived from snapshot deltas, since `render()` never sees events.
7. **THE BREAK ROOM** (`breakroom.ts`) — GLYTCH arcade cabinet, own raycast, `cursor:
pointer` on hover, click → `window.open('./match.html')` **verified firing**.
8. **CAMERA + CLICKS** — default framing lifts world origin to screen y=264, clear of the
build bar at y=679. Picking investigation below — it found more than expected.
**VERIFIED (numbers)**
- **Relic is subtle-but-findable, measured at two zooms.** Brightest pixel on the relic vs
plain floor 7 tiles away: at wandering zoom (12) **+39** — findable. At overview zoom
(30) **18** — i.e. indistinguishable from floor mottle. You cannot spot it from above;
you have to be down there looking. That's the Mario-hidden-pipe budget, in numbers.
- **Perf holds with everything on**: 500 entities + 2,000 belt items + 50 piles + 198
gnats + 8 seam regions + the new floor = **4.4ms/frame**, 3.8× headroom, 8 instanced
draw calls. (R1 3.2 / R2 3.6 / R3 3.3 / R4 3.6 / R5 4.4.)
- **Juice**: puff → 14 particles decaying over ~0.67s; burst → 22; shipment flash fired 22
times tinted `#ff7a3f`, exactly MELT's authored colour — so the flash reads which item
shipped, not just that something did.
- Prod bundle: `devscene`/`showroom`/`plinth`/`window.__render` all 0 hits; the round-5
shipping code (seams, aura, relics, breakroom) present. 446 tests pass, tsc clean.
**DECISIONS**
- **Dropped my own era-rim guess when DATA contradicted it.** The orders asked for strata
whispers toward the world edges and I built exactly that — sepia north, phosphor east.
Then `data/seams.json` landed mid-round, its doc saying "RENDER: `era` skins the ground",
with reel in BOTH the north-west and north-east and disc both north-centre and
south-west. My directional guess would have contradicted the data on screen, so era is
now skinned per seam region with a wide soft falloff. It still reads as strata whispers
toward the rim — the richest seams live out there anyway.
- **Seam glint is flecks, not veins.** First pass drew continuous sinusoidal veins; on
screen they were bright green worms crawling over the floor, competing with the belts.
Mineral glitter is sparse and twinkles, so it's now a fleck grid with per-fleck sparkle
clocks. The body fade is deliberately SHORT (0.8 tiles): SIM extracts on any
footprint/rect overlap, and a glint dying two tiles inside the boundary would
under-report where mining actually works.
- **The cooler aura stays a rectangle** (round 4's reasoning) and the relic monument, the
cabinet and the seams all now carry `colorspace_fragment` for the same reason the ground
does.
- **Cabinet is rotated 45°** to face the iso camera and sized to its own marquee — at the
rig's closest zoom a 0.03-unit marquee pixel was sub-pixel and GLYTCH was a smudge.
**BLOCKED/BROKEN**
- **A frame-delta of 63.7 SECONDS was silently eating all juice.** `clock.getDelta()`
returns the real gap, and a backgrounded tab / alt-tab / GC pause / first frame after
load hands back seconds. Particles have a 0.5s TTL, so every effect was born and killed
inside one update — and a 63s integration step flings them to infinity. `render()` now
clamps dt to 0.05 (main.ts clamps the sim accumulator for the same reason; this is the
renderer's half). This also protects the scale-pop, camera pan and heat animation.
- **`match.html` only exists in `dist/`** (deploy.sh copies it there). In a dev server the
cabinet's click opens a 404. Not fixable from this lane — `public/` is mine but the file
is the deploy's. Flagged so nobody reports it as a cabinet bug.
- The devscene relic sits at (9,-7), which is NOT inside DATA's `relicReserve`
(12,12,20,20). That's fine for a fixture, but SIM's real relic should land in the
reserve — worth a check when SIM lands `relics`.
- The cabinet is decorative and reserves no tiles, so a player can place a machine
"inside" it. Cosmetic only.
**B8 PICKING INVESTIGATION — 27.6% of the viewport cannot be clicked**
I probed a 20px grid over the whole viewport (2,205 points) asking "does a click here
reach the game canvas?". 609 points — **27.6%** — do not. Breakdown:
| blocker | points | note |
|---|---|---|
| `#screen` | 153 (6.9%) | **decorative overlay, `pointer-events: auto`** |
| `#fk-build` + hint | 155 (7.0%) | build bar — legitimately interactive |
| `#fk-fax` + flavor | 90 (4.1%) | fax panel — legitimately interactive |
| `#fk-top` | 36 (1.6%) | bandwidth panel — legitimately interactive |
The one that is a **bug**: `#screen` is THE SCREEN's canvas in `index.html`, purely
decorative, 322×182 at top-centre, `z-index: 5`, and it takes pointer events. `#ui` next to
it correctly sets `pointer-events: none`. So a 6.9% rectangle of prime play area silently
swallows clicks — I hit it myself: the cabinet at (-5,-5) rendered *underneath* it and was
both invisible and unclickable, which is exactly the review's "machines spawned
unclickable". **Fix is one line in `index.html` (orchestrator-owned): add
`pointer-events: none` to `#screen`.** I moved the cabinet to (6,12) as a workaround, but
the dead zone is still there for machines.
Also seen: `#fk-screenview` (LANE-UI's theatre mode) is a full-viewport `pointer-events:
auto` host. Correctly `display:none` when closed — not a bug, noting it so the next person
who probes picking isn't confused by it the way I was.
**CONTRACT REQUEST**
1. **A renderer inspect channel.** `Renderer.setGhost`'s mode is only `build|remove` and
main.ts passes `'build'` during inspect, so nothing about the inspected entity reaches
this lane. `SelectionState` carries it, so I read `window.__fktryBus.selection()` each
frame as a bridge — same shape as the old `'__remove'` sentinel, documented on both
sides, and it wants a real hook: either widen `setGhost`'s mode to include `'inspect'`
with the entity id, or add `Renderer.setInspect(id | null)` that main.ts calls.
2. **`pointer-events: none` on `#screen`** in `index.html` (see above). Not my file.
3. Advisory: SIM doesn't gate extraction to seam rects yet, so the glint is currently a
cosmetic tell rather than a rule. The moment SIM lands the overlap check the floor
already teaches it — no renderer work needed.
**PROPOSAL — the shared tree cost me real time again.** Verification kept failing because
LANE-UI and LANE-SIM save `src/ui/*.ts` / `src/sim/*.ts` every few seconds and Vite
full-reloads my page, wiping every probe mid-measurement (the vite log shows a reload every
515s for minutes). I finished the round by snapshotting the repo to a scratch dir and
running an isolated dev server against it. That works, but it's a workaround for the same
root cause as round 3's commit collision: **one working tree, five writers.** A worktree
per lane fixes both.
**NEXT (round 6 candidates, not self-assigned)**
- Real MODELBEAST assets through `?showroom`; heat-shimmer suppression reading as *relief*
inside a cooler's rectangle once SIM lands spatial cooling; relic excavation VFX on the
`relicFound` event; bloom-loop belt tell; save/load camera state; LOD/culling if a
megabase ever drags the frame.
### Round 6 — 2026-07-20 — Opus 4.8
**SHIPPED** — all 3 orders, verified in the running build. The second asset wave landed
mid-round: **27 GLBs are now in `public/assets/models/`** (up from the 10 the order named).
Everything below is generic — it runs on *any* hot-swapped GLB through the registry, no
hardcoded list — so all 27 (and whatever lands next) got the treatment automatically.
1. **GLB legibility pass (centrepiece).** The two-material rule died on real assets: baked
albedo, zero emissive, near-black at game zoom (before/after below). Two halves, both
keyed off the SAME accent derivation placeholders already use, so a machine that made
melt still glows melt-orange whether it's a box or a GLB:
- **Texel-following emissive FLOOR** (`registry.ts floorMaterials`, run ONCE on each
GLB template in `tryLoad`): `emissiveMap = map`, `emissive = white`,
`emissiveIntensity = 0.42` — the baked albedo self-illuminates so the brightest texels
lift off the void without washing to flat grey. Materials with no map self-light their
own colour; materials that already ship a glow are left alone. EntityLayer still clones
these per instance, so per-entity heat/scram rides on top (heat lerps `emissive`→red;
the map keeps modulating).
- **Accent BEACON** (`addAccentBeacon`): the one impossible element, perched on top,
scaled to footprint, in the machine's derived/art-directed accent — plus a gentle idle
pulse so a GLB gets a heartbeat like a placeholder. The idle reads `beacon.material` at
call time, not a captured ref, because EntityLayer re-clones GLB materials per instance
(a closure over the pre-clone material would drive a mesh nothing renders — caught that
before it shipped). **DATA's round-6 accents were chosen against the real GLB textures,
so the beacons wear DATA's hand-picked colours.**
- Verified in `?showroom` (before: dark specks on plinths → after: lit chassis + colour-
coded beacons across the whole 27-machine catalog) AND in the live demo world at the
default game frame (`__fktryDemo()` reference factory, `rig.frame(3,6,15)` untouched):
every GLB legible, beacons marking each unit.
2. **Parity mites** (`wildlife.ts`, +2 instanced meshes). Hospital-white faceted beetles
with a green **checkmark eye** (canvas ✓ texture, tilted 45° to meet the iso camera).
The tell tracks SIM's `size` state channel exactly: `0` hunting (slow, patient patrol —
the wrongness is the calm), `0<size<1` on station (dwells, only a tremor; the eye
**glows up** via per-instance `instanceColor` as the correction completes), `size≥1`
sated. render() can't see events, so I derive the two mite cues from snapshot deltas:
- **`repaired`** = a mite crossing `size` <1 1: a tidy sparkle where the product used
to be. Verified deterministically driving a mite 0.61.0 emitted **exactly 4**
particles (one `Juice.tidy`).
- **caught in a firewall = filed paperwork** = a mite that vanishes from the list while
still unsated (sated ones leave at the rim carrying their fix, no cue). Verified:
removing an unsated mite fired the tidy sparkle (juice 48).
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfoundfound
transition, a slow tall golden geyser "sings" up out of the crust as the monument rises.
Gated on a live transition (a save that loads already-excavated doesn't fire it primed
like EntityLayer). Verified: flipping the fixture relic fired the geyser (juice pool
4→**89**) and built the monument.
**VERIFIED (perf)** honest caveat: the canonical full-frame CPU-ms sampler could not be
read reliably this round because the browser backgrounds the tab and starves the rAF loop
that drives `layer.sync` (same shared-tree/environment friction round 5 hit), so the
instanced hot-path meshes never fully populated for a passive read. What holds by
construction: the additions don't touch the terms that dominate the frame. Wildlife gained
2 InstancedMeshes (mite bodies + eyes 2 draw calls, only when mites exist, O(mites) capped
at 96); GLB legibility is one-time material params + one small beacon mesh per GLB *machine
instance*; the 2,000-item / 480-belt instanced path is untouched and no per-frame allocation
was added. Round 5's measured **4.4ms/frame** at full load stands as the reference.
**DECISIONS**
- **Floor is texel-following, not a flat wash.** A flat emissive floor guarantees "never a
hole" but greys the texture to mud and, since most GLBs export colour in the *map* with a
white base colour, would floor-glow everything white. `emissiveMap = map` keeps each
machine's baked colour and lifts its lit texels; the beacon + scene lighting cover the
rare all-dark texel. Trade-off noted honestly: a genuinely pure-black texel stays dark
(correct dark rubber should read darker than a metal panel); no machine reads as a hole
in practice (verified by eye at default zoom).
- **Beacon geometry is module-shared, material is per-instance.** GLB records don't own
geometry (`ownsGeo=false`) so the shared beacon geo is never disposed; the per-instance
material is (EntityLayer disposes all materials on drop regardless).
- **Mite state is read from `size`, not invented.** SIM documents `size` as the state
channel and `target` as the belt it converges on; I render that contract rather than a
timer, so the tell is true to the sim (hunting drifts, repairing dwells + glows, sated
leaves). The eye-glow ramp uses `instanceColor` (multiplies the checkmark map) no
shared-material emissive hack.
**BLOCKED/BROKEN** nothing blocking.
- **The mite "filed paperwork" cue is a heuristic, not the event.** render() has no event
channel, so "canned by a firewall" is inferred from a mite disappearing while `size<1`.
This is correct for the sim's current lifecycle (sated mites leave at the rim, far from
firewalls; canned ones vanish mid-repair near one), but it's an inference if a mite ever
despawns unsated for another reason, it'd read as a false "filed". A real renderer event
channel (see CONTRACT REQUEST) would make both mite cues and the excavation exact instead
of derived. Low risk today.
- The `firewall` machine (DATA, this round) has no GLB yet, so it shows the placeholder in
`?showroom` it'll hot-swap + get the floor/beacon treatment the moment its asset lands,
no code change.
- `match.html` 404 in dev (round 5, unchanged): the cabinet's target only exists in `dist/`.
**CONTRACT REQUEST**
1. **A renderer event channel** (renewing round 5's ask, now with two more consumers).
`Renderer` never receives `SimEvent[]`; ScreenFX and AudioFX do. This round I derived
`repaired`, the firewall "can", and `relicFound` from snapshot deltas all work, but
they're inferences that can drift from truth. Cheapest shape: add
`Renderer.onEvents(events: SimEvent[])` called by main.ts alongside the others. It would
make excavation fire on the actual `relicFound` and the tidy sparkle fire on the actual
`repaired` (with the event's own `pos`), retiring three heuristics. Everything else this
lane does stays snapshot-driven; this is specifically for one-shot cues.
2. Still open (round 5): `pointer-events: none` on `#screen` in `index.html` (orchestrator).
**PROPOSAL**
- The GLB legibility pass makes the MODELBEAST pipeline fully hands-off for legibility: any
asset dropped in textured, untextured, rigged, multi-mesh lands lit and identifiable
with zero per-asset tuning. The whole 27-machine catalog proves it. Hero re-cuts
(`muxer`, `mosh-reactor`) can arrive without a render change.
**NEXT (round 7 candidates, not self-assigned)**
- Wire the renderer event channel if granted (exacts the three derived cues above); a
`firewall.glb` when it lands; per-mite orientation toward its `target` belt; heat-shimmer
*relief* inside a cooler rectangle; bloom-loop belt tell; save/load camera state;
LOD/culling if a real megabase ever drags the frame.
### Round 6 ADDENDUM — 2026-07-20 — Opus 4.8 — GLB body-scale regression fix
**Orchestrator caught a deploy-blocking regression at 0d491f8: GLB machine BODIES collapsed
to specks (beacons + jam lights + labels still correct). Fixed in `entities.ts` commit
below. My round-6 verification missed it because I checked on an HMR'd session and wrongly
attributed the small bodies to "asset normalisation"; on a FRESH load the specks are stark.**
**ROOT CAUSE the placement-pop hardcoded the settled scale to 1, which is only true for
placeholders.** A normalised GLB roots at its fit-to-footprint scale `s` (measured live:
2.03.5 for the current wave). The pop's settle branch was
`else if (rec.obj.scale.x !== 1) rec.obj.scale.setScalar(1)` so on the very first sync it
saw `2.08 !== 1` and stomped the root to 1, shrinking the body to raw geometry size (~0.63
vs the correct 1.84 for a 2×2). The beacon and jam meshes carry their own local scale on
child nodes, so they were untouched which is exactly the "bodies vanished, beacons fine"
signature the orchestrator reported.
**Why it surfaced now, honestly:** the pop code is UNCHANGED by me and the bug is latent for
ANY non-placeholder settled scale 1. It bit this round because the live GLB wave normalises
to s23.5 (verified: fresh `entry.create()` returns root scale 2.08 + a correct 1.84-wide
body; the live post-pop root was scale 1 + a 0.63 speck). The pop only ever writes
`rec.obj.scale`, so it was provably the resetter. It is not the beacon a fresh create is
correct; the pop destroys it one frame later.
**FIX:** the pop is now a MULTIPLIER on a stored `rec.baseScale` (captured from `obj.scale.x`
at build = 1 for placeholders, the normalisation scale for GLBs), never an absolute. Settle
compares against `baseScale`, not 1. Placeholders: identical behaviour (baseScale 1). GLBs:
body stays full-size, and the placement scale-pop still plays (relative overshoot).
**VERIFIED on FRESH loads (hard navigate, no HMR), before/after described:**
- `?showroom`: BEFORE = every GLB a speck on a full plinth; AFTER = all 27 render as full
sculpted bodies filling their footprints, lifted + beaconed. Scene-graph confirms live
roots now carry scale 2.03.5 and body world sizes match footprints (1.84 for 2×2, 2.76
for 3×3), where they read 1.0 / 0.63 before the fix.
- `?uidemo` (reference factory, default game zoom): BEFORE = specks; AFTER = full-size
machines throughout, belts + cargo flowing. No console errors. `npm run check` clean,
`npm test` 481/481.
**LESSON (for me and the next lane):** verify visual regressions on a cold load, not an
HMR'd session an HMR'd renderer can hold meshes normalised in a prior module state and
mask a fresh-boot break. And a hardcoded `scale = 1` is a placeholder assumption that any
scaled asset (GLB, future LOD) will trip; base-relative is the correct shape.