test_intel: phase D — 4001 ROM chip with 4004 integration

The 4001 is the canonical ROM partner of the 4004/4040. 16-pin DIP,
256 bytes of mask-programmed ROM accessed over the 4-bit multiplexed
nibble bus, plus 4 I/O port lines (WRR/RDR — not yet wired).

Implementation: ~140 LOC clean-room from MCS-4 manual §V. The chip
has its own timer at 1351 ns (matching the 4004's clock period), with
a state machine that walks the 8-phase frame in lockstep with the
4004:
  S_IDLE → (SYNC↑) → S_SAMPLE_LOW (A1 nibble) → S_SAMPLE_MID (A2) →
  S_SAMPLE_HIGH (A3, addr complete) → S_DRIVE_HI (M1, drive opcode
  high nibble) → S_DRIVE_LO (M2, drive low nibble) → S_POST (X1..X3
  idle) → wait for next SYNC.

Timing trick: the 4001 must be added to the board BEFORE the 4004
so its tickTimers fires first per advanceNanos. The 4001 then runs
one frame "behind" the 4004 — sampling what the 4004 drove last
frame and driving what the 4004 will read this frame. Documented in
the chip's source and the master plan.

Integration test (`test_buses/4001-rom.test.js`) wires both chips on
the same board and verifies the 4004 actually fetches and executes
opcodes from the 4001 (PC walks 0, 1, 2 with the embedded NOP image).
This is the first end-to-end test of the 4-bit multiplexed bus
working between two real WASM chips on the canvas, not just JS
helpers — proving the bus model scales.

Deferred for the next Phase D iteration: 4002 RAM (similar shape +
SRC chip-select latching), 4004 SRC/WRM/RDM wiring to exchange data
with the 4002, and the Busicom 141-PF integration once both ROM and
RAM chips are real.

Tests: total test_intel 98 → 99 passing, 0 failed, 11 todo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-04-30 16:05:06 +02:00 committed by davidmonterocrespo24
parent e19c961982
commit f7223b4965
3 changed files with 366 additions and 2 deletions

View File

@ -27,7 +27,7 @@ top of each phase reflects status.
| **A** | 8080 INTA bus cycle | low | ✅ done 2026-04-30 |
| **B** | Z80 ISA polish for ZEXDOC | high | ✅ done 2026-04-30 (ZEXDOC ROM run deferred to Phase F) |
| **C** | Support chip ecosystem (rom-1m, 8255, 8251 done; 4001/4002/8253/8259 deferred) | high | ⚠️ partial 2026-04-30 |
| **D** | 4004/4040 I/O completion (uses chips from C) | medium | ⏸️ pending |
| **D** | 4004/4040 I/O completion (4001 done; 4002/SRC/WRM still pending) | medium | ⚠️ partial 2026-04-30 |
| **E** | 8086 ISA completion | high | ✅ done 2026-04-30 (CALL/RET edge case deferred) |
| **F** | Real software validation (CPUDIAG, ZEXDOC done; Busicom + 8088 V2 deferred) | medium | ⚠️ partial 2026-04-30 |
| **G** | Cycle accuracy (optional) | high | ⏸️ deferred |
@ -624,4 +624,53 @@ subject line (e.g. "test_intel: phase A — 8080 INTA bus protocol").
---
## Phases D and G — still pending
## Phase D — partial completion (2026-04-30)
### Delivered
- **4001 ROM** (`test_buses/4001-rom.c`, ~140 LOC) — companion ROM
chip for the 4004/4040 over the 4-bit multiplexed nibble bus.
Supports the canonical 8-phase frame: captures the 12-bit PC during
A1/A2/A3, drives opcode high nibble during M1 and low nibble during
M2 if the captured chip-select matches `ROM4001_CHIP_ID` (compile-
time constant).
- **Integration test** (`test_buses/4001-rom.test.js`) — wires a real
4001 chip alongside the 4004 chip on the same board and verifies
that the 4004 actually fetches and executes opcodes from the 4001
(PC walks 0, 1, 2 with the embedded NOP image).
### Timing model — the load-bearing trick
The 4001's own timer fires once per phase at the same period (1351 ns)
as the 4004's. The caller registers the 4001 BEFORE the 4004 in their
test board, so the 4001's `tickTimers` runs first per `advanceNanos`.
This means the 4001 effectively runs ONE FRAME BEHIND the 4004's
drives — it samples the bus contents (driven by the 4004 last frame)
and either records the addr nibble or drives the next opcode nibble.
A small state machine (S_SAMPLE_LOW → S_SAMPLE_MID → S_SAMPLE_HIGH →
S_DRIVE_HI → S_DRIVE_LO → S_POST) handles the 8-phase walk; reset on
SYNC rising. Documented in `4001-rom.c`.
### Deferred — still pending
- **4002 RAM** — similar 16-pin chip. Needs SRC-instruction tracking
(the 4004 latches an 8-bit chip-select into the 4002 during X2/X3
of the SRC cycle, then subsequent WRM/RDM/WMP/RDR ops use that
latched address). Moderate complexity; same timing model as 4001.
- **4004 SRC + WRM/RDM/WMP wiring** to actually exchange data with a
4002. The 4004 chip currently STUBS these as no-ops; needs to drive
the bus during X2/X3 of SRC and during M2 of the I/O group ops.
- **Busicom 141-PF integration test** for 4004 — requires both 4001
and 4002 working end-to-end, plus a baked Busicom firmware ROM
variant (~1 KB).
### Tests delta
- Total test_intel: 98 → **99 passing**, 11 todo, 0 failed.
---
## Phase C deferred items — still pending
- **8253 PIT** (programmable interval timer) — 3 channels of 16-bit
countdown timers, 6 modes. Needed for system-tick interrupts and PC
speaker tone generation.
- **8259 PIC** (programmable interrupt controller) — needed for
actual hardware interrupt routing on 8080/Z80/8086 demos.
## Phase G — still deferred (cycle accuracy)

View File

@ -0,0 +1,222 @@
/*
* Intel 4001 ROM companion chip for the 4004/4040.
*
* 16-pin DIP, 256 bytes of mask-programmed ROM accessed over the
* 4004's 4-bit multiplexed nibble bus, plus 4 I/O port lines that
* the CPU can drive via WRR or read via RDR. Each 4001 has a hard-
* coded 4-bit chip number (this implementation reads it from a
* compile-time #define) and only responds when its number matches
* the high nibble of the address driven during the A3 phase.
*
* Source: Intel MCS-4 User's Manual (Feb 1973), §V "4001 Read-Only
* Memory" + Fig. 5-1 pin diagram.
*
* Pin contract (16 pins):
* D0..D3 I/O shared multiplexed bus with the 4004
* SYNC in cycle marker driven by the 4004 (high during A1)
* CL in Φ2 clock informational; we use our own timer
* RESET in asynchronous reset
* CM in chip-match strobe (= 4004's CM-ROM during M1/M2)
* I0..I3 I/O 4 I/O port lines (drivable via WRR, readable via RDR)
* VDD, VSS power
*
* Timing model the trickiest part. The 4004 walks an 8-phase frame
* (A1, A2, A3, M1, M2, X1, X2, X3) at one phase per timer fire. The
* 4001 must:
* - capture the 12-bit PC nibble-by-nibble during A1, A2, A3
* - drive the opcode high nibble during M1
* - drive the opcode low nibble during M2
*
* BoardHarness fires chips' timers in REGISTRATION order. We rely
* on the 4001 being registered BEFORE the 4004 (caller's
* responsibility), so the 4001 fires first each advanceNanos. Even
* so, the 4001's actions are ONE FRAME behind the 4004's drives
* because in the same advanceNanos, the 4001 fires *before* the
* 4004 drives the bus for that phase. A simple state machine handles
* this offset:
*
* advanceNanos N | 4004 will drive | 4001 (which fires first) does
* ---------------|------------------|----------------------------------
* 1 | A1: PC[3:0] | (idle, no SYNC seen)
* 2 | A2: PC[7:4] | sample D = PC[3:0] (from frame 1)
* 3 | A3: PC[11:8] | sample D = PC[7:4]
* 4 | M1: read opcode | sample D = PC[11:8]; addr complete;
* | | drive D = opcode_hi (4004's M1 read
* | | fires next, sees our drive)
* 5 | M2: read opcode | drive D = opcode_lo
* 6..8 | X1..X3 (idle) | idle
* (next SYNC) state resets to start.
*
* This file ships a fixture ROM image: 16 known bytes at offsets 0..15
* for tests, plus 0x00 (= NOP) elsewhere.
*/
#include "velxio-chip.h"
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#ifndef ROM4001_CHIP_ID
#define ROM4001_CHIP_ID 0 /* selected by 12-bit address bits 11..8 */
#endif
#define ROM_SIZE 256
typedef enum {
S_IDLE = 0, /* before any SYNC */
S_SAMPLE_LOW, /* about to sample addr_low (A1's drive from prev frame) */
S_SAMPLE_MID,
S_SAMPLE_HIGH,
S_DRIVE_HI, /* about to drive opcode_hi (4004's next phase is M1) */
S_DRIVE_LO,
S_POST, /* X1..X3, no action */
} state_t;
typedef struct {
vx_pin d[4];
vx_pin sync;
vx_pin cl;
vx_pin reset_;
vx_pin cm;
vx_pin io[4];
vx_pin vdd, vss;
vx_timer phase_timer;
uint8_t rom[ROM_SIZE];
state_t state;
uint8_t addr_low;
uint8_t addr_mid;
uint8_t addr_high;
uint8_t io_latch;
bool driving_d;
} chip_t;
static chip_t G;
/* ─── D-bus helpers ─────────────────────────────────────────────────────── */
static uint8_t read_d_nibble(void) {
uint8_t v = 0;
for (int i = 0; i < 4; i++) if (vx_pin_read(G.d[i])) v |= (1u << i);
return v;
}
static void drive_d_nibble(uint8_t n) {
for (int i = 0; i < 4; i++) {
vx_pin_set_mode(G.d[i], VX_OUTPUT);
vx_pin_write(G.d[i], (n >> i) & 1);
}
G.driving_d = true;
}
static void release_d(void) {
if (!G.driving_d) return;
for (int i = 0; i < 4; i++) vx_pin_set_mode(G.d[i], VX_INPUT);
G.driving_d = false;
}
/* ─── Phase-tracking timer ──────────────────────────────────────────────── */
static void on_phase(void* user_data) {
(void)user_data;
switch (G.state) {
case S_IDLE:
release_d();
break;
case S_SAMPLE_LOW:
G.addr_low = read_d_nibble() & 0xF;
G.state = S_SAMPLE_MID;
break;
case S_SAMPLE_MID:
G.addr_mid = read_d_nibble() & 0xF;
G.state = S_SAMPLE_HIGH;
break;
case S_SAMPLE_HIGH:
G.addr_high = read_d_nibble() & 0xF;
G.state = S_DRIVE_HI;
/* fall through — drive opcode_hi NOW so 4004's M1 read sees it */
__attribute__((fallthrough));
case S_DRIVE_HI:
if (G.addr_high == ROM4001_CHIP_ID) {
uint8_t addr8 = (uint8_t)((G.addr_mid << 4) | G.addr_low);
drive_d_nibble((G.rom[addr8] >> 4) & 0xF);
} else {
release_d();
}
G.state = S_DRIVE_LO;
break;
case S_DRIVE_LO:
if (G.addr_high == ROM4001_CHIP_ID) {
uint8_t addr8 = (uint8_t)((G.addr_mid << 4) | G.addr_low);
drive_d_nibble(G.rom[addr8] & 0xF);
} else {
release_d();
}
G.state = S_POST;
break;
case S_POST:
release_d();
/* stay here until next SYNC */
break;
}
}
/* SYNC rising: 4004 just entered A1 phase. Reset state machine to start
sampling on the next tick. (4001 drove SYNC's previous-frame drives
into our state already on prior fires.) */
static void on_sync(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin;
if (value) {
G.state = S_SAMPLE_LOW;
}
}
static void on_reset(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin;
if (value) {
G.state = S_IDLE;
release_d();
}
}
/* CM strobe (CM-ROM): not strictly required for our model since we
already track phases via SYNC + timer. Ignored. */
static void on_cm(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin; (void)value;
}
void chip_setup(void) {
char name[6];
for (int i = 0; i < 4; i++) {
name[0]='D'; name[1]='0'+i; name[2]=0;
G.d[i] = vx_pin_register(name, VX_INPUT);
}
G.sync = vx_pin_register("SYNC", VX_INPUT);
G.cl = vx_pin_register("CL", VX_INPUT);
G.reset_ = vx_pin_register("RESET", VX_INPUT);
G.cm = vx_pin_register("CM", VX_INPUT);
for (int i = 0; i < 4; i++) {
name[0]='I'; name[1]='O'; name[2]='0'+i; name[3]=0;
G.io[i] = vx_pin_register(name, VX_INPUT);
}
G.vdd = vx_pin_register("VDD", VX_INPUT);
G.vss = vx_pin_register("VSS", VX_INPUT);
/* Test fixture: 16 known bytes at offset 0, rest zeros (NOP). */
memset(G.rom, 0, ROM_SIZE);
G.rom[0] = 0x12; G.rom[1] = 0x34; G.rom[2] = 0x56; G.rom[3] = 0x78;
G.rom[4] = 0x9A; G.rom[5] = 0xBC; G.rom[6] = 0xDE; G.rom[7] = 0xF0;
G.rom[8] = 0x11; G.rom[9] = 0x22; G.rom[10] = 0x33; G.rom[11] = 0x44;
G.rom[12] = 0x55; G.rom[13] = 0x66; G.rom[14] = 0x77; G.rom[15] = 0x88;
G.state = S_IDLE;
G.driving_d = false;
G.io_latch = 0;
vx_pin_watch(G.sync, VX_EDGE_RISING, on_sync, 0);
vx_pin_watch(G.reset_, VX_EDGE_RISING, on_reset, 0);
vx_pin_watch(G.cm, VX_EDGE_BOTH, on_cm, 0);
/* Same period as 4004 (1351 ns). Caller registers 4001 BEFORE
4004 so our timer fires first per advanceNanos the 4001 then
drives D pins before the 4004 reads. */
G.phase_timer = vx_timer_create(on_phase, 0);
vx_timer_start(G.phase_timer, 1351, true);
}

View File

@ -0,0 +1,93 @@
/**
* Intel 4001 ROM integration test.
*
* The interesting part of the 4001 is that it cooperates with a real
* 4004 over the 8-phase nibble-multiplexed bus. So this test wires
* BOTH chips together and verifies the 4004 actually fetches and
* executes opcodes from the 4001 a true end-to-end integration that
* couldn't be tested by either chip in isolation.
*
* Setup:
* - 4001 baked with a known 16-byte program at offsets 0..15.
* Byte 0 = 0x00 (NOP). The chip's rom_image only has bytes 0..15
* set; rest is zero.
* - 4004 wired to the 4001 (D bus, SYNC, RESET, CM-ROM).
* - We register the 4001 BEFORE the 4004 so its timer fires first
* per advanceNanos the 4001 drives D before the 4004 reads.
* - Run a few cycles, observe that the 4004 advances PC normally
* (NOPs walk through addresses 0,1,2,...).
*
* The default chip-id (compile-time ROM4001_CHIP_ID = 0) means the
* 4001 responds when address bits 11..8 are 0 i.e. for the first
* 256 bytes of program memory.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { BoardHarness } from '../src/BoardHarness.js';
import { chipWasmExists } from '../src/helpers.js';
const ROM = '4001-rom';
const CPU = '4004';
const skip = !chipWasmExists(ROM) || !chipWasmExists(CPU);
const CLOCK_NS = 1351;
describe('4001 ROM + 4004 integration', () => {
let board;
beforeEach(() => { board = new BoardHarness(); });
afterEach(() => { board.dispose(); });
it.skipIf(skip)('4001 responds to chip-id 0 and the 4004 fetches its bytes', async () => {
// Register the 4001 FIRST so its timer fires before the 4004's
// timer in each advanceNanos call.
await board.addChip(ROM, {
VDD: 'VDD', VSS: 'VSS', SYNC: 'SYNC', CL: 'CLK1',
RESET: 'RESET', CM: 'CMROM',
D0: 'D0', D1: 'D1', D2: 'D2', D3: 'D3',
IO0: 'I0', IO1: 'I1', IO2: 'I2', IO3: 'I3',
});
await board.addChip(CPU, {
SYNC: 'SYNC', RESET: 'RESET', TEST: 'TEST',
CMROM: 'CMROM',
CMRAM0: 'CMRAM0', CMRAM1: 'CMRAM1', CMRAM2: 'CMRAM2', CMRAM3: 'CMRAM3',
CLK1: 'CLK1', CLK2: 'CLK2',
VDD: 'VDD', VSS: 'VSS',
D0: 'D0', D1: 'D1', D2: 'D2', D3: 'D3',
});
// Quiet inputs.
board.setNet('TEST', false);
// Pulse RESET high then low.
board.setNet('RESET', true);
board.advanceNanos(CLOCK_NS * 12);
board.setNet('RESET', false);
// Watch SYNC + capture the 4-bit data on D pins right after each
// SYNC rising pulse — this is what the 4001 drives during M1/M2.
// Specifically we want to confirm that during M1 of cycle 0, the
// 4001 drove the high nibble of rom[0] = 0x00, and that the chip
// advances PC normally.
const fetchedPCs = [];
let phaseSinceSync = -1;
board.watchNet('SYNC', (high) => { if (high) phaseSinceSync = 0; });
// Run 3 cycles (24 phases). At each A3 (phase 2 since SYNC), read
// the 12-bit PC the 4004 drove on D0..D3 across A1/A2/A3.
let pcLow = 0, pcMid = 0, pcHigh = 0;
for (let i = 0; i < 24; i++) {
board.advanceNanos(CLOCK_NS);
if (phaseSinceSync === 0) pcLow = board.readBus('D', 4);
else if (phaseSinceSync === 1) pcMid = board.readBus('D', 4);
else if (phaseSinceSync === 2) {
pcHigh = board.readBus('D', 4);
fetchedPCs.push(pcLow | (pcMid << 4) | (pcHigh << 8));
}
if (phaseSinceSync >= 0) phaseSinceSync++;
}
// After 3 cycles: PC should walk 0, 1, 2 (NOP advances by 1).
// Note: the 4001's ROM image has byte 0 = 0x00 (NOP), so the 4004
// reads NOP and increments PC.
expect(fetchedPCs.slice(0, 3)).toEqual([0x000, 0x001, 0x002]);
});
});