feat(digital-gate-engine): evaluate logic gates on the event-driven kernel

Board-less digital circuits (logic gates + switches + LEDs) run today as ngspice
analog B-sources, which is fragile for deep logic: a 4-bit ripple adder re-solves
but never lights its result LEDs live. This adds an event-driven digital motor
that reuses the multichip-bus settle kernel, so the same engine that boots a Z80
over a chip bus evaluates a gate network exactly and instantly.

Phases 0-2 (project/digital-gate-engine/), all behind ?digitalgates=on (default
OFF — flag off is byte-for-byte the old behaviour):

- digitalGateEngine.ts: buildDigitalNetwork(components, wires) does union-find
  over the wires (merging pass-through resistors), identifies the rail/gnd from
  the signal-generator, registers drivers (rail STRONG-1, gnd 0, pull resistors
  PULL, slide-switch as a pass-gate) and event-driven gates (reusing the
  LogicGateParts boolean semantics), settles on busKernel, and exposes
  setSwitch / readLed / netOf. Tolerant of both the raw example `type` and the
  store `metadataId`. Returns {ok:false} for any non-primitive, so mixed/analog
  circuits stay entirely on ngspice.

- digitalGateController.ts + a SimulatorCanvas useEffect: when the flag is on and
  the circuit is all-digital, rebuild from the store on switch-toggle / load
  (rAF-coalesced) and paint the wokwi-led DOM. CircuitSimulationService.tick()
  skips the SPICE solve for all-digital circuits when the flag is on, so the two
  motors never fight over the LEDs.

Tests: digitalgate-kernel (22 — single gates -> half/full adder -> 4-bit
adder/subtractor -> exhaustive ADD 256 -> mux/decoder/comparator/parity/
multiplier) and digitalgate-engine-examples (6 — the real gallery data for
and/or/xor/not + the full adder/subtractor). Verified live: ?digitalgates=on
lights the adder's result LEDs that the SPICE path leaves dark. Full suite
2117 pass / 5 pre-existing unrelated fails.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 20:21:33 -03:00
parent 47adb0b1c8
commit b08df89c9b
6 changed files with 821 additions and 0 deletions

View File

@ -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);
}
});
});

View File

@ -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();
}
}
});
});

View File

@ -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);

View File

@ -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();
};
}

View File

@ -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<string, unknown>;
}
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<string, (b: boolean[]) => 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-<base>(-<n>)?` 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<string, string>();
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<string>, rail: Set<string>) => {
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<string>();
const railRoots = new Set<string>();
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<string, number>();
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<string, 0 | 1>();
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,
};
}

View File

@ -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<void> {
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;