From eb3b04efe34b928f115f0a927a0b1114c68b80f1 Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 31 Jul 2026 20:51:42 +0200 Subject: [PATCH] spice: seed INPUT_PULLUP pins to their resting level on pull enable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deterministic repro (emulation-gaps F4): after pinMode(2, INPUT_PULLUP) with a correctly-wired button-to-GND, all 8 setup()-time digitalReads and the first two loop() passes returned 0 — the input register only got its level from the first SPICE solve (~400 ms). That window is what fired phantom emergency-stop latches and auto-started state machines in audited sessions (and an unwired INPUT_PULLUP pin on the qemu bridges never got driven at all: no net in pinNetMap, connector skips it, guest read 0 forever). Fix: PinManager.onPullChange fires on pull transitions; AVRSimulator seeds its port input to the resting level instantly (real silicon does this in ns), and makePinPullHandler does the same for the qemu-bridge boards via the shim's setPinState. Sourced nets are still overridden by the very next solve, so the 'mis-wired button still works' regression guarded by the June work cannot return. --- frontend/src/simulation/AVRSimulator.ts | 13 +++++++++++++ frontend/src/simulation/PinManager.ts | 10 ++++++++++ frontend/src/store/useSimulatorStore.ts | 24 ++++++++++++++++++------ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 2e968c60..d29f472e 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -373,6 +373,19 @@ export class AVRSimulator { // register devices BEFORE the firmware loads. The real AVRTWI // takes over via `i2cBus.attachMaster(twi)` inside loadHex. this.i2cBus = new I2CBusManager(nullI2CMaster()); + // Seed the input register to the pull's resting level the instant the + // firmware enables it (INPUT_PULLUP -> HIGH). Real silicon does this in + // nanoseconds; without the seed, digitalRead returned 0 from pinMode + // until the first SPICE solve (~400 ms) — long enough for setup()-time + // button checks and emergency-stop latches to fire spuriously (2026-07 + // audit, reproduced deterministically). The SPICE connector overrides + // with the solved level on sourced nets right after, so a button held + // at boot still reads pressed within one solve. + pinManager.onPullChange = (pin, pull) => { + if (pull === 0) return; + if (pinManager.getOutputPins().has(pin)) return; + this.setPinState(pin, pull === 1); + }; } private get pwmPins() { diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 3d55fabe..040231e8 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -185,9 +185,19 @@ export class PinManager { * IO_MUX / pad config): 0 = none, 1 = pull-up, 2 = pull-down. The SPICE * collector reads this back via `getPinPull` to stamp a weak resistor. */ + /** Fired on pull-state TRANSITIONS (not repeats). Simulators use it to + * seed the pin input to the pull's resting level the moment the firmware + * enables it — closing the boot window where INPUT_PULLUP read LOW until + * the first SPICE solve (~400 ms): 8/8 setup() reads plus the first two + * loop() passes returned 0 in the deterministic repro, which is exactly + * the phantom emergency-stop latch of the 2026-07 audit. */ + onPullChange: ((pin: number, pull: 0 | 1 | 2) => void) | null = null; + setPinPull(pin: number, pull: 0 | 1 | 2): void { + const prev = this.pinPulls.get(pin) ?? 0; if (pull === 0) this.pinPulls.delete(pin); else this.pinPulls.set(pin, pull); + if (prev !== pull) this.onPullChange?.(pin, pull); } /** Internal pull config for a pin: 0 = none, 1 = pull-up, 2 = pull-down. */ diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 4a25e69b..aaf76f61 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -623,12 +623,24 @@ function makeGpioRoutingClearHandler(boardId: string) { function makePinPullHandler(boardId: string) { return (gpio: number, pull: 0 | 1 | 2) => { // Record the internal pull so the netlist stamps a weak resistor - // (vcc_rail for pull-up, GND for pull-down) and request a re-solve. The - // digital read itself is driven from the solved circuit by - // connectDigitalInputsToMcu — we deliberately do NOT seed the pin directly - // here, because that would bypass the real wiring and re-introduce the - // "mis-wired button still works" bug. - pinManagerMap.get(boardId)?.setPinPull(gpio, pull); + // (vcc_rail for pull-up, GND for pull-down) and request a re-solve. + const pm = pinManagerMap.get(boardId); + pm?.setPinPull(gpio, pull); + // Seed the guest input to the pull's RESTING level immediately (real + // silicon raises the pad in nanoseconds). Two failure modes this + // closes (2026-07 audit): (a) the boot window before the first SPICE + // solve where INPUT_PULLUP read LOW and phantom-triggered buttons, + // and (b) an INPUT_PULLUP pin with NOTHING wired — no net in + // pinNetMap, so connectDigitalInputsToMcu never drives it and the + // guest read 0 forever. On wired+sourced nets the connector overrides + // with the solved level right after, so the "mis-wired button still + // works" bug does NOT come back: the solve remains authoritative. + if (pull !== 0 && !(pm?.getOutputPins().has(gpio))) { + const sim = simulatorMap.get(boardId) as + | { setPinState?: (pin: number, state: boolean) => void } + | undefined; + sim?.setPinState?.(gpio, pull === 1); + } requestElectricalResolve(); }; }