feat(sim): custom chips read inputs (buttons/switches/sensors) with no board

The chip-output board-less path existed (chipPinDrives -> SPICE voltage sources
-> LEDs). The INPUT direction was missing: a chip pin wired to a pushbutton had
its net solved by ngspice, but nothing fed that net's state back to the
PinManager key the chip reads via vx_pin_read. So a board-less chip could light
LEDs but never read a button (verified: i8080 counter stayed at 0 on press).

connectChipInputsToSolve subscribes to the electrical store and, after each
solve, thresholds every wired chip input pin's net voltage to HIGH/LOW and
triggerPinChange()s the chip's synthetic pin — updating getPinState (polling)
and firing onPinChange edges. Pins the chip is actively driving are skipped so
it never fights its own outputs. Hooked alongside connectAnalogInputsToMcu in
start.ts. Solver-agnostic; reads only the electrical store shape.

Also gives the board-less button examples a pull-down on each chip BTN pin so
they read a clean LOW when open (a button-to-VCC floats HIGH otherwise):
i8080-button-counter (2) and i8080-killbits (8).

- new connectChipInputsToSolve.ts; start.ts wiring.
- examples-retro-intel: pull-down resistors + wires for the button examples.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-04 05:09:51 +02:00
parent 694f038988
commit dd62b94c1d
3 changed files with 157 additions and 1 deletions

View File

@ -314,7 +314,8 @@ export const retroIntelExamples: ExampleProject[] = [
y: 110 + i * 50,
properties: { color: i < 4 ? 'red' : 'orange' },
})),
// 2 buttons
// 2 buttons, each with a pull-down so the chip pin reads a clean LOW
// when the button is open (the button ties the pin to +5V when pressed).
{
type: 'wokwi-pushbutton',
id: 'btn-inc',
@ -329,6 +330,20 @@ export const retroIntelExamples: ExampleProject[] = [
y: 470,
properties: { color: 'red' },
},
{
type: 'wokwi-resistor',
id: 'rpd-inc',
x: 300,
y: 580,
properties: { value: '10000' },
},
{
type: 'wokwi-resistor',
id: 'rpd-rst',
x: 480,
y: 580,
properties: { value: '10000' },
},
],
wires: [
{
@ -387,6 +402,31 @@ export const retroIntelExamples: ExampleProject[] = [
end: { componentId: 'psu', pinName: 'SIG' },
color: '#e74c3c',
},
// Pull-downs: chip BTN pin -> 10k -> GND (clean LOW when not pressed)
{
id: 'pd-inc-sig',
start: { componentId: 'rpd-inc', pinName: '1' },
end: { componentId: 'i8080c', pinName: 'BTN_INC' },
color: '#000000',
},
{
id: 'pd-inc-gnd',
start: { componentId: 'rpd-inc', pinName: '2' },
end: { componentId: 'psu', pinName: 'GND' },
color: '#000000',
},
{
id: 'pd-rst-sig',
start: { componentId: 'rpd-rst', pinName: '1' },
end: { componentId: 'i8080c', pinName: 'BTN_RST' },
color: '#000000',
},
{
id: 'pd-rst-gnd',
start: { componentId: 'rpd-rst', pinName: '2' },
end: { componentId: 'psu', pinName: 'GND' },
color: '#000000',
},
],
},
@ -448,6 +488,14 @@ export const retroIntelExamples: ExampleProject[] = [
y: 110 + i * 50,
properties: { color: 'green' },
})),
// Pull-downs so each chip BTN pin reads a clean LOW when not pressed.
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
type: 'wokwi-resistor',
id: `rpd-${i}`,
x: 1240,
y: 110 + i * 50,
properties: { value: '10000' },
})),
],
wires: [
{
@ -492,6 +540,19 @@ export const retroIntelExamples: ExampleProject[] = [
end: { componentId: 'psu', pinName: 'SIG' },
color: '#e74c3c',
})),
// Pull-downs: each chip BTN pin -> 10k -> GND
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
id: `pd-${i}-sig`,
start: { componentId: `rpd-${i}`, pinName: '1' },
end: { componentId: 'i8080cpu', pinName: `BTN${i}` },
color: '#000000',
})),
...[0, 1, 2, 3, 4, 5, 6, 7].map((i) => ({
id: `pd-${i}-gnd`,
start: { componentId: `rpd-${i}`, pinName: '2' },
end: { componentId: 'psu', pinName: 'GND' },
color: '#000000',
})),
],
},

View File

