diff --git a/frontend/src/__tests__/chipbus-z80-rom-boot.test.ts b/frontend/src/__tests__/chipbus-z80-rom-boot.test.ts new file mode 100644 index 00000000..70257acc --- /dev/null +++ b/frontend/src/__tests__/chipbus-z80-rom-boot.test.ts @@ -0,0 +1,127 @@ +/** + * Phase 0-2 LIVE end-to-end proof on a REAL CPU (project/multichip-bus/). + * + * A real Z80 (examples/intel/z80.c) and a 32K EPROM (z80-boot-rom.c, a rom-32k + * variant) are wired chip-to-chip over a shared address + data bus, NO board. + * The ROM holds `JP 0x0006 / HALT`. Booting it requires the Z80 to: + * - drive the address bus -> ROM reacts (shared net key, Phase 0); + * - assert RD -> ROM tri-state-drives the data bus while the Z80 released it + * (Phase 1 drive resolution); + * - read the data bus IN THE SAME tickTimers step and get the settled byte + * (Phase 2 settle-before-read); + * fetch C3,06,00 (the JP + target), jump, fetch 76 at 0x0006, and HALT — which + * drives HALT low. Observing HALT go low is the proof the whole bus works. + * + * Fixtures compiled with wasi-sdk (see test_intel/scripts/compile-chip.sh flags); + * skips cleanly if absent. z80.wasm from examples/intel/z80.c; z80-boot-rom.wasm + * from project/multichip-bus research (rom-32k with a boot image). + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { PinManager } from '../simulation/PinManager'; +import { ChipInstance } from '../simulation/customChips/ChipRuntime'; +import { + resolveChipNetKey, + setChipBusEnabledForTest, + resetChipNetIndexForTest, + type ChipNetState, +} from '../simulation/customChips/chipNets'; +import { syntheticChipPin } from '../simulation/customChips/syntheticPins'; +import { resetBusNets } from '../simulation/customChips/busNets'; + +const z80Path = fileURLToPath(new URL('./fixtures/chipbus/z80.wasm', import.meta.url)); +const romPath = fileURLToPath(new URL('./fixtures/chipbus/z80-boot-rom.wasm', import.meta.url)); +const haveFixtures = existsSync(z80Path) && existsSync(romPath); + +const range = (n: number, from = 0) => Array.from({ length: n }, (_, i) => i + from); + +const Z80_PINS = [ + ...range(16).map((i) => `A${i}`), + ...range(8).map((i) => `D${i}`), + 'M1', 'MREQ', 'IORQ', 'RD', 'WR', 'RFSH', 'HALT', 'WAIT', + 'INT', 'NMI', 'RESET', 'BUSREQ', 'BUSACK', 'CLK', 'VCC', 'GND', +]; +const ROM_PINS = [ + ...range(15).map((i) => `A${i}`), + ...range(8).map((i) => `D${i}`), + 'CE', 'OE', 'VCC', 'GND', +]; + +// The schematic: address A0..A14 and data D0..D7 straight across, plus the Z80's +// RD strobe into the ROM's OE so the ROM only drives during reads. CE is left +// unwired (reads 0 = always enabled). +const STATE: ChipNetState = { + wires: [ + ...range(15).map((i) => ({ + start: { componentId: 'z80', pinName: `A${i}` }, + end: { componentId: 'rom', pinName: `A${i}` }, + })), + ...range(8).map((i) => ({ + start: { componentId: 'z80', pinName: `D${i}` }, + end: { componentId: 'rom', pinName: `D${i}` }, + })), + { start: { componentId: 'z80', pinName: 'RD' }, end: { componentId: 'rom', pinName: 'OE' } }, + ], + components: [ + { id: 'z80', metadataId: 'custom-chip' }, + { id: 'rom', metadataId: 'custom-chip' }, + ], + boards: [], +}; + +function pinKey(chipId: string, pin: string): number { + return resolveChipNetKey(STATE, chipId, pin) ?? syntheticChipPin(chipId, pin); +} +function wiresFor(chipId: string, pins: string[]): Map { + return new Map(pins.map((p) => [p, pinKey(chipId, p)] as [string, number])); +} + +describe.skipIf(!haveFixtures)('chipbus Phase 0-2 — a real Z80 boots from a ROM over the bus', () => { + beforeEach(() => { + setChipBusEnabledForTest(true); + resetChipNetIndexForTest(); + resetBusNets(); + }); + afterEach(() => { + setChipBusEnabledForTest(null); + resetChipNetIndexForTest(); + resetBusNets(); + }); + + it('fetches JP+HALT from the ROM across the bus and drives HALT low', async () => { + const z80Wasm = new Uint8Array(readFileSync(z80Path)); + const romWasm = new Uint8Array(readFileSync(romPath)); + const pm = new PinManager(); + + const z80 = await ChipInstance.create({ + wasm: z80Wasm, componentId: 'z80', pinManager: pm, wires: wiresFor('z80', Z80_PINS), + }); + z80.start(); + const rom = await ChipInstance.create({ + wasm: romWasm, componentId: 'rom', pinManager: pm, wires: wiresFor('rom', ROM_PINS), + }); + rom.start(); + + const halt = pinKey('z80', 'HALT'); + // At power-on the Z80 holds HALT high (running, registered OUTPUT_HIGH). + expect(pm.getPinState(halt)).toBe(true); + + // on_clock bails while BUSREQ/WAIT read low (unwired inputs default 0), so + // deassert them, then release RESET (rising edge) to start the CPU. + pm.triggerPinChange(pinKey('z80', 'BUSREQ'), true); + pm.triggerPinChange(pinKey('z80', 'WAIT'), true); + pm.triggerPinChange(pinKey('z80', 'RESET'), true); + + // Run the 4 MHz pseudo-clock (250 ns period) for ~200 ticks — far more than + // the 2 instructions to reach HALT. + z80.tickTimers(50_000n); + + // HALT went low: the Z80 executed JP 0x0006 then HALT, having correctly read + // every byte from the ROM over the shared chip-to-chip bus. + expect(pm.getPinState(halt)).toBe(false); + + z80.dispose(); + rom.dispose(); + }); +}); diff --git a/frontend/src/__tests__/fixtures/chipbus/z80-boot-rom.wasm b/frontend/src/__tests__/fixtures/chipbus/z80-boot-rom.wasm new file mode 100644 index 00000000..3e2ef522 Binary files /dev/null and b/frontend/src/__tests__/fixtures/chipbus/z80-boot-rom.wasm differ diff --git a/frontend/src/__tests__/fixtures/chipbus/z80.wasm b/frontend/src/__tests__/fixtures/chipbus/z80.wasm new file mode 100644 index 00000000..ca42d0eb Binary files /dev/null and b/frontend/src/__tests__/fixtures/chipbus/z80.wasm differ diff --git a/test/test_custom_chips/sdk/examples/z80-boot-rom.c b/test/test_custom_chips/sdk/examples/z80-boot-rom.c new file mode 100644 index 00000000..1730833c --- /dev/null +++ b/test/test_custom_chips/sdk/examples/z80-boot-rom.c @@ -0,0 +1,104 @@ +/* + * z80-boot-rom — a rom-32k variant whose image is a tiny Z80 boot program, + * for the Phase 0-2 live proof (project/multichip-bus/). Identical to + * examples/intel/rom-32k.c except for rom_image, which holds: + * + * 0000: C3 06 00 JP 0x0006 ; requires reading C3,06,00 over the bus + * 0003: 00 00 00 NOP x3 ; jumped over + * 0006: 76 HALT ; Z80 drives HALT low here -> observable + * + * Reaching HALT proves the Z80 fetched the multi-byte JP and its target from + * this ROM across the shared data bus and executed it. + */ +#include "velxio-chip.h" +#include +#include + +#define ROM_SIZE 0x8000 /* 32 KB */ + +static const uint8_t rom_image[ROM_SIZE] = { + [0x0000] = 0xC3, [0x0001] = 0x06, [0x0002] = 0x00, /* JP 0x0006 */ + [0x0003] = 0x00, [0x0004] = 0x00, [0x0005] = 0x00, /* NOP NOP NOP */ + [0x0006] = 0x76, /* HALT */ +}; + +typedef struct { + vx_pin a[15]; + vx_pin d[8]; + vx_pin ce; + vx_pin oe; + vx_pin vcc; + vx_pin gnd; + bool driving; +} chip_t; + +static chip_t G; + +static uint16_t read_addr(void) { + uint16_t v = 0; + for (int i = 0; i < 15; i++) if (vx_pin_read(G.a[i])) v |= (1u << i); + return v; +} + +static uint8_t image_byte(uint16_t addr) { + if (addr >= ROM_SIZE) return 0xFF; + /* honour the programmed low bytes; an erased EPROM reads 0xFF elsewhere */ + if (addr >= 0x10) return 0xFF; + return rom_image[addr]; +} + +static void drive_data(uint8_t v) { + for (int i = 0; i < 8; i++) { + vx_pin_set_mode(G.d[i], VX_OUTPUT); + vx_pin_write(G.d[i], (v >> i) & 1); + } + G.driving = true; +} + +static void release_data(void) { + if (!G.driving) return; + for (int i = 0; i < 8; i++) vx_pin_set_mode(G.d[i], VX_INPUT); + G.driving = false; +} + +static void update_outputs(void) { + int ce_low = (vx_pin_read(G.ce) == 0); + int oe_low = (vx_pin_read(G.oe) == 0); + if (ce_low && oe_low) { + drive_data(image_byte(read_addr())); + } else { + release_data(); + } +} + +static void on_pin_change(void* user_data, vx_pin pin, int value) { + (void)user_data; (void)pin; (void)value; + update_outputs(); +} + +void chip_setup(void) { + char name[4]; + for (int i = 0; i < 15; i++) { + name[0]='A'; + if (i<10) { name[1]='0'+i; name[2]=0; } + else { name[1]='1'; name[2]='0'+(i-10); name[3]=0; } + G.a[i] = vx_pin_register(name, VX_INPUT); + } + for (int i = 0; i < 8; i++) { + name[0]='D'; name[1]='0'+i; name[2]=0; + G.d[i] = vx_pin_register(name, VX_INPUT); + } + G.ce = vx_pin_register("CE", VX_INPUT); + G.oe = vx_pin_register("OE", VX_INPUT); + G.vcc = vx_pin_register("VCC", VX_INPUT); + G.gnd = vx_pin_register("GND", VX_INPUT); + G.driving = false; + + for (int i = 0; i < 15; i++) { + vx_pin_watch(G.a[i], VX_EDGE_BOTH, on_pin_change, 0); + } + vx_pin_watch(G.ce, VX_EDGE_BOTH, on_pin_change, 0); + vx_pin_watch(G.oe, VX_EDGE_BOTH, on_pin_change, 0); + + update_outputs(); +} diff --git a/test/test_custom_chips/sdk/examples/z80-boot-rom.chip.json b/test/test_custom_chips/sdk/examples/z80-boot-rom.chip.json new file mode 100644 index 00000000..f2429a6e --- /dev/null +++ b/test/test_custom_chips/sdk/examples/z80-boot-rom.chip.json @@ -0,0 +1,9 @@ +{ + "schema": "velxio-chip/v1", + "name": "Z80 Boot ROM (JP+HALT)", + "author": "Velxio", + "license": "MIT", + "description": "A rom-32k variant holding a tiny Z80 boot program (JP 0x0006; HALT). Fixture for the Phase 0-2 live proof that a real Z80 boots from a ROM over a chip-to-chip bus.", + "pins": ["A0", "A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "A12", "A13", "A14", "D0", "D1", "D2", "D3", "D4", "D5", "D6", "D7", "CE", "OE", "VCC", "GND"], + "attributes": [] +}