/** * BoardHarness — assembles a multi-chip "board" for CPU integration tests. * * The runtime in test_custom_chips/src/ exposes: * - PinManager (numeric pin IDs, edge callbacks) * - ChipInstance.create({ wasm, pinManager, wires, attrs, simNanos }) * - chip.tickTimers(nowNanos) * * For CPU work we want to talk in *named nets* ("A0", "D7", "RD") and * have multiple chips share the same net by both registering against it. * BoardHarness owns: * - A name → numeric-pin-id map (one numeric ID per net) * - A simulated clock (nanoseconds) advanced by advanceNanos() * - A list of chips so we can tick all timers at once * * The harness imports the upstream ChipRuntime/PinManager directly. We * deliberately do NOT vendor a copy — staying lockstep with the canonical * runtime is more important than self-containment. */ import { ChipInstance } from '../../test_custom_chips/src/ChipRuntime.js'; import { PinManager } from '../../test_custom_chips/src/PinManager.js'; import { loadChipWasm } from './helpers.js'; const FIRST_NET_ID = 1000; export class BoardHarness { constructor() { this.pm = new PinManager(); this.chips = []; this.nowNanos = 0n; this._netIds = new Map(); this._nextNetId = FIRST_NET_ID; this._busListeners = []; } /** Get (or allocate) the numeric pin ID for a named net. */ net(name) { if (!this._netIds.has(name)) { this._netIds.set(name, this._nextNetId++); } return this._netIds.get(name); } /** Build a Map for ChipInstance.create's `wires` option. */ wires(pinToNet) { const m = new Map(); for (const [pin, netName] of Object.entries(pinToNet)) { m.set(pin, this.net(netName)); } return m; } /** * Instantiate and start a chip. `pinToNet` maps the chip's pin names * (the strings it passes to vx_pin_register) to the board's net names. */ async addChip(chipName, pinToNet, opts = {}) { const chip = await ChipInstance.create({ wasm: loadChipWasm(chipName), pinManager: this.pm, wires: this.wires(pinToNet), attrs: opts.attrs ?? new Map(), simNanos: () => this.nowNanos, }); chip.start(); this.chips.push(chip); return chip; } /** Advance simulated time by `nanos` ns and tick every chip's timers. */ advanceNanos(nanos) { this.nowNanos += BigInt(nanos); for (const chip of this.chips) chip.tickTimers(this.nowNanos); } /** Convenience: advance by `n` cycles of period `periodNanos`. */ clock(n, periodNanos) { for (let i = 0; i < n; i++) this.advanceNanos(periodNanos); } setNet(name, value) { this.pm.triggerPinChange(this.net(name), Boolean(value)); } getNet(name) { return this.pm.getPinState(this.net(name)); } /** PinManager invokes listeners with (pin, state). Wrap so callers see the more natural (state) signature for nets they already named. */ watchNet(name, cb) { this.pm.onPinChange(this.net(name), (_pin, state) => cb(state)); } /** Drive a wide bus (e.g. setBus("A", 16, 0xC000) drives A0..A15). */ setBus(prefix, width, value) { for (let i = 0; i < width; i++) { this.pm.triggerPinChange(this.net(`${prefix}${i}`), Boolean((value >> i) & 1)); } } /** Read a wide bus as an integer (LSB = pin 0). */ readBus(prefix, width) { let v = 0; for (let i = 0; i < width; i++) { if (this.pm.getPinState(this.net(`${prefix}${i}`))) v |= (1 << i); } return v; } /** * Install a software-only ROM that responds to the CPU's bus protocol * without compiling a separate chip per test program. The CPU drives * address pins + RD̅ (and optionally CS̅); when RD̅ falls, this watcher * reads the address bus and drives the data bus with `program[addr]`. * * Defaults match the 8080/Z80 family: * - 16-bit address bus on A0..A15 * - 8-bit data bus on D0..D7 * - RD̅ is active-low (asserted = false) * * For 8086 minimum-mode multiplexed AD bus, write a custom variant * that demuxes via ALE. */ installFakeRom(program, opts = {}) { const { addrPrefix = 'A', addrWidth = 16, dataPrefix = 'D', dataWidth = 8, rd = 'RD', rdActiveLow = true, cs = null, csActiveLow = true, baseAddr = 0, } = opts; const isAsserted = (level, activeLow) => activeLow ? level === false : level === true; const drive = (byte) => { for (let i = 0; i < dataWidth; i++) { this.pm.triggerPinChange(this.net(`${dataPrefix}${i}`), Boolean((byte >> i) & 1)); } }; const release = () => { for (let i = 0; i < dataWidth; i++) { this.pm.triggerPinChange(this.net(`${dataPrefix}${i}`), false); } }; this.pm.onPinChange(this.net(rd), (_pin, newLevel) => { const reading = isAsserted(newLevel, rdActiveLow); const csOk = !cs || isAsserted(this.getNet(cs), csActiveLow); if (!reading || !csOk) { // Released or chip not selected — tristate (don't fight other drivers). return; } const addr = this.readBus(addrPrefix, addrWidth); const offset = addr - baseAddr; // Address out of our range → tristate (let another chip drive). if (offset < 0 || offset >= program.length) return; drive(program[offset] & 0xff); }); } /** * Install a software-only RAM. Like installFakeRom but also handles * write cycles (drive D, assert WR̅ → harness latches into mem[]). */ installFakeRam(sizeBytes, opts = {}) { const { addrPrefix = 'A', addrWidth = 16, dataPrefix = 'D', dataWidth = 8, rd = 'RD', rdActiveLow = true, wr = 'WR', cs = null, baseAddr = 0, } = opts; const mem = new Uint8Array(sizeBytes); const driveData = (byte) => { for (let i = 0; i < dataWidth; i++) { this.pm.triggerPinChange(this.net(`${dataPrefix}${i}`), Boolean((byte >> i) & 1)); } }; const inRange = (addr) => addr >= baseAddr && addr < baseAddr + sizeBytes; const csOk = () => cs === null || this.getNet(cs) === false; const rdAsserted = (level) => rdActiveLow ? level === false : level === true; this.pm.onPinChange(this.net(rd), (_pin, level) => { if (!rdAsserted(level) || !csOk()) return; const addr = this.readBus(addrPrefix, addrWidth); if (!inRange(addr)) return; // tristate when address outside our range driveData(mem[addr - baseAddr]); }); this.pm.onPinChange(this.net(wr), (_pin, level) => { // Latch on rising edge of WR̅ release (i.e. WR̅ goes from 0→1) — that's // when real DRAM/SRAM samples data. CPUs typically guarantee data is // stable for a setup time before WR̅ deasserts. if (level !== true || !csOk()) return; const addr = this.readBus(addrPrefix, addrWidth); if (!inRange(addr)) return; const byte = this.readBus(dataPrefix, dataWidth); mem[addr - baseAddr] = byte & 0xff; }); return { mem, peek: (a) => mem[a - baseAddr], poke: (a, v) => { mem[a - baseAddr] = v & 0xff; }, }; } /** * Capture every (addr, data) pair the CPU writes via WR̅. Useful for * asserting the *sequence* of writes, not just final state. */ captureWrites(opts = {}) { const { addrPrefix = 'A', addrWidth = 16, dataPrefix = 'D', dataWidth = 8, wr = 'WR' } = opts; const log = []; this.pm.onPinChange(this.net(wr), (_pin, level) => { if (level !== true) return; log.push({ addr: this.readBus(addrPrefix, addrWidth), data: this.readBus(dataPrefix, dataWidth), atNanos: this.nowNanos, }); }); return log; } /** Pulse RESET̅ low for a few simulated nanoseconds, then high. */ pulseReset(opts = {}) { const { name = 'RESET', activeLow = true, holdNanos = 100, periodNanos = 250 } = opts; this.setNet(name, activeLow ? false : true); this.advanceNanos(holdNanos); this.setNet(name, activeLow ? true : false); this.advanceNanos(periodNanos); } /** Run until `predicate(board)` is true or we exceed `maxCycles` clock ticks. */ runUntil(predicate, opts = {}) { const { maxCycles = 100000, periodNanos = 500 } = opts; for (let i = 0; i < maxCycles; i++) { if (predicate(this)) return i; this.advanceNanos(periodNanos); } throw new Error(`runUntil: predicate never true after ${maxCycles} cycles`); } dispose() { for (const chip of this.chips) chip.dispose(); this.pm.clearAllListeners?.(); } }