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>
805 lines
53 KiB
Markdown
805 lines
53 KiB
Markdown
# 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.089–0.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
|
||
5–15s 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.6→1.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 4→8).
|
||
|
||
3. **Excavation VFX** (`relics.ts` + `Juice.excavate`). On the relic's unfound→found
|
||
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.0–3.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 s≈2–3.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.0–3.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.
|