diff --git a/frontend/src/__tests__/chipbus-buslogic.test.ts b/frontend/src/__tests__/chipbus-buslogic.test.ts new file mode 100644 index 00000000..b5c1fdea --- /dev/null +++ b/frontend/src/__tests__/chipbus-buslogic.test.ts @@ -0,0 +1,102 @@ +/** + * Phase 1 — 4-valued logic + drive-strength resolution (project/multichip-bus/). + * Covers the exit criteria from 03-phases.md: a shared net with a pull-up and + * two tri-state drivers resolves to 0/1/Z/X per the rules; contention -> X. + */ +import { describe, it, expect } from 'vitest'; +import { + resolveNet, + modeToDrive, + resolvedToBool, + Strength, + HIGHZ_DRIVE, + type Drive, +} from '../simulation/customChips/busLogic'; + +const strong = (v: 0 | 1): Drive => ({ value: v, strength: Strength.STRONG }); +const pull = (v: 0 | 1): Drive => ({ value: v, strength: Strength.PULL }); +const supply = (v: 0 | 1): Drive => ({ value: v, strength: Strength.SUPPLY }); + +describe('busLogic — resolveNet', () => { + it('no drivers -> Z (floating)', () => { + expect(resolveNet([]).v).toBe('Z'); + expect(resolveNet([HIGHZ_DRIVE, HIGHZ_DRIVE]).v).toBe('Z'); + }); + + it('a single strong driver wins', () => { + expect(resolveNet([strong(1)]).v).toBe('1'); + expect(resolveNet([strong(0)]).v).toBe('0'); + }); + + it('any real driver overrides Z', () => { + expect(resolveNet([strong(1), HIGHZ_DRIVE, HIGHZ_DRIVE]).v).toBe('1'); + }); + + it('strong beats pull', () => { + expect(resolveNet([strong(0), pull(1)]).v).toBe('0'); + expect(resolveNet([pull(0), strong(1)]).v).toBe('1'); + }); + + it('a lone pull resistor wins over all-Z (floating bus reads the pull)', () => { + expect(resolveNet([pull(1), HIGHZ_DRIVE, HIGHZ_DRIVE]).v).toBe('1'); + expect(resolveNet([pull(0)]).v).toBe('0'); + }); + + it('supply beats strong', () => { + expect(resolveNet([supply(1), strong(0)]).v).toBe('1'); + }); + + it('two strong drivers that agree -> that value (no contention)', () => { + expect(resolveNet([strong(1), strong(1)]).v).toBe('1'); + }); + + it('two strong drivers that disagree -> X (contention)', () => { + const r = resolveNet([strong(0), strong(1)]); + expect(r.v).toBe('X'); + expect(r.strength).toBe(Strength.STRONG); + }); + + it('reports the winning strength', () => { + expect(resolveNet([pull(1)]).strength).toBe(Strength.PULL); + expect(resolveNet([strong(1)]).strength).toBe(Strength.STRONG); + expect(resolveNet([]).strength).toBe(Strength.HIGHZ); + }); + + // The Phase 1 exit scenario: a data bus with a pull-up + two tri-state drivers. + it('tri-state bus: only the enabled driver drives; releasing falls to the pull-up', () => { + const pullup = pull(1); + // Driver A enabled to 0, driver B released -> bus reads 0. + expect(resolveNet([pullup, strong(0), HIGHZ_DRIVE]).v).toBe('0'); + // Both drivers released -> bus floats up to the pull-up -> 1. + expect(resolveNet([pullup, HIGHZ_DRIVE, HIGHZ_DRIVE]).v).toBe('1'); + // Both drivers enabled to OPPOSITE values -> contention -> X. + expect(resolveNet([pullup, strong(0), strong(1)]).v).toBe('X'); + }); +}); + +describe('busLogic — modeToDrive', () => { + it('outputs drive strong at their value', () => { + expect(modeToDrive(1 /* OUTPUT */, 1)).toEqual({ value: 1, strength: Strength.STRONG }); + expect(modeToDrive(16 /* OUTPUT_LOW */, 0)).toEqual({ value: 0, strength: Strength.STRONG }); + expect(modeToDrive(17 /* OUTPUT_HIGH */, 1)).toEqual({ value: 1, strength: Strength.STRONG }); + }); + + it('plain input / analog release the bus (Hi-Z)', () => { + expect(modeToDrive(0 /* INPUT */, 1).strength).toBe(Strength.HIGHZ); + expect(modeToDrive(4 /* ANALOG */, 0).strength).toBe(Strength.HIGHZ); + }); + + it('pull-up / pull-down contribute a pull-strength level', () => { + expect(modeToDrive(2 /* INPUT_PULLUP */, 0)).toEqual({ value: 1, strength: Strength.PULL }); + expect(modeToDrive(3 /* INPUT_PULLDOWN */, 1)).toEqual({ value: 0, strength: Strength.PULL }); + }); +}); + +describe('busLogic — resolvedToBool', () => { + it('only a driven 1 is high; 0/Z/X read low', () => { + expect(resolvedToBool(resolveNet([strong(1)]))).toBe(true); + expect(resolvedToBool(resolveNet([strong(0)]))).toBe(false); + expect(resolvedToBool(resolveNet([]))).toBe(false); // Z + expect(resolvedToBool(resolveNet([strong(0), strong(1)]))).toBe(false); // X + }); +}); diff --git a/frontend/src/__tests__/chipbus-busnets.test.ts b/frontend/src/__tests__/chipbus-busnets.test.ts new file mode 100644 index 00000000..0d1d71d8 --- /dev/null +++ b/frontend/src/__tests__/chipbus-busnets.test.ts @@ -0,0 +1,94 @@ +/** + * Phase 1 — multi-driver bus net registry (project/multichip-bus/). Drives the + * busNets registry against a real PinManager: tri-state release, pull-up + * fallback, contention warning, and per-chip driver teardown. + */ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { PinManager } from '../simulation/PinManager'; +import { + setBusDrive, + clearBusDriversForChip, + resetBusNets, +} from '../simulation/customChips/busNets'; +import { Strength, HIGHZ_DRIVE, type Drive } from '../simulation/customChips/busLogic'; + +const NET = 100500; // a stand-in syntheticNetPin key +const strong = (v: 0 | 1): Drive => ({ value: v, strength: Strength.STRONG }); +const pull = (v: 0 | 1): Drive => ({ value: v, strength: Strength.PULL }); + +describe('busNets — multi-driver resolution into PinManager', () => { + let pm: PinManager; + beforeEach(() => { + resetBusNets(); + pm = new PinManager(); + }); + afterEach(() => { + resetBusNets(); + vi.restoreAllMocks(); + }); + + it('a single strong driver sets the net level', () => { + setBusDrive(pm, NET, 'cpu::D0', strong(1)); + expect(pm.getPinState(NET)).toBe(true); + setBusDrive(pm, NET, 'cpu::D0', strong(0)); + expect(pm.getPinState(NET)).toBe(false); + }); + + it('tri-state hand-off: the releasing driver yields to the active one', () => { + setBusDrive(pm, NET, 'rom::D0', strong(1)); // ROM drives 1 + setBusDrive(pm, NET, 'ram::D0', HIGHZ_DRIVE); // RAM released + expect(pm.getPinState(NET)).toBe(true); + + // ROM releases, RAM drives 0 -> bus follows RAM. + setBusDrive(pm, NET, 'rom::D0', HIGHZ_DRIVE); + setBusDrive(pm, NET, 'ram::D0', strong(0)); + expect(pm.getPinState(NET)).toBe(false); + }); + + it('a pull-up holds the bus high only while every driver is released', () => { + setBusDrive(pm, NET, 'pullup::D0', pull(1)); + setBusDrive(pm, NET, 'drv::D0', HIGHZ_DRIVE); + expect(pm.getPinState(NET)).toBe(true); // floats up to the pull-up + + setBusDrive(pm, NET, 'drv::D0', strong(0)); // a strong driver beats the pull + expect(pm.getPinState(NET)).toBe(false); + + setBusDrive(pm, NET, 'drv::D0', HIGHZ_DRIVE); // released again -> pull-up wins + expect(pm.getPinState(NET)).toBe(true); + }); + + it('contention (two strong, opposite) warns once and resolves low (X)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setBusDrive(pm, NET, 'a::D0', strong(0)); + setBusDrive(pm, NET, 'b::D0', strong(1)); // now in contention + expect(warn).toHaveBeenCalledTimes(1); + expect(pm.getPinState(NET)).toBe(false); // X projects to low + + // Re-resolving while still in contention does not warn again. + setBusDrive(pm, NET, 'a::D0', strong(0)); + expect(warn).toHaveBeenCalledTimes(1); + + // Clearing the contention and re-entering it warns afresh. + setBusDrive(pm, NET, 'b::D0', HIGHZ_DRIVE); + setBusDrive(pm, NET, 'b::D0', strong(1)); + expect(warn).toHaveBeenCalledTimes(2); + }); + + it('clearBusDriversForChip releases that chip across every net', () => { + setBusDrive(pm, NET, 'rom::D0', strong(1)); + setBusDrive(pm, NET, 'ram::D0', HIGHZ_DRIVE); + expect(pm.getPinState(NET)).toBe(true); + + clearBusDriversForChip(pm, 'rom'); // ROM removed -> only RAM's Hi-Z left + expect(pm.getPinState(NET)).toBe(false); // floats (Z) -> low + }); + + it('a reader notified via onPinChange sees the resolved level synchronously', () => { + let seen: boolean | null = null; + pm.onPinChange(NET, (_p, s) => { + seen = s; + }); + setBusDrive(pm, NET, 'drv::D0', strong(1)); + expect(seen).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/chipbus-twochip-integration.test.ts b/frontend/src/__tests__/chipbus-twochip-integration.test.ts index 9255f51f..98dc3171 100644 --- a/frontend/src/__tests__/chipbus-twochip-integration.test.ts +++ b/frontend/src/__tests__/chipbus-twochip-integration.test.ts @@ -82,10 +82,22 @@ describe.skipIf(!haveFixtures)('chipbus Phase 0 — two REAL chips exchange a by ...range(8).map((i) => [`OUT${i}`, pinKey('reader', `OUT${i}`)] as [string, number]), ]); - const driver = await ChipInstance.create({ wasm: driverWasm, pinManager: pm, wires: driverWires }); + // componentId must be distinct so each chip's bus drivers are keyed apart + // (the real app always passes it; busNets keys drivers by `${id}::${pin}`). + const driver = await ChipInstance.create({ + wasm: driverWasm, + componentId: 'driver', + pinManager: pm, + wires: driverWires, + }); driver.start(); // chip_setup drives 0xA5 onto the shared D0..D7 net keys. - const reader = await ChipInstance.create({ wasm: readerWasm, pinManager: pm, wires: readerWires }); + const reader = await ChipInstance.create({ + wasm: readerWasm, + componentId: 'reader', + pinManager: pm, + wires: readerWires, + }); reader.start(); // Fire the reader's 1ms polling timer (advance sim time well past it). diff --git a/frontend/src/simulation/customChips/ChipRuntime.ts b/frontend/src/simulation/customChips/ChipRuntime.ts index 1df4c9b5..b6e524c4 100644 --- a/frontend/src/simulation/customChips/ChipRuntime.ts +++ b/frontend/src/simulation/customChips/ChipRuntime.ts @@ -11,8 +11,11 @@ import type { I2CBusManager } from '../I2CBusManager'; import { SPIBus, SPIDevice } from './SPIBus'; import { WasiShim, type SimNanosFn, type WriteStdoutFn } from './WasiShim'; import { setChipPinDrive } from './chipPinDrives'; -import { isSyntheticChipPin } from './syntheticPins'; +import { isSyntheticChipPin, isSyntheticNetPin } from './syntheticPins'; import { requestElectricalResolve } from '../spice/electricalResolveHook'; +import { chipBusEnabled } from './chipNets'; +import { setBusDrive, clearBusDriversForChip } from './busNets'; +import { modeToDrive } from './busLogic'; function readCString(memory: WebAssembly.Memory, ptr: number): string { const u8 = new Uint8Array(memory.buffer); @@ -94,6 +97,9 @@ interface PinEntry { name: string; mode: number; arduinoPin: number | null; + /** Last level written/initialized — used to compute the bus drive on a mode + * flip (e.g. OUTPUT -> INPUT releases the bus without forgetting the level). */ + value: 0 | 1; } interface AttrEntry { @@ -292,6 +298,9 @@ export class ChipInstance { for (const d of this.spiDevices) this.spiBus.removeDevice(d.device); } this.spiDevices = []; + // Stop driving any bus nets this chip contributed to, then re-resolve them + // so a removed chip releases the bus (its drivers no longer count). + if (this.componentId) clearBusDriversForChip(this.pinManager, this.componentId); this.disposed = true; } @@ -364,7 +373,28 @@ export class ChipInstance { * on the net) are emitted as chip sources; a chip pin wired to a real board * pin is already driven by that board's voltage source. */ + /** True if this pin sits on a multi-chip BUS net (Phase 1): its key is a + * syntheticNetPin and the chipbus flag is on. Such pins resolve through the + * driver-strength registry (busNets) instead of last-writer-wins PinManager. */ + private _isBusPin(p: PinEntry): boolean { + return p.arduinoPin != null && chipBusEnabled() && isSyntheticNetPin(p.arduinoPin); + } + + /** Register this pin's current (mode, value) as a bus driver and re-resolve. */ + private _busDrive(p: PinEntry): void { + if (p.arduinoPin == null) return; + setBusDrive( + this.pinManager, + p.arduinoPin, + `${this.componentId}::${p.name}`, + modeToDrive(p.mode, p.value), + ); + } + private _syncSpiceDrive(p: PinEntry): void { + // A bus net is served by the digital driver-strength path; emitting a SPICE + // chip source per chip on the same net would create false analog contention. + if (this._isBusPin(p)) return; if (!this.componentId || !p.name) return; if (p.arduinoPin == null || !isSyntheticChipPin(p.arduinoPin)) return; const isOutput = @@ -383,9 +413,12 @@ export class ChipInstance { const name = readCString(this.memory!, namePtr); const handle = this.pins.length; const arduinoPin = this.wires.has(name) ? this.wires.get(name)! : null; - const p: PinEntry = { name, mode, arduinoPin }; + const value: 0 | 1 = mode === ChipInstance.MODE_OUTPUT_HIGH ? 1 : 0; + const p: PinEntry = { name, mode, arduinoPin, value }; this.pins.push(p); - if (arduinoPin != null) { + if (this._isBusPin(p)) { + this._busDrive(p); + } else if (arduinoPin != null) { if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(arduinoPin, false); if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(arduinoPin, true); } @@ -402,7 +435,12 @@ export class ChipInstance { private _pin_write(handle: number, value: number): void { const p = this.pins[handle]; if (!p || p.arduinoPin == null) return; - this.pinManager.triggerPinChange(p.arduinoPin, value !== 0); + p.value = value !== 0 ? 1 : 0; + if (this._isBusPin(p)) { + this._busDrive(p); + } else { + this.pinManager.triggerPinChange(p.arduinoPin, value !== 0); + } this._syncSpiceDrive(p); } @@ -422,7 +460,12 @@ export class ChipInstance { const p = this.pins[handle]; if (!p) return; p.mode = mode; - if (p.arduinoPin != null) { + // OUTPUT_LOW/HIGH carry an initial level; plain OUTPUT keeps the last value. + if (mode === ChipInstance.MODE_OUTPUT_LOW) p.value = 0; + if (mode === ChipInstance.MODE_OUTPUT_HIGH) p.value = 1; + if (this._isBusPin(p)) { + this._busDrive(p); + } else if (p.arduinoPin != null) { if (mode === ChipInstance.MODE_OUTPUT_LOW) this.pinManager.triggerPinChange(p.arduinoPin, false); if (mode === ChipInstance.MODE_OUTPUT_HIGH) this.pinManager.triggerPinChange(p.arduinoPin, true); } diff --git a/frontend/src/simulation/customChips/busLogic.ts b/frontend/src/simulation/customChips/busLogic.ts new file mode 100644 index 00000000..dd403b2e --- /dev/null +++ b/frontend/src/simulation/customChips/busLogic.ts @@ -0,0 +1,117 @@ +/** + * 4-valued logic + drive-strength resolution for chip-to-chip bus nets. + * Phase 1 of the multi-chip digital bus track (project/multichip-bus/). + * + * A real wire is one of four states (the IEEE 4-state model): 0, 1, Z (high + * impedance / floating / tri-stated), X (unknown — typically two drivers + * fighting). Phase 0 gave every endpoint of a chip-to-chip net ONE shared key; + * this module turns the "release the bus by switching to VX_INPUT" idiom that + * rom-32k/ram-64k/8255 already use into REAL tri-state, so a multi-driver bus + * (many chips, one data line, only the enabled one drives) resolves correctly. + * + * Resolution is by (value, strength), not value alone (01-how-proteus-works.md + * section 3): strongest driver wins; equal strength + opposite values = X + * (contention); no real driver = Z (floating); a pull resistor is a `pull` + * driver that loses to `strong` but beats all-Z. + * + * Single-bit by design: a net is one wire. 3vl (packed 0/1/X vectors) is for + * multi-bit bus values and waveform display in a later phase; bit-level driver + * resolution needs Z + strength, which 3vl does not model, so it is a small + * self-contained fold here. + */ + +export type V4 = '0' | '1' | 'Z' | 'X'; + +/** Verilog-style drive strengths — only the few that matter for digital buses. */ +export enum Strength { + HIGHZ = 0, // not driving (a tri-stated / input pin contributes this) + PULL = 1, // pull-up / pull-down resistor, or INPUT_PULLUP/PULLDOWN + STRONG = 2, // a normal gate / chip output (the default for VX_OUTPUT) + SUPPLY = 3, // a hard power / ground rail +} + +/** One driver's contribution to a net. `HIGHZ` strength means "not driving". */ +export interface Drive { + value: 0 | 1; + strength: Strength; +} + +export const HIGHZ_DRIVE: Drive = Object.freeze({ value: 0, strength: Strength.HIGHZ }); + +export interface Resolved { + v: V4; + /** Strength of the winning driver(s); HIGHZ when the net is floating (Z). */ + strength: Strength; +} + +/** + * Resolve a net from its drivers' contributions: + * - keep the drivers of maximum real strength; + * - all agree -> that value at that strength; + * - disagree -> X (contention) at that strength; + * - no real driver -> Z at HIGHZ. + */ +export function resolveNet(drives: Iterable): Resolved { + let maxS: Strength = Strength.HIGHZ; + let val: 0 | 1 | null = null; + let contention = false; + + for (const d of drives) { + if (d.strength <= Strength.HIGHZ) continue; // tri-stated: contributes nothing + if (d.strength > maxS) { + maxS = d.strength; + val = d.value; + contention = false; + } else if (d.strength === maxS) { + if (val === null) val = d.value; + else if (val !== d.value) contention = true; + } + } + + if (val === null) return { v: 'Z', strength: Strength.HIGHZ }; + if (contention) return { v: 'X', strength: maxS }; + return { v: val === 1 ? '1' : '0', strength: maxS }; +} + +// Chip pin modes (mirror ChipInstance MODE_* / velxio-chip.h vx_pin_mode). +const VX_INPUT = 0; +const VX_OUTPUT = 1; +const VX_INPUT_PULLUP = 2; +const VX_INPUT_PULLDOWN = 3; +const VX_ANALOG = 4; +const VX_OUTPUT_LOW = 16; +const VX_OUTPUT_HIGH = 17; + +/** + * Map a chip pin's mode + its last-written level to a bus Drive. + * OUTPUT / OUTPUT_LOW / OUTPUT_HIGH -> strong drive of `value` + * INPUT_PULLUP -> pull 1 ; INPUT_PULLDOWN -> pull 0 + * INPUT / ANALOG -> Hi-Z (release the bus) + * This makes "switch to VX_INPUT" mean "stop driving" — real tri-state for free. + */ +export function modeToDrive(mode: number, value: 0 | 1): Drive { + switch (mode) { + case VX_OUTPUT: + case VX_OUTPUT_LOW: + case VX_OUTPUT_HIGH: + return { value, strength: Strength.STRONG }; + case VX_INPUT_PULLUP: + return { value: 1, strength: Strength.PULL }; + case VX_INPUT_PULLDOWN: + return { value: 0, strength: Strength.PULL }; + case VX_INPUT: + case VX_ANALOG: + default: + return HIGHZ_DRIVE; + } +} + +/** + * Project a resolved 4-value onto the boolean PinManager carries. A driven 1 is + * high; everything else (0, floating Z with no pull, contention X) reads low. + * Z and X are surfaced separately (see resolveNet) for warnings / diagnostics; + * callers that care about float-vs-low inspect the V4, not this boolean. + */ +export function resolvedToBool(r: Resolved): boolean { + return r.v === '1'; +} diff --git a/frontend/src/simulation/customChips/busNets.ts b/frontend/src/simulation/customChips/busNets.ts new file mode 100644 index 00000000..d95b1629 --- /dev/null +++ b/frontend/src/simulation/customChips/busNets.ts @@ -0,0 +1,83 @@ +/** + * Multi-driver bus net registry — Phase 1 of the multi-chip digital bus track + * (project/multichip-bus/). Sits between the chip runtime and the PinManager + * for chip-to-chip BUS net keys (syntheticNetPin) only. + * + * Phase 0 gave every endpoint of a chip-to-chip net one shared PinManager key, + * but PinManager is last-writer-wins — wrong for a bus where several chips can + * drive one data line and the rest release it (Hi-Z). This registry tracks each + * chip pin's (value, strength) contribution per net, resolves the net with the + * 4-valued rules (busLogic.resolveNet), and pushes the RESOLVED level into the + * PinManager so readers see the bus, not the last writer. Contention (two strong + * drivers disagree -> X) is surfaced as a console warning, like Proteus. + * + * Only BUS net keys flow through here; board pins and single-chip-to-component + * synthetic pins keep the legacy direct PinManager path untouched. + */ +import { resolveNet, resolvedToBool, type Drive } from './busLogic'; + +interface PinManagerLike { + triggerPinChange(pin: number, state: boolean, source?: 'mcu' | 'external'): void; +} + +// netKey -> (driverId -> Drive). driverId = `${componentId}::${pinName}`. +const nets = new Map>(); +// Nets currently flagged as in contention — so we warn once per onset, not per +// re-resolve, and clear when the contention is gone. +const inContention = new Set(); + +function recompute(pm: PinManagerLike, netKey: number): void { + const drivers = nets.get(netKey); + const resolved = resolveNet(drivers ? drivers.values() : []); + + if (resolved.v === 'X') { + if (!inContention.has(netKey)) { + inContention.add(netKey); + console.warn( + `[chipbus] bus contention on net ${netKey}: two strong drivers disagree (resolved X)`, + ); + } + } else { + inContention.delete(netKey); + } + + // PinManager is boolean; push the projected level (only a driven 1 is high). + pm.triggerPinChange(netKey, resolvedToBool(resolved)); +} + +/** Set (or replace) one chip pin's contribution to a bus net and re-resolve. */ +export function setBusDrive( + pm: PinManagerLike, + netKey: number, + driverId: string, + drive: Drive, +): void { + let m = nets.get(netKey); + if (!m) { + m = new Map(); + nets.set(netKey, m); + } + m.set(driverId, drive); + recompute(pm, netKey); +} + +/** Remove every driver a chip contributes (on dispose) and re-resolve its nets. */ +export function clearBusDriversForChip(pm: PinManagerLike, componentId: string): void { + const prefix = `${componentId}::`; + for (const [netKey, m] of nets) { + let changed = false; + for (const id of [...m.keys()]) { + if (id.startsWith(prefix)) { + m.delete(id); + changed = true; + } + } + if (changed) recompute(pm, netKey); + } +} + +/** Test seam: wipe all bus-net driver state. */ +export function resetBusNets(): void { + nets.clear(); + inContention.clear(); +} diff --git a/frontend/src/simulation/customChips/syntheticPins.ts b/frontend/src/simulation/customChips/syntheticPins.ts index 2de0b0a4..0ddb2416 100644 Binary files a/frontend/src/simulation/customChips/syntheticPins.ts and b/frontend/src/simulation/customChips/syntheticPins.ts differ