diff --git a/frontend/src/__tests__/digitalgate-sequential.test.ts b/frontend/src/__tests__/digitalgate-sequential.test.ts new file mode 100644 index 00000000..a8224570 --- /dev/null +++ b/frontend/src/__tests__/digitalgate-sequential.test.ts @@ -0,0 +1,98 @@ +/** + * digital-gate-engine Phase 5 — sequential logic (D/T/JK flip-flops) on the + * event-driven engine. project/digital-gate-engine/. + * + * Flip-flops are edge-triggered: they sample their data inputs on the rising + * edge of CLK and hold Q between edges. The combinational settle kernel cannot + * model that on its own, so the engine gives each flip-flop explicit state + + * edge detection (reusing the LogicGateParts sample semantics). Because a + * flip-flop only updates on the clock edge, a Q->D feedback (a counter / shift + * register) does NOT oscillate the settle loop — these circuits are impossible + * on the SPICE B-source path (no edge detection at DC; no SPICE mapper). + * + * A switch supplies the clock (closed = 1). A full clock cycle = setSwitch(1) + * then setSwitch(0); each rising 0->1 is one trigger. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { resetBusNets } from '../simulation/customChips/busNets'; +import { buildDigitalNetwork, type DigitalComponent, type DigitalWire } from '../simulation/digital/digitalGateEngine'; + +beforeEach(() => resetBusNets()); + +const W = (a: string, ap: string, b: string, bp: string): DigitalWire => ({ start: { componentId: a, pinName: ap }, end: { componentId: b, pinName: bp } }); +const src: DigitalComponent = { id: 'src', metadataId: 'signal-generator' }; +const sw = (id: string, v: 0 | 1 = 0): DigitalComponent => ({ id, metadataId: 'slide-switch', properties: { value: v } }); + +describe('digital-gate-engine Phase 5 — flip-flops', () => { + it('D flip-flop: Q <- D on the rising edge, holds between edges', () => { + const comps = [src, sw('clk'), sw('d'), { id: 'ff', metadataId: 'flip-flop-d' }]; + const wires = [ + W('src', 'SIG', 'clk', '1'), W('clk', '2', 'ff', 'CLK'), + W('src', 'SIG', 'd', '1'), W('d', '2', 'ff', 'D'), + ]; + const net = buildDigitalNetwork(comps, wires); + const Q = net.netOf('ff', 'Q')!; + const pulse = () => { net.setSwitch('clk', 1); net.setSwitch('clk', 0); }; + + expect(net.readNet(Q), 'initial Q=0').toBe(0); + net.setSwitch('d', 1); pulse(); + expect(net.readNet(Q), 'D=1 clocked -> Q=1').toBe(1); + net.setSwitch('d', 0); + expect(net.readNet(Q), 'Q holds 1 before the next edge').toBe(1); + pulse(); + expect(net.readNet(Q), 'D=0 clocked -> Q=0').toBe(0); + }); + + it('T flip-flop: toggles on each rising edge when T=1', () => { + const comps = [src, sw('clk'), { id: 'ff', metadataId: 'flip-flop-t' }]; + const wires = [W('src', 'SIG', 'clk', '1'), W('clk', '2', 'ff', 'CLK'), W('src', 'SIG', 'ff', 'T')]; // T tied high + const net = buildDigitalNetwork(comps, wires); + const Q = net.netOf('ff', 'Q')!; + const seq: number[] = [net.readNet(Q)]; + for (let i = 0; i < 4; i++) { net.setSwitch('clk', 1); net.setSwitch('clk', 0); seq.push(net.readNet(Q)); } + expect(seq, 'T=1 toggles each clock').toEqual([0, 1, 0, 1, 0]); + }); + + it('JK flip-flop: hold / set / reset / toggle', () => { + const comps = [src, sw('clk'), sw('j'), sw('k'), { id: 'ff', metadataId: 'flip-flop-jk' }]; + const wires = [ + W('src', 'SIG', 'clk', '1'), W('clk', '2', 'ff', 'CLK'), + W('src', 'SIG', 'j', '1'), W('j', '2', 'ff', 'J'), + W('src', 'SIG', 'k', '1'), W('k', '2', 'ff', 'K'), + ]; + const net = buildDigitalNetwork(comps, wires); + const Q = net.netOf('ff', 'Q')!; + const pulse = () => { net.setSwitch('clk', 1); net.setSwitch('clk', 0); }; + const set = (j: 0 | 1, k: 0 | 1) => { net.setSwitch('j', j); net.setSwitch('k', k); }; + + set(1, 0); pulse(); expect(net.readNet(Q), 'J=1,K=0 set -> 1').toBe(1); + set(0, 0); pulse(); expect(net.readNet(Q), 'J=0,K=0 hold -> 1').toBe(1); + set(0, 1); pulse(); expect(net.readNet(Q), 'J=0,K=1 reset -> 0').toBe(0); + set(1, 1); pulse(); expect(net.readNet(Q), 'J=1,K=1 toggle -> 1').toBe(1); + set(1, 1); pulse(); expect(net.readNet(Q), 'J=1,K=1 toggle -> 0').toBe(0); + }); + + it('2-bit ripple counter from T flip-flops (impossible on the SPICE path)', () => { + // FF0 toggles on every clock; FF1 is clocked by FF0.Qbar so it toggles when + // FF0 goes 1->0. Counts 00,01,10,11,00 across rising clock edges. + const comps = [ + src, sw('clk'), + { id: 'ff0', metadataId: 'flip-flop-t' }, + { id: 'ff1', metadataId: 'flip-flop-t' }, + ]; + const wires = [ + W('src', 'SIG', 'clk', '1'), W('clk', '2', 'ff0', 'CLK'), + W('src', 'SIG', 'ff0', 'T'), W('src', 'SIG', 'ff1', 'T'), // both T high + W('ff0', 'Qbar', 'ff1', 'CLK'), // ripple: FF0.Qbar clocks FF1 + ]; + const net = buildDigitalNetwork(comps, wires); + const Q0 = net.netOf('ff0', 'Q')!; + const Q1 = net.netOf('ff1', 'Q')!; + const read = () => net.readNet(Q0) + net.readNet(Q1) * 2; + + expect(read(), 'start 0').toBe(0); + const got: number[] = []; + for (let i = 0; i < 5; i++) { net.setSwitch('clk', 1); net.setSwitch('clk', 0); got.push(read()); } + expect(got, 'counts 1,2,3,0,1').toEqual([1, 2, 3, 0, 1]); + }); +}); diff --git a/frontend/src/simulation/digital/digitalGateEngine.ts b/frontend/src/simulation/digital/digitalGateEngine.ts index e9c693a3..8cbafa1d 100644 --- a/frontend/src/simulation/digital/digitalGateEngine.ts +++ b/frontend/src/simulation/digital/digitalGateEngine.ts @@ -81,7 +81,18 @@ function parseGate(kind: string): { inputs: string[]; fn: (b: boolean[]) => bool return { inputs, fn }; } +// Edge-triggered flip-flops (match parts/LogicGateParts.ts edgeTriggeredFF): +// sample the data inputs on the rising edge of CLK, drive Q + Qbar. They hold +// state between edges and so break combinational loops (a counter / shift +// register feeds Q back without the settle kernel oscillating). +const FF: Record boolean }> = { + 'flip-flop-d': { data: ['D'], sample: (_q, [d]) => d }, + 'flip-flop-t': { data: ['T'], sample: (q, [t]) => (t ? !q : q) }, + 'flip-flop-jk': { data: ['J', 'K'], sample: (q, [j, k]) => (j && k ? !q : j ? true : k ? false : q) }, +}; + const isGate = (t: string) => t.startsWith('logic-gate-'); +const isFlipFlop = (t: string) => t in FF; const isSwitch = (t: string) => t === 'slide-switch'; const isLed = (t: string) => t === 'led'; const isResistor = (t: string) => t === 'resistor'; @@ -89,7 +100,7 @@ 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); + return isGate(t) || isFlipFlop(t) || isSwitch(t) || isLed(t) || isResistor(t) || isPower(t); } /** Opt-in flag, mirrors chipBusEnabled / mixedmode. Default OFF until verified. */ @@ -124,7 +135,7 @@ export function digitalGatesEnabled(): boolean { export function isAllDigital(components: DigitalComponent[]): boolean { if (components.length === 0) return false; if (!components.every((c) => isDigitalPrimitive(kindOf(c)))) return false; - return components.some((c) => isGate(kindOf(c))); + return components.some((c) => isGate(kindOf(c)) || isFlipFlop(kindOf(c))); } // Endpoint key. A printable separator (NOT a space — a lone space gets stored @@ -278,6 +289,31 @@ export function buildDigitalNetwork( update(); } + // ── Flip-flops: sample data on the rising CLK edge, drive Q + Qbar ───────── + // State is held between edges, so a Q->D feedback (counter / shift register) + // does not oscillate the settle kernel — the clock edge is the only update. + for (const c of components) { + const spec = FF[kindOf(c)]; + if (!spec) continue; + const clkNet = netKey(c.id, 'CLK'); + const dataNets = spec.data.map((p) => netKey(c.id, p)); + const qNet = netKey(c.id, 'Q'); + const qbarNet = netKey(c.id, 'Qbar'); + let prevClk = pm.getPinState(clkNet); + let q = false; + const dataSt = dataNets.map((n) => pm.getPinState(n)); + const emit = () => { + setBusDrive(pm, qNet, `${c.id}::Q`, STRONG(q ? 1 : 0)); + setBusDrive(pm, qbarNet, `${c.id}::Qbar`, STRONG(q ? 0 : 1)); + }; + dataNets.forEach((n, i) => pm.onPinChange(n, (_p, s) => { dataSt[i] = s; })); + pm.onPinChange(clkNet, (_p, s) => { + if (!prevClk && s) { q = spec.sample(q, dataSt); emit(); } + prevClk = s; + }); + emit(); // drive initial Q / Qbar + } + // 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);