feat(chipbus): Phase 1 - 4-valued logic + drive-strength tri-state buses

A chip-to-chip net is now resolved by (value, strength), not last-writer-
wins, so a real multi-driver bus works: many chips on one data line, only
the enabled one drives, the rest release to Hi-Z.

- busLogic.ts: 4-valued (0/1/Z/X) + drive-strength resolution. Strongest
  driver wins; equal strength + opposite = X (contention); no driver = Z;
  pull resistor = pull strength. modeToDrive maps VX_OUTPUT -> strong,
  VX_INPUT -> Hi-Z (the rom/ram/8255 "release by input" idiom becomes real
  tri-state), VX_INPUT_PULLUP/DOWN -> pull.
- busNets.ts: per-net driver registry; resolves and pushes the resolved
  level into PinManager; warns once on contention.
- syntheticPins.ts: isSyntheticNetPin distinguishes bus net keys.
- ChipRuntime.ts: pin register/write/set_mode route bus-net pins through
  busNets (gated by chipBusEnabled + isSyntheticNetPin); non-bus pins keep
  the legacy path; dispose releases the chip's bus drivers. SPICE source
  emission is skipped for bus pins (digital fast path beside SPICE).

Tests: busLogic (14), busNets (6, incl. tri-state hand-off + contention),
and the two-real-chip integration now exchanges 0xA5 through the registry.
Full suite 2074 pass with the flag off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 02:18:47 -03:00
parent 474d132368
commit 23516247a6
7 changed files with 458 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<number, Map<string, Drive>>();
// 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<number>();
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();
}