[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). Dev scaffold still DEV-gated and absent from `dist/` (verified 0 hits).
**DECISIONS** **DECISIONS**
- **Scram is derived from `heat >= 1`**, not from the `scram` event — the `Renderer` - **Scram is a latch with hysteresis, mirroring SIM's — and this caught a real bug.**
interface only receives snapshots, never events, so the edge isn't reachable from SIM landed heat mid-round, so I read their code: `scrammed` lives on their internal
here. Works as long as SIM holds heat at 1.0 while latched off. See request 2. `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 - 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. 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. 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 - **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 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. (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). - 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** **CONTRACT REQUEST**
1. **Settle `MachineDef.color`'s meaning** (see the top of this entry). My proposal: 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 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 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. 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 2. **`EntityState.scrammed?: boolean`** — now CONFIRMED necessary, not speculative (see
cools a machine while it's still latched off, the visual un-scrams early and lies. DECISIONS). SIM's scram latch is invisible to the snapshot, and the only way I can
A flag makes it unambiguous and costs one bool. 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 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. 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"). */ /** Heat is throttled above this (contracts: "throttles >0.7, scrams at 1"). */
const THROTTLE_AT = 0.7; const THROTTLE_AT = 0.7;
const THROTTLE_FLOOR = 0.25; 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. */ /** Used when LANE-DATA hasn't set `bufferCap` yet — tanks still read as tanks. */
const DEFAULT_BUFFER_CAP = 100; const DEFAULT_BUFFER_CAP = 100;
const HEAT_RED = new THREE.Color(0xff3a12); const HEAT_RED = new THREE.Color(0xff3a12);
@ -52,6 +65,9 @@ interface Rec {
idleClock: number; idleClock: number;
heat: HeatTarget[]; heat: HeatTarget[];
all: 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; wasScrammed: boolean;
} }
@ -108,7 +124,10 @@ export class EntityLayer {
} }
const heat = THREE.MathUtils.clamp(e.heat ?? 0, 0, 1); 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". // Throttle ramps in rather than snapping, so "labouring" reads before "dead".
const speed = scrammed const speed = scrammed
? 0 ? 0
@ -239,6 +258,7 @@ export class EntityLayer {
idleClock: 0, idleClock: 0,
heat, heat,
all, all,
scramLatch: false,
wasScrammed: false, wasScrammed: false,
}; };
this.live.set(id, rec); this.live.set(id, rec);