From 4cb5748dcef55e5a42a7c6d9a3d939a4087c0476 Mon Sep 17 00:00:00 2001 From: David Montero Date: Wed, 3 Jun 2026 05:40:49 +0200 Subject: [PATCH] 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 --- frontend/src/components/DynamicComponent.tsx | 28 ++++++++++++ .../src/simulation/customChips/ChipRuntime.ts | 40 +++++++++++++++++- .../simulation/customChips/chipPinDrives.ts | Bin 0 -> 1926 bytes .../simulation/customChips/syntheticPins.ts | Bin 0 -> 1571 bytes .../src/simulation/parts/CustomChipPart.ts | 7 +++ .../src/simulation/spice/componentToSpice.ts | 22 ++++++++++ .../simulation/spice/electricalResolveHook.ts | 26 ++++++++++++ frontend/src/simulation/spice/start.ts | 10 +++++ 8 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 frontend/src/simulation/customChips/chipPinDrives.ts create mode 100644 frontend/src/simulation/customChips/syntheticPins.ts create mode 100644 frontend/src/simulation/spice/electricalResolveHook.ts diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 57d6e023..8a99e737 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -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; +// 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 }; } diff --git a/frontend/src/simulation/customChips/ChipRuntime.ts b/frontend/src/simulation/customChips/ChipRuntime.ts index b189b3a6..04901a67 100644 --- a/frontend/src/simulation/customChips/ChipRuntime.ts +++ b/frontend/src/simulation/customChips/ChipRuntime.ts @@ -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; private attrs: Map; 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 { diff --git a/frontend/src/simulation/customChips/chipPinDrives.ts b/frontend/src/simulation/customChips/chipPinDrives.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac7b14dd22f2e2915f824c7360bc0082f70874f0 GIT binary patch literal 1926 zcma)-(M}^r5JmUdUr`S$?F=&ZJA{=GY!al9wSX1UA|$tG$}=suyGPwU28*?nkH{D1 zOLD7c#yAKm`-FwNs&Acpt35qB8qpD5)ViTYTPnTYFnO+Yh<;1|{Pj1*nrY#6S>X@I zCuU2EiZ(>IJ}yxADbW4-?e%dgwbISCv1z093#4qBI^52#F1`>OE~3|kGQ_shmM1tb zCHM8)ww)AdKwrPz&A#0weVHh|X>HVN&c~!9v=+{nAzgwSKLu&GaV9DVuGurKc*`X% zwlx3vB7(2V-O0xnoc&X+c{iWX9VYDbS&jyi)1uX;WZyNL9}GLEwQ3snl!ZnYJT2V1 zah7eobGP!39C}ozCNM=uyoN?amlub!f@^)j#d>gVh6~xsG)ssDYdSka9du$O(vZ

1b3q3j@+bfzCkWiLO=i3*xA4H747x7wkW0`RQo1 zY;BR0;?}Hl_JoFmy}!dh^5Fx$j0lio>+L}xJ{$z%bhI0drrl!Pq;yf{JQaqO&oNFy z{+e;v*75H{H=&sdf#$X~W-bud%CCf^fEFf7m{@1i#$c$9!h~*;C?wgpOnRB%?>7;V zXq7=KidxxgcX9MFkIQ%QrXyt+VgR6X2W?$n4cSmB<$e@ju%KE z?7fub|38r9VYyCvFX#t87z4ly=fKD&!kvvPIQVMLSjLy7gLxzqX2YH$3aLjb-P_ZJ zU{d1Mh@7ob)vmAXzpFUn3q!!>_n12CB;>g)^Q37* zo%Ivol)?MgAD9?C@3F=U-8+0j8`|v+?75@I9{aD(0~hgz@w1ae93aR3;ipN5#^F(& zQqb$xsI>Wb>6-jq_-S4D_}@Xlak~5&xCfY4Lg?IkwVf=zTW5W;knoV@fbs&WU9jJW L)}Mb9=-udFgr?^m=gb*ThQk4dSnZT6$;k|jR8VcbA!D)C23c#Ju18rZ8|cltKNJBYJ7U%QX`v1>x8UmTF8?8t=Bs4C@{NzSo-N;czdFsJRVWBiOT9_RF zEf*i46{O>bh1|$%VqjNFRq($? zw*@ydRxS(aM1`ePr$UK>%ov{tg|DY;GCSBdEB9dD8v*OYrXtwiEIa#n`gVSPh^;UX z8_oSf?U)*aM>*W(Md>0Xbg`HzhxxtICJ$v@3v6FgZfYF0rGwiG5PWG{cA zjw{;X``2%%#Z?qa(F1|4G}eu>N?02fY;|ig>UX&A!4uDAkstb+GoF@0=DaQGb{^XX z7OUlAHjlX1x^nCpFUF%Gv9$M{4L$L$TO7bPGNZ{}&RVbhgS6i#`(Eh+qzcJ$!mi>Z zmg*gIT95Ib8V7ctSIAaTLyC!IyHO>J(xKLQ>o?+~)qL$gL zPh#39CQ33b%VnFB89Q+vf28msk{n$V2ua5obR{1H8jhT|nwg!G$Q&kdQ%;-U?qo2a zTi?ggSJq+m`E31ezFy2OX73iui{;|%;_dNjjw$|q?mtI^K`ad6@*LR6Q_*mNq8sqH z*VY-Hc-YW7CD}72i)((1omjkiP0P;ckG=FQ4csE}!0IA(;11 zp5Ap`_m@Xp6_j(uA9jrkatZgcH-OwCQJ7ArXgM@n4q2Xt5dc4a(71yG--y3pZG>g> z1M)o=#g`A%(cnIMx;8EOnU_rT5}a0nS}Moy!o49OY9s!?7o1%Cvfp18>4T<9`{$Aq j#_6473pZL<#Buc=cHI1+H;+aE&z^->!s`c}@UngYkaZIA literal 0 HcmV?d00001 diff --git a/frontend/src/simulation/parts/CustomChipPart.ts b/frontend/src/simulation/parts/CustomChipPart.ts index 62b419f8..6c207d2d 100644 --- a/frontend/src/simulation/parts/CustomChipPart.ts +++ b/frontend/src/simulation/parts/CustomChipPart.ts @@ -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(); }; }, }); diff --git a/frontend/src/simulation/spice/componentToSpice.ts b/frontend/src/simulation/spice/componentToSpice.ts index 6fd97c83..2d546c55 100644 --- a/frontend/src/simulation/spice/componentToSpice.ts +++ b/frontend/src/simulation/spice/componentToSpice.ts @@ -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 = { + // 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'); diff --git a/frontend/src/simulation/spice/electricalResolveHook.ts b/frontend/src/simulation/spice/electricalResolveHook.ts new file mode 100644 index 00000000..ee1a7ddd --- /dev/null +++ b/frontend/src/simulation/spice/electricalResolveHook.ts @@ -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 */ + } +} diff --git a/frontend/src/simulation/spice/start.ts b/frontend/src/simulation/spice/start.ts index 3768386e..57bac328 100644 --- a/frontend/src/simulation/spice/start.ts +++ b/frontend/src/simulation/spice/start.ts @@ -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();