[render] scram latches with hysteresis, mirroring sim (fixes a ~4s visual lie)

SIM landed heat this round, so I read their implementation: `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 earlier today) therefore un-scrams the
moment a machine starts cooling. With HEAT_RESTART=0.5 and dissipation
0.004/tick, the sim holds it dead for ~125 ticks (~4s) while heat falls 1.0 ->
0.5 — the renderer showed it alive, glowing and animating, that whole window.

Now latches at 1.0 and releases below SCRAM_RELEASE=0.5, mirroring them.
Verified: 1.0 dead -> 0.7 STAYS dead -> 0.3 releases and recovers.

This duplicates SIM's constant, which is the wrong place for it to live —
EntityState.scrammed is filed as a contract request with the evidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-17 17:47:15 +10:00
parent 30deed10dc
commit dc1591d0e6
2 changed files with 55 additions and 13 deletions

View File

@ -211,9 +211,19 @@ literal reading, it's a two-line revert in `registry.ts` (`bodyFor`/`accentFor`)
Dev scaffold still DEV-gated and absent from `dist/` (verified 0 hits).
**DECISIONS**
- **Scram is derived from `heat >= 1`**, not from the `scram` event — the `Renderer`
interface only receives snapshots, never events, so the edge isn't reachable from
here. Works as long as SIM holds heat at 1.0 while latched off. See request 2.
- **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.
@ -228,22 +238,34 @@ Dev scaffold still DEV-gated and absent from `dist/` (verified 0 hits).
- **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.
- **`reference.ts` doesn't exist** — SIM hadn't landed it when I built, so the DoD's
"reference factory looks ALIVE" was checked against my own devscene bend + showroom
row instead. Happy to re-verify against theirs next round.
- SIM doesn't emit heat/splitters/ids yet, so orders 2/4/5 are verified against the
devscene, not live sim data. All of it is contract-shaped and will light up as SIM
lands; nothing in my lane needs to change.
- 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`** — I derive scram from `heat >= 1`. If SIM ever
cools a machine while it's still latched off, the visual un-scrams early and lies.
A flag makes it unambiguous and costs one bool.
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.

View File

@ -21,6 +21,19 @@ import { createHeatHaze, type HeatHaze } from './heat';
/** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25;
/**
* Scram releases below this mirroring LANE-SIM's `HEAT_RESTART`.
*
* Scram is a LATCH in the sim, but it lives on its internal `Ent`, never on
* `EntityState`; it clears `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, and a plain `heat >= 1` test un-scrams the moment the
* machine starts cooling showing it alive and glowing for the ~125 ticks (~4s) it
* spends dead between 1.0 and HEAT_RESTART. Mirroring the sim's hysteresis here is
* correct today but couples this constant to theirs; `EntityState.scrammed` would kill
* the duplication. Filed as a CONTRACT REQUEST.
*/
const SCRAM_RELEASE = 0.5;
/** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12);
@ -52,6 +65,9 @@ interface Rec {
idleClock: number;
heat: HeatTarget[];
all: HeatTarget[];
/** Latched scram state, mirroring the sim's (see SCRAM_RELEASE). */
scramLatch: boolean;
/** Whether dead-dark is currently applied, so restart knows to restore. */
wasScrammed: boolean;
}
@ -108,7 +124,10 @@ export class EntityLayer {
}
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1);
const scrammed = heat >= 1;
// Latch with hysteresis, exactly as the sim does — see SCRAM_RELEASE.
if (heat >= 1) rec.scramLatch = true;
else if (heat < SCRAM_RELEASE) rec.scramLatch = false;
const scrammed = rec.scramLatch;
// Throttle ramps in rather than snapping, so "labouring" reads before "dead".
const speed = scrammed
? 0
@ -239,6 +258,7 @@ export class EntityLayer {
idleClock: 0,
heat,
all,
scramLatch: false,
wasScrammed: false,
};
this.live.set(id, rec);