@ -0,0 +1,92 @@
/**
* connectChipInputsToSolve feed solved net voltages back into custom-chip
* INPUT pins, so a chip can read buttons / switches / sensors with no board.
*
* A custom chip already drives its OUTPUT pins into the netlist (chipPinDrives
* -> SPICE voltage sources -> LEDs light). The INPUT direction was missing: a
* chip pin wired to a pushbutton / switch / sensor had its net solved by
* ngspice, but NOTHING wrote that net's state back to the PinManager key the
* chip reads via `vx_pin_read`. So a chip could light LEDs board-less but never
* read an input without an Arduino driving the pin.
*
* After every solve we look up each wired chip pin's net voltage, threshold it
* to HIGH/LOW, and `triggerPinChange()` the chip's synthetic pin which both
* updates `getPinState` (polling reads) and fires the chip's `onPinChange` edge
* handlers. Pins the chip is actively DRIVING are skipped so we never fight its
* own outputs. This is the input counterpart of the chip-output SPICE path and
* is solver-agnostic it reads only the electrical store shape.
*/
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { useElectricalStore } from '../../store/useElectricalStore';
import { syntheticChipPin } from '../customChips/syntheticPins';
import { getChipDrivenPins } from '../customChips/chipPinDrives';
// 5V-logic thresholds with a hysteresis band so a node hovering near the
// midpoint doesn't chatter HIGH/LOW every solve. Digital inputs (a button to
// VCC with a pull-down) swing fully, so the band is rarely entered.
const V_HIGH = 3.0;
const V_LOW = 2.0;
/** Pin names declared by a chip.json (entries may be strings or {name,...}). */
function chipPinNames(chipJsonStr: string): string[] {
try {
const obj = JSON.parse(chipJsonStr);
if (Array.isArray(obj.pins)) {
return obj.pins
.map((p: unknown) =>
typeof p === 'string' ? p : String((p as { name?: string } | null)?.name ?? ''),
)
.filter(Boolean);
}
} catch {
/* malformed chip.json — no readable pins */
}
return [];
}
export function connectChipInputsToSolve(): () => void {
// Last logic level written per synthetic pin, so we only emit real edges
// (and so the hysteresis band can hold the previous level).
const lastState = new Map<number, boolean>();
function writeChipInputs() {
const { nodeVoltages, pinNetMap } = useElectricalStore.getState();
const sim = useSimulatorStore.getState();
const pinManager = sim.pinManager;
if (!pinManager) return;
for (const comp of sim.components) {
if (comp.metadataId !== 'custom-chip') continue;
const props = comp.properties as Record<string, unknown>;
const names = chipPinNames(String(props.chipJson ?? '{}'));
if (names.length === 0) continue;
// Pins the chip is currently driving as outputs — never overwrite those.
const driven = new Set(getChipDrivenPins(comp.id).map((d) => d.pin));
for (const pinName of names) {
if (driven.has(pinName)) continue;
const net = pinNetMap.get(`${comp.id}:${pinName}`);
if (!net) continue;
const v = nodeVoltages[net];
if (v == null) continue;
const synth = syntheticChipPin(comp.id, pinName);
const prev = lastState.get(synth);
let next: boolean;
if (v >= V_HIGH) next = true;
else if (v <= V_LOW) next = false;
else next = prev ?? false; // inside the hysteresis band — hold
if (prev === next) continue;
lastState.set(synth, next);
pinManager.triggerPinChange(synth, next);
}
}
}
const unsub = useElectricalStore.subscribe((state, prev) => {
if (state.nodeVoltages !== prev.nodeVoltages) writeChipInputs();
});
// Initial pass for examples that pre-populate the store before mount.
writeChipInputs();
return () => unsub();
}

View File

@ -30,6 +30,7 @@ import {
type ElectricalSnapshot,
} from './CircuitSimulationService';
import { connectAnalogInputsToMcu } from './connectAnalogInputsToMcu';
import { connectChipInputsToSolve } from './connectChipInputsToSolve';
import { connectMcuEdgesToService } from './connectMcuEdgesToService';
import { setElectricalResolveHook } from './electricalResolveHook';
import { collectPinStates } from './collectPinStates';
@ -81,6 +82,7 @@ export function startSimulation(): () => void {
const unsubService = service.start();
const unsubAdc = connectAnalogInputsToMcu();
const unsubChipIn = connectChipInputsToSolve();
const unsubEdges = connectMcuEdgesToService(service);
// Let custom chips request a re-solve when they toggle an output pin, so
@ -136,6 +138,7 @@ export function startSimulation(): () => void {
setElectricalResolveHook(null);
unsubService();
unsubAdc();
unsubChipIn();
unsubEdges();
};
}