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:
parent
65b2c02f9b
commit
4cb5748dce
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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();
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue