spice: seed INPUT_PULLUP pins to their resting level on pull enable

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.
This commit is contained in:
David Montero 2026-07-31 20:51:42 +02:00
parent fc048ff941
commit eb3b04efe3
3 changed files with 41 additions and 6 deletions

View File

@ -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() {

View File

@ -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. */

View File

@ -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();
};
}