feat(custom-chip): make chip pins first-class circuit nodes (digital + SPICE)

A custom-chip output pin wired directly to a component (LED, resistor, ...)
had no Arduino pin on its net, so the chip could drive nothing and the pin
resolved to null. Now:

- Layer A (digital): such chip pins get a stable synthetic pin number
  (syntheticPins.ts). traceDetailed resolves a chip<->component net to that
  shared number, so the chip's PinManager drive reaches the wired components
  through the existing digital event flow. A real board pin still wins.
- Layer B (analog/SPICE): a custom-chip mapper in componentToSpice emits a DC
  voltage source on each driven output pin's net (recorded in chipPinDrives by
  ChipRuntime), exactly like a board GPIO, and the chip requests an electrical
  re-solve when it toggles a pin (electricalResolveHook -> service.tick).
  So LEDs / resistors / analog parts wired to a chip output are driven by
  ngspice too.

This makes the bundled Z80 / i8080 chip examples actually animate their LEDs,
and lets any custom chip drive components, passives and analog circuits from
its own pins. Non-chip circuits are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-06-03 05:40:49 +02:00
parent 65b2c02f9b
commit 4cb5748dce
8 changed files with 132 additions and 1 deletions

View File

@ -25,6 +25,7 @@ import {
type PinResolver,
} from '../simulation/PinResolver';
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
@ -99,11 +100,19 @@ for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// Custom-chip output pins get stable synthetic pin numbers from
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
// through passive components to reach a board pin. Returns the arduino pin
// plus a `crossedActiveDevice` flag so the resolver factory can decide
// between digital fast-path and SPICE-resolved per-pin.
//
// A real board pin always wins (digital GPIO semantics are unchanged). Only
// when NO board pin is reachable do we fall back to a custom-chip pin on the
// net — either a neighbour chip pin, or (when the trace itself started at a
// chip pin) the starting chip pin — resolving it to its synthetic number.
//
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
// can call it too — the previous nested-scope version caused a runtime
// ReferenceError "traceDetailed is not defined" on the simulator page.
@ -122,6 +131,10 @@ function traceDetailed(
(w.end.componentId === fromId && w.end.pinName === fromPin),
);
// Remember a custom-chip neighbour on this net (if any) as a fallback —
// a real board pin found in any branch still takes priority over it.
let chipNeighbour: { id: string; pin: string } | null = null;
for (const w of wires) {
const selfEp =
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
@ -135,6 +148,9 @@ function traceDetailed(
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
} else {
const comp = state.components.find((c) => c.id === otherEp.componentId);
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
}
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
if (pair) {
const [p1, p2] = pair;
@ -152,6 +168,18 @@ function traceDetailed(
}
}
}
// No board pin reachable. Fall back to a custom-chip pin on this net so the
// chip can still drive / read it through the synthetic-pin PinManager key.
if (chipNeighbour) {
return {
arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin),
crossedActiveDevice: activeSeen,
};
}
if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') {
return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen };
}
return { arduinoPin: null, crossedActiveDevice: activeSeen };
}

View File

