diff --git a/frontend/src/__tests__/digitalgate-engine-examples.test.ts b/frontend/src/__tests__/digitalgate-engine-examples.test.ts new file mode 100644 index 00000000..97fcc002 --- /dev/null +++ b/frontend/src/__tests__/digitalgate-engine-examples.test.ts @@ -0,0 +1,131 @@ +/** + * digital-gate-engine Phase 1 — the engine evaluates the REAL gallery examples. + * + * Loads the actual component+wire data from `examples-digital.ts` (the same data + * the canvas renders) into `buildDigitalNetwork` and checks the result LEDs + * against truth tables — with NO ngspice. De-risks the app integration: if the + * engine lights the right LEDs straight from example data here, Phase 2 only has + * to bridge it to the store + DOM. + * + * Climbs simple -> complex, ending on /example/digital-adder-subtractor-4bit — + * the circuit whose result LEDs never light on the SPICE B-source path live. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetBusNets } from '../simulation/customChips/busNets'; +import { buildDigitalNetwork, type DigitalComponent, type DigitalWire } from '../simulation/digital/digitalGateEngine'; +import { digitalExamples } from '../data/examples-digital'; + +beforeEach(() => resetBusNets()); + +type Ex = { id: string; components: DigitalComponent[]; wires: DigitalWire[] }; +const byId = (id: string): Ex => { + const ex = (digitalExamples as unknown as Ex[]).find((e) => e.id === id); + if (!ex) throw new Error(`example ${id} not found`); + return ex; +}; +const switchIds = (ex: Ex) => ex.components.filter((c) => c.type === 'wokwi-slide-switch').map((c) => c.id); +const ledIds = (ex: Ex) => ex.components.filter((c) => c.type === 'wokwi-led').map((c) => c.id); + +describe('digital-gate-engine Phase 1 — real examples on the engine', () => { + it('builds without bailing (all primitives recognised) for a sample of examples', () => { + for (const id of ['digital-and-two-switches', 'digital-xor-difference', 'digital-full-adder', 'digital-adder-subtractor-4bit']) { + const ex = byId(id); + const net = buildDigitalNetwork(ex.components, ex.wires); + expect(net.ok, `${id} should be all-digital`).toBe(true); + resetBusNets(); + } + }); + + it('digital-and-two-switches: LED = s1 AND s2', () => { + const ex = byId('digital-and-two-switches'); + const [s1, s2] = switchIds(ex); + const [led] = ledIds(ex); + for (const a of [0, 1] as const) { + for (const b of [0, 1] as const) { + const net = buildDigitalNetwork(ex.components, ex.wires); + net.setSwitch(s1, a); + net.setSwitch(s2, b); + expect(net.readLed(led), `AND(${a},${b})`).toBe((a & b) as 0 | 1); + resetBusNets(); + } + } + }); + + it('digital-or-any-switch: LED = s1 OR s2', () => { + const ex = byId('digital-or-any-switch'); + const [s1, s2] = switchIds(ex); + const [led] = ledIds(ex); + for (const a of [0, 1] as const) { + for (const b of [0, 1] as const) { + const net = buildDigitalNetwork(ex.components, ex.wires); + net.setSwitch(s1, a); + net.setSwitch(s2, b); + expect(net.readLed(led), `OR(${a},${b})`).toBe((a | b) as 0 | 1); + resetBusNets(); + } + } + }); + + it('digital-xor-difference: LED = s1 XOR s2', () => { + const ex = byId('digital-xor-difference'); + const [s1, s2] = switchIds(ex); + const [led] = ledIds(ex); + for (const a of [0, 1] as const) { + for (const b of [0, 1] as const) { + const net = buildDigitalNetwork(ex.components, ex.wires); + net.setSwitch(s1, a); + net.setSwitch(s2, b); + expect(net.readLed(led), `XOR(${a},${b})`).toBe((a ^ b) as 0 | 1); + resetBusNets(); + } + } + }); + + it('digital-not-inverter: LED = NOT s (incl. the no-input-high case)', () => { + const ex = byId('digital-not-inverter'); + const [s] = switchIds(ex); + const [led] = ledIds(ex); + for (const a of [0, 1] as const) { + const net = buildDigitalNetwork(ex.components, ex.wires); + net.setSwitch(s, a); + expect(net.readLed(led), `NOT(${a})`).toBe((a ? 0 : 1) as 0 | 1); + resetBusNets(); + } + }); + + it('digital-adder-subtractor-4bit: the result LEDs the SPICE path never lights', () => { + const ex = byId('digital-adder-subtractor-4bit'); + const A = [0, 1, 2, 3].map((i) => `asA${i}`); + const B = [0, 1, 2, 3].map((i) => `asB${i}`); + const S = [0, 1, 2, 3].map((i) => `asLS${i}`); + const M = 'asM', CO = 'asLCo'; + + const run = (a: number, b: number, m: 0 | 1) => { + const net = buildDigitalNetwork(ex.components, ex.wires); + net.setSwitch(M, m); + for (let i = 0; i < 4; i++) { + net.setSwitch(A[i], ((a >> i) & 1) as 0 | 1); + net.setSwitch(B[i], ((b >> i) & 1) as 0 | 1); + } + const sum = S.reduce((acc, s, i) => acc + (net.readLed(s) << i), 0); + const cout = net.readLed(CO); + resetBusNets(); + return { sum, cout }; + }; + + const vectors: Array<[number, number, 0 | 1, number, 0 | 1, string]> = [ + [3, 2, 0, 5, 0, 'ADD 3+2'], + [7, 6, 0, 13, 0, 'ADD 7+6'], + [15, 1, 0, 0, 1, 'ADD 15+1 carry'], + [9, 4, 0, 13, 0, 'ADD 9+4'], + [5, 2, 1, 3, 1, 'SUB 5-2'], + [9, 9, 1, 0, 1, 'SUB 9-9'], + [2, 5, 1, 13, 0, 'SUB 2-5'], + ]; + for (const [a, b, m, sum, cout, label] of vectors) { + const r = run(a, b, m); + expect(r.sum, `${label} sum`).toBe(sum); + expect(r.cout, `${label} carry`).toBe(cout); + } + }); +}); diff --git a/frontend/src/__tests__/digitalgate-kernel.test.ts b/frontend/src/__tests__/digitalgate-kernel.test.ts new file mode 100644 index 00000000..02bc61a4 --- /dev/null +++ b/frontend/src/__tests__/digitalgate-kernel.test.ts @@ -0,0 +1,308 @@ +/** + * digital-gate-engine Phase 0 — a gate network settles correctly on the + * multichip-bus kernel, with NO ngspice (project/digital-gate-engine/). + * + * Proves the event-driven settle kernel built for chip-to-chip buses + * (customChips/{busLogic,busNets,busKernel} + PinManager) evaluates a discrete + * logic-gate network exactly: switches drive nets, gates subscribe to their + * input nets and drive their output, and busKernel.settle() ripples the whole + * combinational network to its fixed point. Builds up simple -> complex, ending + * with the exact 4-bit adder/subtractor that the SPICE B-source path fails to + * light live (00-problem-analysis.md). + * + * This is the D-001 go/no-go gate: if the kernel can ripple a carry through a + * deep gate chain, the whole "gates on the digital engine" approach is sound. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { PinManager } from '../simulation/PinManager'; +import { setBusDrive, resetBusNets } from '../simulation/customChips/busNets'; +import { Strength, type Drive } from '../simulation/customChips/busLogic'; + +const strong = (value: 0 | 1): Drive => ({ value, strength: Strength.STRONG }); + +// Boolean primitives (match parts/LogicGateParts.ts semantics; XOR = parity). +const AND = (b: boolean[]) => b.every(Boolean); +const OR = (b: boolean[]) => b.some(Boolean); +const NAND = (b: boolean[]) => !AND(b); +const NOR = (b: boolean[]) => !OR(b); +const XOR = (b: boolean[]) => b.filter(Boolean).length % 2 === 1; +const XNOR = (b: boolean[]) => !XOR(b); +const NOT = (b: boolean[]) => !b[0]; + +/** + * A digital network on the settle kernel. Nets are integer keys (the same keys + * PinManager + busNets use for chip-to-chip nets). A switch is a STRONG driver; + * a gate subscribes to its input nets, recomputes on any change, and drives its + * output STRONG. Reading a net returns its resolved level. + */ +class Network { + readonly pm = new PinManager(); + private nextKey = 1; + + net(): number { + return this.nextKey++; + } + + /** Drive a net from an input switch (STRONG). */ + setSwitch(net: number, value: 0 | 1, id = `sw${net}`): void { + setBusDrive(this.pm, net, `${id}::o`, strong(value)); + } + + /** Read a net's resolved logic level (what an LED on it would show). */ + read(net: number): 0 | 1 { + return this.pm.getPinState(net) ? 1 : 0; + } + + /** A combinational gate: inputs[] -> output, recomputed event-driven. */ + gate(id: string, inputs: number[], output: number, fn: (b: boolean[]) => boolean): void { + const state = inputs.map((n) => this.pm.getPinState(n)); + const update = () => setBusDrive(this.pm, output, `${id}::Y`, strong(fn(state) ? 1 : 0)); + inputs.forEach((n, i) => + this.pm.onPinChange(n, (_p: number, s: boolean) => { + state[i] = s; + update(); + }), + ); + update(); // drive-on-mount so the network has a defined initial steady state + } + + /** One full adder: returns {sum, cout} nets. */ + fullAdder(tag: string, a: number, b: number, cin: number): { sum: number; cout: number } { + const axb = this.net(); + const sum = this.net(); + const ab = this.net(); + const cab = this.net(); + const cout = this.net(); + this.gate(`${tag}_axb`, [a, b], axb, XOR); + this.gate(`${tag}_sum`, [axb, cin], sum, XOR); + this.gate(`${tag}_ab`, [a, b], ab, AND); + this.gate(`${tag}_cab`, [cin, axb], cab, AND); + this.gate(`${tag}_cout`, [ab, cab], cout, OR); + return { sum, cout }; + } +} + +beforeEach(() => resetBusNets()); + +describe('digital-gate-engine Phase 0 — single gates settle on the kernel', () => { + const cases: Array<[string, (b: boolean[]) => boolean, Array<[number, number, number]>]> = [ + ['AND', AND, [[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 1]]], + ['OR', OR, [[0, 0, 0], [0, 1, 1], [1, 0, 1], [1, 1, 1]]], + ['NAND', NAND, [[0, 0, 1], [0, 1, 1], [1, 0, 1], [1, 1, 0]]], + ['NOR', NOR, [[0, 0, 1], [0, 1, 0], [1, 0, 0], [1, 1, 0]]], + ['XOR', XOR, [[0, 0, 0], [0, 1, 1], [1, 0, 1], [1, 1, 0]]], + ['XNOR', XNOR, [[0, 0, 1], [0, 1, 0], [1, 0, 0], [1, 1, 1]]], + ]; + + it.each(cases)('%s truth table', (_name, fn, table) => { + for (const [a, b, y] of table) { + const net = new Network(); + const A = net.net(), B = net.net(), Y = net.net(); + net.gate('g', [A, B], Y, fn); + net.setSwitch(A, a as 0 | 1); + net.setSwitch(B, b as 0 | 1); + expect(net.read(Y), `${_name}(${a},${b})`).toBe(y); + resetBusNets(); + } + }); + + it('NOT inverter (incl. the all-zero-input high output)', () => { + for (const [a, y] of [[0, 1], [1, 0]] as Array<[0 | 1, 0 | 1]>) { + const net = new Network(); + const A = net.net(), Y = net.net(); + net.gate('inv', [A], Y, NOT); + // Read BEFORE driving: NOT(0)=1 must come from the drive-on-mount. + expect(net.read(Y), `NOT(${a}) initial`).toBe(1); + net.setSwitch(A, a); + expect(net.read(Y), `NOT(${a})`).toBe(y); + resetBusNets(); + } + }); +}); + +describe('digital-gate-engine Phase 0 — combinational blocks', () => { + it('half adder: S = A XOR B, C = A AND B', () => { + for (const [a, b] of [[0, 0], [0, 1], [1, 0], [1, 1]] as Array<[0 | 1, 0 | 1]>) { + const net = new Network(); + const A = net.net(), B = net.net(), S = net.net(), C = net.net(); + net.gate('s', [A, B], S, XOR); + net.gate('c', [A, B], C, AND); + net.setSwitch(A, a); + net.setSwitch(B, b); + expect([net.read(S), net.read(C)], `HA(${a},${b})`).toEqual([a ^ b, a & b]); + resetBusNets(); + } + }); + + it('full adder: all 8 input combinations', () => { + for (let v = 0; v < 8; v++) { + const a = (v & 1) as 0 | 1, b = ((v >> 1) & 1) as 0 | 1, cin = ((v >> 2) & 1) as 0 | 1; + const net = new Network(); + const A = net.net(), B = net.net(), CIN = net.net(); + const { sum, cout } = net.fullAdder('fa', A, B, CIN); + net.setSwitch(A, a); + net.setSwitch(B, b); + net.setSwitch(CIN, cin); + const total = a + b + cin; + expect([net.read(sum), net.read(cout)], `FA(${a},${b},${cin})`).toEqual([total & 1, total >> 1]); + resetBusNets(); + } + }); +}); + +describe('digital-gate-engine Phase 0 — 4-bit ripple adder/subtractor (the failing example)', () => { + // Builds the exact topology of /example/digital-adder-subtractor-4bit: + // each B bit XOR M, M -> FA0 carry-in, ripple chain; result = sum bits + carry. + const build = (N = 4) => { + const net = new Network(); + const A = Array.from({ length: N }, () => net.net()); + const B = Array.from({ length: N }, () => net.net()); + const M = net.net(); + let carry = M; // M feeds FA0 carry-in (two's-complement subtract) + const S: number[] = []; + for (let i = 0; i < N; i++) { + const bxm = net.net(); + net.gate(`bxm${i}`, [B[i], M], bxm, XOR); // B_i XOR M + const { sum, cout } = net.fullAdder(`fa${i}`, A[i], bxm, carry); + S.push(sum); + carry = cout; + } + const apply = (a: number, b: number, m: 0 | 1) => { + net.setSwitch(M, m); + for (let i = 0; i < N; i++) { + net.setSwitch(A[i], ((a >> i) & 1) as 0 | 1); + net.setSwitch(B[i], ((b >> i) & 1) as 0 | 1); + } + }; + const result = () => S.reduce((acc, s, i) => acc + (net.read(s) << i), 0); + const carryOut = () => net.read(carry); + return { apply, result, carryOut }; + }; + + const vectors: Array<{ a: number; b: number; m: 0 | 1; sum: number; cout: 0 | 1; label: string }> = [ + { a: 3, b: 2, m: 0, sum: 5, cout: 0, label: 'ADD 3+2' }, + { a: 7, b: 6, m: 0, sum: 13, cout: 0, label: 'ADD 7+6' }, + { a: 15, b: 1, m: 0, sum: 0, cout: 1, label: 'ADD 15+1 (carry)' }, + { a: 9, b: 4, m: 0, sum: 13, cout: 0, label: 'ADD 9+4' }, + { a: 5, b: 2, m: 1, sum: 3, cout: 1, label: 'SUB 5-2' }, + { a: 9, b: 9, m: 1, sum: 0, cout: 1, label: 'SUB 9-9' }, + { a: 2, b: 5, m: 1, sum: 13, cout: 0, label: 'SUB 2-5 (borrow, 1101=-3)' }, + ]; + + it.each(vectors)('$label -> $sum (carry $cout)', ({ a, b, m, sum, cout }) => { + const adder = build(4); + adder.apply(a, b, m); + expect(adder.result()).toBe(sum); + expect(adder.carryOut()).toBe(cout); + }); + + it('exhaustive ADD: every A,B in 0..15 gives (A+B) mod 16 + carry', () => { + for (let a = 0; a < 16; a++) { + for (let b = 0; b < 16; b++) { + const adder = build(4); + adder.apply(a, b, 0); + const total = a + b; + expect(adder.result(), `ADD ${a}+${b} sum`).toBe(total & 15); + expect(adder.carryOut(), `ADD ${a}+${b} carry`).toBe(((total >> 4) & 1) as 0 | 1); + resetBusNets(); + } + } + }); +}); + +describe('digital-gate-engine Phase 0 — more example topologies (fan-out, select, wide, deep)', () => { + it('2-to-1 mux: Y = S ? B : A (all 8 inputs)', () => { + for (let v = 0; v < 8; v++) { + const s = (v & 1) as 0 | 1, a = ((v >> 1) & 1) as 0 | 1, b = ((v >> 2) & 1) as 0 | 1; + const net = new Network(); + const S = net.net(), A = net.net(), B = net.net(); + const nS = net.net(), t0 = net.net(), t1 = net.net(), Y = net.net(); + net.gate('ns', [S], nS, NOT); + net.gate('t0', [nS, A], t0, AND); + net.gate('t1', [S, B], t1, AND); + net.gate('y', [t0, t1], Y, OR); + net.setSwitch(S, s); net.setSwitch(A, a); net.setSwitch(B, b); + expect(net.read(Y), `MUX s=${s} a=${a} b=${b}`).toBe(s ? b : a); + resetBusNets(); + } + }); + + it('2-to-4 decoder: one-hot output (fan-out from 2 inputs)', () => { + for (let v = 0; v < 4; v++) { + const s0 = (v & 1) as 0 | 1, s1 = ((v >> 1) & 1) as 0 | 1; + const net = new Network(); + const S0 = net.net(), S1 = net.net(), nS0 = net.net(), nS1 = net.net(); + const D = [net.net(), net.net(), net.net(), net.net()]; + net.gate('n0', [S0], nS0, NOT); + net.gate('n1', [S1], nS1, NOT); + net.gate('d0', [nS1, nS0], D[0], AND); + net.gate('d1', [nS1, S0], D[1], AND); + net.gate('d2', [S1, nS0], D[2], AND); + net.gate('d3', [S1, S0], D[3], AND); + net.setSwitch(S0, s0); net.setSwitch(S1, s1); + expect(D.map((d) => net.read(d)), `DECODE ${v}`).toEqual([0, 1, 2, 3].map((i) => (i === v ? 1 : 0))); + resetBusNets(); + } + }); + + it('4-bit equality comparator: EQ = AND of (A_i XNOR B_i) — wide AND', () => { + const samples: Array<[number, number]> = [[0, 0], [5, 5], [15, 15], [5, 7], [9, 1], [15, 14]]; + for (const [a, b] of samples) { + const net = new Network(); + const e: number[] = []; + for (let i = 0; i < 4; i++) { + const Ai = net.net(), Bi = net.net(), Ei = net.net(); + net.gate(`xnor${i}`, [Ai, Bi], Ei, XNOR); + net.setSwitch(Ai, ((a >> i) & 1) as 0 | 1); + net.setSwitch(Bi, ((b >> i) & 1) as 0 | 1); + e.push(Ei); + } + const EQ = net.net(); + net.gate('eq', e, EQ, AND); // 4-input AND + expect(net.read(EQ), `EQ ${a}==${b}`).toBe(a === b ? 1 : 0); + resetBusNets(); + } + }); + + it('4-bit parity: cascaded XOR chain (depth) — odd-1s detector', () => { + for (let v = 0; v < 16; v++) { + const net = new Network(); + const bits = [net.net(), net.net(), net.net(), net.net()]; + const p01 = net.net(), p012 = net.net(), p0123 = net.net(); + net.gate('p01', [bits[0], bits[1]], p01, XOR); + net.gate('p012', [p01, bits[2]], p012, XOR); + net.gate('p0123', [p012, bits[3]], p0123, XOR); + bits.forEach((bnet, i) => net.setSwitch(bnet, ((v >> i) & 1) as 0 | 1)); + const ones = [0, 1, 2, 3].reduce((n, i) => n + ((v >> i) & 1), 0); + expect(net.read(p0123), `PARITY ${v}`).toBe((ones & 1) as 0 | 1); + resetBusNets(); + } + }); + + it('2x2 binary multiplier: partial products + half adders (mixed arithmetic)', () => { + for (let a = 0; a < 4; a++) { + for (let b = 0; b < 4; b++) { + const net = new Network(); + const A0 = net.net(), A1 = net.net(), B0 = net.net(), B1 = net.net(); + const a0b0 = net.net(), a1b0 = net.net(), a0b1 = net.net(), a1b1 = net.net(); + net.gate('a0b0', [A0, B0], a0b0, AND); + net.gate('a1b0', [A1, B0], a1b0, AND); + net.gate('a0b1', [A0, B1], a0b1, AND); + net.gate('a1b1', [A1, B1], a1b1, AND); + const P0 = a0b0; + const P1 = net.net(), c1 = net.net(); + net.gate('p1', [a1b0, a0b1], P1, XOR); + net.gate('c1', [a1b0, a0b1], c1, AND); + const P2 = net.net(), c2 = net.net(); + net.gate('p2', [a1b1, c1], P2, XOR); + net.gate('c2', [a1b1, c1], c2, AND); + const P3 = c2; + net.setSwitch(A0, (a & 1) as 0 | 1); net.setSwitch(A1, ((a >> 1) & 1) as 0 | 1); + net.setSwitch(B0, (b & 1) as 0 | 1); net.setSwitch(B1, ((b >> 1) & 1) as 0 | 1); + const product = net.read(P0) + (net.read(P1) << 1) + (net.read(P2) << 2) + (net.read(P3) << 3); + expect(product, `MUL ${a}*${b}`).toBe(a * b); + resetBusNets(); + } + } + }); +}); diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index ec68a019..c1d9b096 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -22,6 +22,7 @@ import { BoardOnCanvas } from './BoardOnCanvas'; import { CanvasMinimap } from './CanvasMinimap'; import { PartSimulationRegistry } from '../../simulation/parts'; import { PROPERTY_CHANGE_EVENT, type PropertyChangeDetail } from '../../simulation/parts/partUtils'; +import { mountDigitalGateEngine } from '../../simulation/digital/digitalGateController'; import { isSpiceMapped } from '../../simulation/spice/componentToSpice'; import { PinOverlay } from './PinOverlay'; import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping'; @@ -433,6 +434,12 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => { return () => window.removeEventListener(PROPERTY_CHANGE_EVENT, onPropertyChange); }, []); + // Digital-gate engine (project/digital-gate-engine): when ?digitalgates=on and + // the board-less circuit is all-digital, evaluate the logic gates on the + // event-driven settle kernel and paint the LEDs, instead of ngspice B-sources. + // No-op when the flag is off (default). + useEffect(() => mountDigitalGateEngine(), []); + // Auto-start/stop Pi bridges when simulation state changes const startBoard = useSimulatorStore((s) => s.startBoard); const stopBoard = useSimulatorStore((s) => s.stopBoard); diff --git a/frontend/src/simulation/digital/digitalGateController.ts b/frontend/src/simulation/digital/digitalGateController.ts new file mode 100644 index 00000000..968d40ad --- /dev/null +++ b/frontend/src/simulation/digital/digitalGateController.ts @@ -0,0 +1,76 @@ +/** + * digitalGateController — Phase 2 of project/digital-gate-engine/. + * + * Mounts the digital gate engine into the live app: when `?digitalgates=on` and + * the board-less circuit is all-digital, it builds the network from the store on + * every relevant change (switch toggle / load), settles it on the multichip-bus + * kernel, and pushes the resolved levels onto the real `wokwi-led` DOM elements. + * ngspice is told to skip all-digital circuits (CircuitSimulationService guard) + * so the two motors do not fight over the LEDs. + * + * Flag OFF (default) => this is a no-op and nothing changes. Mixed / analog + * circuits never qualify as all-digital, so they stay entirely on ngspice. + */ +import { useSimulatorStore } from '../../store/useSimulatorStore'; +import { PinManager } from '../PinManager'; +import { resetBusNets } from '../customChips/busNets'; +import { buildDigitalNetwork, digitalGatesEnabled, isAllDigital } from './digitalGateEngine'; +import { PROPERTY_CHANGE_EVENT } from '../parts/partUtils'; + +interface LedEl extends HTMLElement { + value?: boolean; + brightness?: number; +} + +/** + * Start the controller. Returns an unsubscribe handle. Safe to call when the + * flag is off — it returns a no-op disposer immediately. + */ +export function mountDigitalGateEngine(): () => void { + if (typeof window === 'undefined' || !digitalGatesEnabled()) return () => {}; + + let disposed = false; + let raf = 0; + + const paintLeds = () => { + const st = useSimulatorStore.getState(); + // Mixed / analog circuits belong to ngspice — leave them alone. + if (!isAllDigital(st.components as never[])) return; + resetBusNets(); + const net = buildDigitalNetwork(st.components as never[], st.wires as never[], new PinManager()); + if (!net.ok) return; + for (const id of net.ledIds) { + const el = document.getElementById(id) as LedEl | null; + if (!el) continue; + const lit = net.readLed(id) === 1; + el.value = lit; + el.brightness = lit ? 1 : 0; + } + }; + + // Coalesce bursts (e.g. loadExample sets many components) into one paint. + const schedule = () => { + if (disposed || raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + if (!disposed) paintLeds(); + }); + }; + + // Switch toggles emit velxio:property-change; structural changes bump the + // store's components/wires references. + const onProp = () => schedule(); + window.addEventListener(PROPERTY_CHANGE_EVENT, onProp); + const unsub = useSimulatorStore.subscribe((n, p) => { + if (n.components !== p.components || n.wires !== p.wires) schedule(); + }); + + schedule(); // initial paint + + return () => { + disposed = true; + if (raf) cancelAnimationFrame(raf); + window.removeEventListener(PROPERTY_CHANGE_EVENT, onProp); + unsub(); + }; +} diff --git a/frontend/src/simulation/digital/digitalGateEngine.ts b/frontend/src/simulation/digital/digitalGateEngine.ts new file mode 100644 index 00000000..fa238a78 --- /dev/null +++ b/frontend/src/simulation/digital/digitalGateEngine.ts @@ -0,0 +1,289 @@ +/** + * digitalGateEngine — evaluate a board-less DIGITAL circuit (logic gates + + * switches + LEDs + power rails) on the event-driven settle kernel instead of + * ngspice B-sources. Phase 1 of project/digital-gate-engine/. + * + * It reuses the multichip-bus substrate (customChips/{busLogic,busNets, + * busKernel} + PinManager): every wire-connected set of pins becomes one bus + * net key, each primitive contributes a driver (or, for gates, an event-driven + * compute), and busKernel.settle() ripples the network to its fixed point. The + * same kernel that boots a Z80 over a chip bus evaluates the gate network — so a + * 4-bit ripple adder settles exactly, which the cascaded-B-source SPICE model + * does not (00-problem-analysis.md). + * + * Digital abstraction of the analog scaffolding the examples use: + * - signal-generator SIG = STRONG 1 (the 5 V rail); its GND pin = node 0. + * - a resistor with one end on GND = PULL 0 on the other net (pull-down). + * - a resistor with one end on rail = PULL 1 on the other net (pull-up). + * - a resistor between two signal nets = pass-through (the nets merge). + * - a slide-switch closed = pass its rail-side level to its other pin (STRONG); + * open = Hi-Z (the pull-down then wins -> 0). + * - a gate computes its boolean and drives Y STRONG. + * - an LED is a pure sink: it reads its anode net (lit iff the net is 1). + * + * `buildDigitalNetwork` returns a controller: drive switches, read LED/net + * levels. It does not touch the DOM or the store — the app layer (Phase 2) + * wires those in. + */ +import { PinManager } from '../PinManager'; +import { setBusDrive } from '../customChips/busNets'; +import { Strength, type Drive } from '../customChips/busLogic'; + +const STRONG = (v: 0 | 1): Drive => ({ value: v, strength: Strength.STRONG }); +const PULL = (v: 0 | 1): Drive => ({ value: v, strength: Strength.PULL }); + +export interface DigitalComponent { + id: string; + /** Raw example type (`velxio-logic-gate-and`, `wokwi-slide-switch`, …). */ + type?: string; + /** Store-normalised id (`logic-gate-and`, `slide-switch`, …). */ + metadataId?: string; + properties?: Record; +} +export interface DigitalWire { + start: { componentId: string; pinName: string }; + end: { componentId: string; pinName: string }; +} + +/** + * Canonical kind for a component, tolerant of both shapes: the raw example data + * carries `type: 'velxio-logic-gate-and'` / `'wokwi-led'`, the loaded store + * carries `metadataId: 'logic-gate-and'` / `'led'`. Strip the vendor prefixes so + * both resolve to the same kind. + */ +function kindOf(c: DigitalComponent): string { + const raw = String(c.metadataId ?? c.type ?? ''); + return raw.replace(/^velxio-/, '').replace(/^wokwi-/, ''); +} + +// Boolean primitives (match parts/LogicGateParts.ts; XOR = parity). +const OPS: Record boolean> = { + and: (b) => b.every(Boolean), + or: (b) => b.some(Boolean), + nand: (b) => !b.every(Boolean), + nor: (b) => !b.some(Boolean), + xor: (b) => b.filter(Boolean).length % 2 === 1, + xnor: (b) => b.filter(Boolean).length % 2 === 0, + not: (b) => !b[0], + buffer: (b) => !!b[0], +}; + +/** Parse a normalised gate kind `logic-gate-(-)?` into pins + fn. */ +function parseGate(kind: string): { inputs: string[]; fn: (b: boolean[]) => boolean } | null { + const m = /^logic-gate-([a-z]+)(?:-(\d))?$/.exec(kind); + if (!m) return null; + const base = m[1]; + const fn = OPS[base]; + if (!fn) return null; + if (base === 'not' || base === 'buffer') return { inputs: ['A'], fn }; + const n = m[2] ? Number(m[2]) : 2; + const inputs = ['A', 'B', 'C', 'D'].slice(0, n); + return { inputs, fn }; +} + +const isGate = (t: string) => t.startsWith('logic-gate-'); +const isSwitch = (t: string) => t === 'slide-switch'; +const isLed = (t: string) => t === 'led'; +const isResistor = (t: string) => t === 'resistor'; +const isPower = (t: string) => t === 'signal-generator'; + +/** Components this engine understands. Anything else => analog => bail. */ +function isDigitalPrimitive(t: string): boolean { + return isGate(t) || isSwitch(t) || isLed(t) || isResistor(t) || isPower(t); +} + +/** Opt-in flag, mirrors chipBusEnabled / mixedmode. Default OFF until verified. */ +export function digitalGatesEnabled(): boolean { + try { + if (typeof window !== 'undefined' && window.location) { + const q = new URLSearchParams(window.location.search).get('digitalgates'); + if (q === 'on' || q === '1' || q === 'true') return true; + if (q === 'off' || q === '0' || q === 'false') return false; + } + if (typeof localStorage !== 'undefined') { + const v = localStorage.getItem('velxio.digitalgates'); + if (v === 'on' || v === '1' || v === 'true') return true; + if (v === 'off' || v === '0' || v === 'false') return false; + } + } catch { + /* missing globals in tests / SecurityError — fall through */ + } + return false; +} + +/** True iff every component is a digital primitive (so the engine can own it). */ +export function isAllDigital(components: DigitalComponent[]): boolean { + return components.length > 0 && components.every((c) => isDigitalPrimitive(kindOf(c))); +} + +// Endpoint key. A printable separator (NOT a space — a lone space gets stored +// as a NUL byte by the edit tools, turning the source into a git-binary). +const epKey = (compId: string, pin: string) => `${compId}::${pin}`; + +// ── Union-find over wire endpoints ────────────────────────────────────────── +class UnionFind { + private parent = new Map(); + find(x: string): string { + let r = this.parent.get(x); + if (r === undefined) { + this.parent.set(x, x); + return x; + } + while (r !== this.parent.get(r)) { + const gp = this.parent.get(r)!; + this.parent.set(r, this.parent.get(gp)!); + r = gp; + } + return r; + } + union(a: string, b: string): void { + const ra = this.find(a), rb = this.find(b); + if (ra !== rb) this.parent.set(ra, rb); + } +} + +export interface DigitalNetwork { + /** True if every component was a digital primitive (else nothing was built). */ + ok: boolean; + pinManager: PinManager; + /** Resolve a component pin to its bus-net key (or undefined). */ + netOf(componentId: string, pin: string): number | undefined; + /** Read a net's resolved logic level. */ + readNet(net: number): 0 | 1; + /** Read an LED's lit state (its anode net level). */ + readLed(ledId: string): 0 | 1; + /** Set a slide-switch open/closed and re-settle. */ + setSwitch(switchId: string, value: 0 | 1): void; + /** All LED ids in the network. */ + ledIds: string[]; +} + +/** + * Build the digital network. Returns `{ ok:false }` (and drives nothing) if any + * component is not a digital primitive — that circuit belongs to ngspice. + */ +export function buildDigitalNetwork( + components: DigitalComponent[], + wires: DigitalWire[], + pinManager?: PinManager, +): DigitalNetwork { + const pm = pinManager ?? new PinManager(); + const noop: DigitalNetwork = { + ok: false, pinManager: pm, + netOf: () => undefined, readNet: () => 0, readLed: () => 0, setSwitch: () => {}, ledIds: [], + }; + if (components.some((c) => !isDigitalPrimitive(kindOf(c)))) return noop; + + const byId = new Map(components.map((c) => [c.id, c])); + const uf = new UnionFind(); + for (const w of wires) uf.union(epKey(w.start.componentId, w.start.pinName), epKey(w.end.componentId, w.end.pinName)); + + const pinNet = (compId: string, pin: string) => uf.find(epKey(compId, pin)); + + // Identify the GND and rail roots from the signal-generator(s). + const findRailGnd = (gnd: Set, rail: Set) => { + gnd.clear(); rail.clear(); + for (const c of components) { + if (isPower(kindOf(c))) { + gnd.add(pinNet(c.id, 'GND')); + rail.add(pinNet(c.id, 'SIG')); + } + } + }; + const gndRoots = new Set(); + const railRoots = new Set(); + findRailGnd(gndRoots, railRoots); + const isGnd = (root: string) => gndRoots.has(root); + const isRail = (root: string) => railRoots.has(root); + + // Pass-through resistor merge (neither end on rail/gnd), then recompute roots. + for (const c of components) { + if (!isResistor(kindOf(c))) continue; + const r1 = pinNet(c.id, '1'), r2 = pinNet(c.id, '2'); + const special = (r: string) => isGnd(r) || isRail(r); + if (!special(r1) && !special(r2)) uf.union(epKey(c.id, '1'), epKey(c.id, '2')); + } + findRailGnd(gndRoots, railRoots); + + // Assign an integer key per net root. + const keyOf = new Map(); + let nextKey = 1; + const netKey = (compId: string, pin: string): number => { + const root = pinNet(compId, pin); + let k = keyOf.get(root); + if (k === undefined) { k = nextKey++; keyOf.set(root, k); } + return k; + }; + const netOf = (compId: string, pin: string): number | undefined => { + if (!byId.has(compId)) return undefined; + return netKey(compId, pin); + }; + + // ── Static drivers: rail, gnd, pull resistors ────────────────────────────── + for (const c of components) { + if (isPower(kindOf(c))) { + setBusDrive(pm, netKey(c.id, 'SIG'), `${c.id}::SIG`, STRONG(1)); // 5 V rail + setBusDrive(pm, netKey(c.id, 'GND'), `${c.id}::GND`, STRONG(0)); // node 0 + } + } + for (const c of components) { + if (!isResistor(kindOf(c))) continue; + const r1 = pinNet(c.id, '1'), r2 = pinNet(c.id, '2'); + if (isGnd(r1) && !isGnd(r2)) setBusDrive(pm, netKey(c.id, '2'), `${c.id}::pd`, PULL(0)); + else if (isGnd(r2) && !isGnd(r1)) setBusDrive(pm, netKey(c.id, '1'), `${c.id}::pd`, PULL(0)); + else if (isRail(r1) && !isRail(r2)) setBusDrive(pm, netKey(c.id, '2'), `${c.id}::pu`, PULL(1)); + else if (isRail(r2) && !isRail(r1)) setBusDrive(pm, netKey(c.id, '1'), `${c.id}::pu`, PULL(1)); + // else: pass-through (already merged) — contributes no driver. + } + + // ── Switches: closed passes the rail-side level to the other pin ─────────── + const switchState = new Map(); + const driveSwitch = (c: DigitalComponent) => { + const closed = switchState.get(c.id) ?? (Number(c.properties?.value) === 1 ? 1 : 0); + const n1 = netKey(c.id, '1'), n2 = netKey(c.id, '2'); + const root1 = pinNet(c.id, '1'); + // switchInput() wires pin '1' to the rail, pin '2' to the gate input. Drive + // the gate side with the source side's level when closed, else release. + const [src, dst] = isRail(root1) || !isGnd(pinNet(c.id, '2')) ? [n1, n2] : [n2, n1]; + if (closed) { + const srcLevel = pm.getPinState(src) ? 1 : 0; + setBusDrive(pm, dst, `${c.id}::pass`, STRONG(srcLevel as 0 | 1)); + } else { + setBusDrive(pm, dst, `${c.id}::pass`, { value: 0, strength: Strength.HIGHZ }); + } + }; + for (const c of components) if (isSwitch(kindOf(c))) driveSwitch(c); + + // ── Gates: subscribe inputs, compute, drive Y (event-driven) ─────────────── + for (const c of components) { + if (!isGate(kindOf(c))) continue; + const spec = parseGate(kindOf(c)); + if (!spec) continue; + const inNets = spec.inputs.map((p) => netKey(c.id, p)); + const outNet = netKey(c.id, 'Y'); + const st = inNets.map((n) => pm.getPinState(n)); + const update = () => setBusDrive(pm, outNet, `${c.id}::Y`, STRONG(spec.fn(st) ? 1 : 0)); + inNets.forEach((n, i) => pm.onPinChange(n, (_p, s) => { st[i] = s; update(); })); + update(); + } + + // Re-drive switches now that rail levels have settled (a switch built before + // its rail driver landed would have passed a stale 0). + for (const c of components) if (isSwitch(kindOf(c))) driveSwitch(c); + + const ledIds = components.filter((c) => isLed(kindOf(c))).map((c) => c.id); + + return { + ok: true, + pinManager: pm, + netOf, + readNet: (net) => (pm.getPinState(net) ? 1 : 0), + readLed: (ledId) => (pm.getPinState(netKey(ledId, 'A')) ? 1 : 0), + setSwitch: (switchId, value) => { + switchState.set(switchId, value); + const c = byId.get(switchId); + if (c) driveSwitch(c); + }, + ledIds, + }; +} diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index c4096fd6..2972e07c 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -28,6 +28,7 @@ import { buildInputFromStore } from './storeAdapter'; import { buildNetlist, sanitizeSpiceId } from './NetlistBuilder'; import type { TimeWaveforms } from './types'; +import { digitalGatesEnabled, isAllDigital } from '../digital/digitalGateEngine'; /** What the service needs from the simulator store. */ export interface SimulatorStorePort { @@ -166,6 +167,15 @@ export class CircuitSimulationService { /** Run one solve cycle, coalescing concurrent triggers. */ async tick(): Promise { if (this.stopped) return; + // The digital-gate engine owns all-digital board-less circuits when + // ?digitalgates=on; skip the SPICE solve so the two motors don't fight over + // the LEDs. Flag off (default) -> isAllDigital is never consulted. + if ( + digitalGatesEnabled() && + isAllDigital((this.simStore.getState() as { components: unknown[] }).components as never[]) + ) { + return; + } if (this.inFlight) { this.pending = true; return;