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 00000000..ac7b14dd Binary files /dev/null and b/frontend/src/simulation/customChips/chipPinDrives.ts differ diff --git a/frontend/src/simulation/customChips/syntheticPins.ts b/frontend/src/simulation/customChips/syntheticPins.ts new file mode 100644 index 00000000..147551ce Binary files /dev/null and b/frontend/src/simulation/customChips/syntheticPins.ts differ 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();