@ -10,6 +10,9 @@ import type { PinManager } from '../PinManager';
import type { I2CBusManager } from '../I2CBusManager';
import { SPIBus, SPIDevice } from './SPIBus';
import { WasiShim, type SimNanosFn, type WriteStdoutFn } from './WasiShim';
import { setChipPinDrive } from './chipPinDrives';
import { isSyntheticChipPin } from './syntheticPins';
import { requestElectricalResolve } from '../spice/electricalResolveHook';
function readCString(memory: WebAssembly.Memory, ptr: number): string {
const u8 = new Uint8Array(memory.buffer);
@ -133,8 +136,14 @@ export interface ChipInstanceOptions {
* Used by CPU-emulator chips that load their program from a project file
* instead of hard-coding it as a C byte array. */
romBytes?: Uint8Array | null;
/** Canvas component id of this chip. Used to key its SPICE pin sources so
* the analog engine drives the nets wired to the chip's output pins. */
componentId?: string;
}
/** Logic-high voltage a chip output pin asserts on its SPICE net. */
const CHIP_OUTPUT_VCC = 5;
export class ChipInstance {
static MODE_OUTPUT_LOW = 16;
static MODE_OUTPUT_HIGH = 17;
@ -146,6 +155,7 @@ export class ChipInstance {
private wires: Map<string, number>;
private attrs: Map<string, number>;
private display: { width: number; height: number } | null;
private componentId: string;
memory: WebAssembly.Memory | null = null;
instance: WebAssembly.Instance | null = null;
@ -187,6 +197,7 @@ export class ChipInstance {
this.attrs = opts.attrs ?? new Map();
this.display = opts.display ?? null;
this._romBytes = opts.romBytes ?? new Uint8Array(0);
this.componentId = opts.componentId ?? '';
this.wasi = new WasiShim(
opts.simNanos ?? (() => 0n),
@ -342,15 +353,40 @@ export class ChipInstance {
// ── Pin implementations ──────────────────────────────────────────────────
/**
* Mirror an output pin's logic level into the SPICE chip-source registry and
* request a re-solve when it changes so LEDs / analog parts wired to a chip
* output light up through ngspice, not just the digital PinManager path.
* Only synthetic chip pins (chip wired directly to components, no board GPIO
* on the net) are emitted as chip sources; a chip pin wired to a real board
* pin is already driven by that board's voltage source.
*/
private _syncSpiceDrive(p: PinEntry): void {
if (!this.componentId || !p.name) return;
if (p.arduinoPin == null || !isSyntheticChipPin(p.arduinoPin)) return;
const isOutput =
p.mode === ChipInstance.MODE_OUTPUT_LOW || p.mode === ChipInstance.MODE_OUTPUT_HIGH;
const changed = isOutput
? setChipPinDrive(
this.componentId,
p.name,
this.pinManager.getPinState(p.arduinoPin) ? CHIP_OUTPUT_VCC : 0,
)
: setChipPinDrive(this.componentId, p.name, null);
if (changed) requestElectricalResolve();
}
private _pin_register(namePtr: number, mode: number): number {
const name = readCString(this.memory!, namePtr);
const handle = this.pins.length;
const arduinoPin = this.wires.has(name) ? this.wires.get(name)! : null;
this.pins.push({ name, mode, arduinoPin });
const p: PinEntry = { name, mode, arduinoPin };
this.pins.push(p);
if (arduinoPin != null) {
if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(arduinoPin, false);
if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(arduinoPin, true);
}
this._syncSpiceDrive(p);
return handle;
}
@ -364,6 +400,7 @@ export class ChipInstance {
const p = this.pins[handle];
if (!p || p.arduinoPin == null) return;
this.pinManager.triggerPinChange(p.arduinoPin, value !== 0);
this._syncSpiceDrive(p);
}
private _pin_read_analog(handle: number): number {
@ -386,6 +423,7 @@ export class ChipInstance {
if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(p.arduinoPin, false);
if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(p.arduinoPin, true);
}
this._syncSpiceDrive(p);
}
private _pin_watch(handle: number, edge: number, cbIdx: number, userData: number): void {

Binary file not shown.

Binary file not shown.

View File

@ -19,6 +19,8 @@ import {
detectSimulatorKind,
} from '../customChips';
import { useSimulatorStore } from '../../store/useSimulatorStore';
import { clearChipDrives } from '../customChips/chipPinDrives';
import { requestElectricalResolve } from '../spice/electricalResolveHook';
PartSimulationRegistry.register('custom-chip', {
attachEvents: (_element, simulator, getArduinoPin, componentId) => {
@ -156,6 +158,7 @@ PartSimulationRegistry.register('custom-chip', {
const wasm = decodeWasmBase64(wasmBase64);
const inst = await ChipInstance.create({
wasm,
componentId,
pinManager: sim.pinManager,
// Polymorphic I2C: AVR returns the I2CBusManager directly, RP2040
// returns a thin adapter, ESP32 returns null (chip won't get I2C).
@ -219,6 +222,10 @@ PartSimulationRegistry.register('custom-chip', {
if (uartListener) bridges.uartListeners.delete(uartListener);
if (instance) instance.dispose();
instance = null;
// Drop this chip's SPICE voltage sources so a stopped chip stops
// driving its nets, and re-solve so the LEDs fall dark.
clearChipDrives(componentId);
requestElectricalResolve();
};
},
});

View File

@ -15,6 +15,7 @@
import type { ComponentForSpice } from './types';
import { parseValueWithUnits } from './valueParser';
import { LM358_SUBCKT } from './models/lm358Subckt';
import { getChipDrivenPins } from '../customChips/chipPinDrives';
export interface SpiceEmission {
/** One or more netlist lines (without trailing newline). */
@ -102,6 +103,27 @@ function ntcResistance(Tc: number, R0 = 10_000, T0 = 298.15, beta = 3950): numbe
// ── Mappers (one per metadataId) ───────────────────────────────────────────
const MAPPERS: Record<string, Mapper> = {
// Custom chip — emit a DC voltage source for every pin the chip's WASM is
// currently driving as an output (recorded in customChips/chipPinDrives).
// This makes the chip a first-class SPICE source on its nets, so LEDs,
// resistors and analog parts wired straight to a chip output pin are driven
// by the engine — exactly like a board GPIO. Chip pins wired to a real board
// pin resolve to that board's source instead and aren't recorded here.
'custom-chip': (comp, netLookup) => {
const driven = getChipDrivenPins(comp.id);
if (driven.length === 0) return null;
const cid = String(comp.id).replace(/[^A-Za-z0-9_]/g, '_');
const cards: string[] = [];
for (const { pin, voltage } of driven) {
const net = netLookup(pin);
if (!net || net === '0' || net === 'vcc_rail') continue;
const pid = String(pin).replace(/[^A-Za-z0-9_]/g, '_');
cards.push(`V_${cid}_${pid} ${net} 0 DC ${voltage}`);
}
if (cards.length === 0) return null;
return { cards, modelsUsed: new Set() };
},
// Passive — Velxio existing parts
resistor: (comp, netLookup) => {
const pins = twoPin(comp, netLookup, '1', '2');

View File

@ -0,0 +1,26 @@
/**
* electricalResolveHook lets non-SPICE code request an electrical re-solve
* without importing the service singleton (which would create a dependency
* cycle: customChips -> spice service -> store -> components -> customChips).
*
* `start.ts` registers the service's `tick()` here on mount; a custom chip
* calls `requestElectricalResolve()` when it toggles an output pin so the
* netlist is rebuilt with the chip's new pin voltages and the LEDs / analog
* parts on its net update. The service coalesces overlapping ticks, so this is
* safe to call frequently.
*/
let hook: (() => void) | null = null;
export function setElectricalResolveHook(fn: (() => void) | null): void {
hook = fn;
}
export function requestElectricalResolve(): void {
if (!hook) return;
try {
hook();
} catch {
/* a failed solve must never break the chip's execution loop */
}
}

View File

@ -31,6 +31,7 @@ import {
} from './CircuitSimulationService';
import { connectAnalogInputsToMcu } from './connectAnalogInputsToMcu';
import { connectMcuEdgesToService } from './connectMcuEdgesToService';
import { setElectricalResolveHook } from './electricalResolveHook';
import { collectPinStates } from './collectPinStates';
/** Adapt useElectricalStore to the ElectricalStorePort. */
@ -82,6 +83,14 @@ export function startSimulation(): () => void {
const unsubAdc = connectAnalogInputsToMcu();
const unsubEdges = connectMcuEdgesToService(service);
// Let custom chips request a re-solve when they toggle an output pin, so
// their SPICE voltage sources (emitted by the custom-chip mapper) are
// refreshed and LEDs / analog parts on the chip's nets update. The service
// coalesces overlapping ticks, so frequent chip toggles are cheap.
setElectricalResolveHook(() => {
void service.tick();
});
// Phase 1d #16 — debug helper. Call `__spiceDebug()` from DevTools
// to get a snapshot of the simulation state (analysis mode, voltage
// count, pin map, last solve time, etc.). Useful for diagnosing
@ -124,6 +133,7 @@ export function startSimulation(): () => void {
};
return () => {
setElectricalResolveHook(null);
unsubService();
unsubAdc();
unsubEdges();