velxio/frontend/src/simulation/digital/digitalGateEngine.ts

301 lines
12 KiB
TypeScript

/**
* 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 */
}
// On by default: all-digital gate circuits are evaluated exactly + instantly by
// the event-driven engine (the ngspice B-source path could not light a 4-bit
// adder). Only pure all-digital-with-a-gate circuits take this path; mixed /
// analog circuits stay on ngspice. Override with ?digitalgates=off.
return true;
}
/**
* True iff every component is a digital primitive AND at least one is a logic
* gate. The gate requirement keeps the engine from claiming degenerate analog
* circuits that happen to use only {source, resistor, LED} with no logic — those
* stay on ngspice.
*/
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)));
}
// 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,
};
}