velxio/test/test_intel/test_4004/4004.test.js

450 lines
17 KiB
JavaScript

/**
* Intel 4004 emulator chip — TDD spec.
*
* The 4004 is electrically the most exotic chip on the list:
* - 4-bit data bus on D0..D3 multiplexed with addresses across an
* 8-cycle instruction frame (A1, A2, A3, M1, M2, X1, X2, X3).
* - SYNC pulses to mark the start of each instruction frame.
* - Two-phase clock (CLK1, CLK2).
* - 16 pins total.
*
* Because the bus is so different from the 8080/Z80, we don't reuse the
* fake-ROM helper. Tests here observe the bus phase-by-phase.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { BoardHarness } from '../src/BoardHarness.js';
import { chipWasmExists } from '../src/helpers.js';
const CHIP = '4004';
const skip = !chipWasmExists(CHIP);
const CLOCK_HZ = 740_000;
const CLOCK_NS = Math.round(1e9 / CLOCK_HZ);
/**
* Feed a program into the 4004 via the multiplexed nibble bus, mirroring
* what a real 4001 ROM would do. The 4004 walks an 8-phase frame
* (A1, A2, A3, M1, M2, X1, X2, X3) per machine cycle. The test must
* pre-drive D0..D3 with the appropriate ROM nibble before the chip's
* M1 and M2 phases fire.
*
* Strategy:
* - Watch SYNC. When SYNC pulses high, that's the start of a new
* cycle (phase A1). We track phasesSinceSync = 0 → 1 → ... → 7.
* - phasesSinceSync == 3 means "next tick will be M1": pre-drive
* the high nibble of program[pc].
* - phasesSinceSync == 4 means "next tick will be M2": pre-drive
* the low nibble.
* - At end of every cycle (X3 done), advance our shadow pc by 1 IF
* the chip didn't jump. We detect jumps by reading the address
* bus during the next cycle's A1/A2/A3 phases and re-syncing.
*
* We track the chip's PC by reading what it drives on D0..D3 during
* A1/A2/A3 phases. That keeps pc in lockstep regardless of jumps.
*
* The class exposes `step()` (advance one phase) and `runCycles(n)`
* (advance n full instruction cycles).
*/
class Bus4004 {
constructor(board, program) {
this.board = board;
this.program = program;
this.phase = -1; // 0=A1, 1=A2, 2=A3, 3=M1, 4=M2, 5=X1, 6=X2, 7=X3
this.pcLow = 0;
this.pcMid = 0;
this.pcHigh = 0;
this.observedPc = 0;
this._setupSyncWatch();
}
_setupSyncWatch() {
this.board.watchNet('SYNC', (high) => {
if (high) this.phase = 0;
});
}
_drive(nibble) {
for (let i = 0; i < 4; i++) {
this.board.setNet(`D${i}`, ((nibble >> i) & 1) === 1);
}
}
step() {
// Pre-drive D pins for the upcoming phase. The chip processes
// phases 0..7 = A1, A2, A3, M1, M2, X1, X2, X3. Our `phase` field
// is the COUNT of phases the chip has already executed in this
// cycle. So phase=3 means "the chip has done A1+A2+A3, next tick
// will be M1" — that's when we drive the opcode high nibble.
// phase=4 means "next tick is M2" — drive low nibble.
if (this.phase === 3) {
const byte = this.program[this.observedPc & 0xFFF] || 0;
this._drive((byte >> 4) & 0xF);
} else if (this.phase === 4) {
const byte = this.program[this.observedPc & 0xFFF] || 0;
this._drive(byte & 0xF);
}
this.board.advanceNanos(CLOCK_NS);
// Sample address nibbles after the chip's drives complete.
if (this.phase === 0) this.pcLow = this.board.readBus('D', 4);
else if (this.phase === 1) this.pcMid = this.board.readBus('D', 4);
else if (this.phase === 2) this.pcHigh = this.board.readBus('D', 4);
// After A3 we have the full PC the chip is about to fetch from.
if (this.phase === 2) {
this.observedPc = this.pcLow | (this.pcMid << 4) | (this.pcHigh << 8);
}
if (this.phase >= 0) this.phase = (this.phase + 1) & 7;
}
/** Run one full instruction cycle (8 phases). */
runCycle() { for (let i = 0; i < 8; i++) this.step(); }
/** Run n full cycles. Useful for multi-cycle programs. */
runCycles(n) { for (let i = 0; i < n; i++) this.runCycle(); }
/** The PC the chip drove on the bus during the most recent A1..A3. */
pc() { return this.observedPc; }
}
function fullPinMap() {
const m = {
SYNC: 'SYNC', RESET: 'RESET', TEST: 'TEST',
CMROM: 'CMROM',
CMRAM0: 'CMRAM0', CMRAM1: 'CMRAM1', CMRAM2: 'CMRAM2', CMRAM3: 'CMRAM3',
CLK1: 'CLK1', CLK2: 'CLK2',
VDD: 'VDD', VSS: 'VSS',
};
for (let i = 0; i < 4; i++) m[`D${i}`] = `D${i}`;
return m;
}
async function bootChip(board) {
await board.addChip(CHIP, fullPinMap());
board.setNet('TEST', false);
// Pulse RESET high then low. Do NOT advance time after RESET goes
// low — caller does that so the first observed cycle starts at
// phase A1 with PC = 0. (Same lesson as bootCpu in the 8080 tests.)
board.setNet('RESET', true);
board.advanceNanos(CLOCK_NS * 10);
board.setNet('RESET', false);
}
describe('Intel 4004 chip', () => {
describe('pin contract', () => {
it.skipIf(skip)('registers all 16 logical pins', async () => {
const board = new BoardHarness();
await expect(board.addChip(CHIP, fullPinMap())).resolves.toBeDefined();
board.dispose();
});
});
describe('instruction-cycle frame', () => {
it.skipIf(skip)('asserts SYNC once every 8 clock cycles', async () => {
const board = new BoardHarness();
await bootChip(board);
const syncTimes = [];
board.watchNet('SYNC', (high) => {
if (high) syncTimes.push(board.nowNanos);
});
// Run 24 clock cycles → expect ≈ 3 SYNC pulses.
for (let i = 0; i < 24; i++) board.advanceNanos(CLOCK_NS);
expect(syncTimes.length, 'SYNC pulses in 24 cycles').toBeGreaterThanOrEqual(2);
// Spacing should be ~8 cycles between pulses.
if (syncTimes.length >= 2) {
const gap = Number(syncTimes[1] - syncTimes[0]);
expect(gap).toBeGreaterThan(CLOCK_NS * 6);
expect(gap).toBeLessThan(CLOCK_NS * 10);
}
board.dispose();
});
it.skipIf(skip)('drives D0..D3 with 12-bit address across A1, A2, A3 phases', async () => {
const board = new BoardHarness();
await bootChip(board);
// After RESET the PC is 0. The first three nibbles after SYNC
// should all be 0 (low addr nibble first, by 4004 convention).
const samples = [];
let sinceSync = -1;
// Latch on the FIRST SYNC only — a second pulse in the window
// would otherwise re-arm the sampler and over-collect.
board.watchNet('SYNC', (high) => { if (high && sinceSync === -1) sinceSync = 0; });
for (let i = 0; i < 10; i++) {
board.advanceNanos(CLOCK_NS);
if (sinceSync >= 0 && sinceSync < 3) {
samples.push(board.readBus('D', 4));
sinceSync++;
}
}
expect(samples.length).toBe(3);
// For PC = 0 all three nibbles are 0.
expect(samples).toEqual([0, 0, 0]);
board.dispose();
});
it.skipIf(skip)('CM-ROM strobes during M1 phase of an instruction cycle', async () => {
const board = new BoardHarness();
await bootChip(board);
let cmRomSeen = false;
board.watchNet('CMROM', (high) => { if (high) cmRomSeen = true; });
for (let i = 0; i < 16; i++) board.advanceNanos(CLOCK_NS);
expect(cmRomSeen, 'CM-ROM must pulse high during M1').toBe(true);
board.dispose();
});
});
describe('instruction set', () => {
it.skipIf(skip)('NOP advances PC by 1', async () => {
// [NOP, NOP, NOP, NOP] — every cycle PC increments by 1.
const prog = [0x00, 0x00, 0x00, 0x00];
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
const pcs = [];
for (let cyc = 0; cyc < 4; cyc++) {
bus.runCycle();
pcs.push(bus.pc());
}
// Cycle 0 fetched at PC=0; cycle 1 at PC=1; etc.
expect(pcs).toEqual([0, 1, 2, 3]);
board.dispose();
});
it.skipIf(skip)('JUN jumps to absolute 12-bit address', async () => {
// Prog: JUN 0x123 (bytes 0x41 0x23) at addr 0; rest zeros.
const prog = new Uint8Array(0x200);
prog[0] = 0x41; prog[1] = 0x23; // JUN target=0x123
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
// Cycle 0: fetch 0x41 (JUN opcode); 2-byte op.
// Cycle 1: fetch 0x23 (operand); execute → PC = 0x123.
// Cycle 2: fetch at PC=0x123 (NOP from the all-zero region).
bus.runCycles(3);
expect(bus.pc()).toBe(0x123);
board.dispose();
});
it.skipIf(skip)('JMS pushes return address and BBL pops it', async () => {
// Prog: JMS 0x010, NOP, ... ; at 0x010: BBL 5
const prog = new Uint8Array(0x100);
prog[0] = 0x50; prog[1] = 0x10; // JMS 0x010
prog[2] = 0x00; // NOP (return target after BBL)
prog[0x10] = 0xC5; // BBL 5
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JMS opcode + operand fetch → PC = 0x010.
// Cycle 2: chip fetches BBL at 0x010 → end of cycle PC = 0x002.
// Cycle 3: chip fetches NOP at 0x002 → end of cycle PC = 0x003.
// Cycle 4: chip starts fetch at 0x003. We need cycle 4's A1/A2/A3
// to OBSERVE the post-NOP PC (since bus.pc() reports the address
// the chip is currently driving on the bus).
bus.runCycles(5);
expect(bus.pc()).toBe(0x003);
board.dispose();
});
it.skipIf(skip)('JCN with C4 jumps when TEST pin is logic-0', async () => {
// Prog at 0:
// JCN 0x1, 0x10 ; jump-if-test-low to 0x010 (C4=1)
// ...
// at 0x010: zeros (target)
const prog = new Uint8Array(0x80);
prog[0] = 0x11; prog[1] = 0x10; // JCN C4=1, target page-low=0x10
const board = new BoardHarness();
await bootChip(board);
// TEST pin LOW (false) means "logic 0" per [M4] p. 14 — JUMP IF TEST=logic-0
board.setNet('TEST', false);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JCN opcode + operand → PC = 0x010 if condition met.
// Cycle 2: chip drives PC = 0x010 in A1..A3 (observed).
bus.runCycles(3);
expect(bus.pc()).toBe(0x010);
board.dispose();
});
it.skipIf(skip)('JCN does not jump when condition is false', async () => {
const prog = new Uint8Array(0x80);
prog[0] = 0x11; prog[1] = 0x10; // JCN C4=1, target=0x10
prog[2] = 0x00; // fallthrough = NOP
const board = new BoardHarness();
await bootChip(board);
// TEST pin HIGH means "logic 1" → JCN with C4=1 not taken.
board.setNet('TEST', true);
const bus = new Bus4004(board, prog);
// Cycle 0+1: JCN; not taken → PC = 0x002.
// Cycle 2: chip drives PC = 0x002 in A1..A3 (observed).
bus.runCycles(3);
expect(bus.pc()).toBe(0x002);
board.dispose();
});
it.skipIf(skip)('LDM loads the immediate nibble into the accumulator', async () => {
// Program: LDM 5 ; SRC P0 ; WMP ; NOP
// After LDM the ACC = 5. SRC P0 drives the (R0:R1) pair on the
// bus during X2/X3 — both 0 since the regs are still reset.
// WMP drives ACC on the bus during X2. We capture D0..D3 on
// exactly the WMP cycle's X2 frame (phase 6 since SYNC) and
// assert it equals 5.
const prog = new Uint8Array(0x40);
prog[0] = 0xD5;
prog[1] = 0x21;
prog[2] = 0xE1;
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
let cycleIdx = -1;
let phaseSinceSync = -1;
let wmpX2Drive = null;
board.watchNet('SYNC', (high) => {
if (high) { cycleIdx++; phaseSinceSync = 0; }
});
// Run cycles 0, 1, 2 phase by phase, capturing D after each step.
for (let i = 0; i < 24; i++) {
bus.step();
// Cycle 2 is WMP; phase 6 since SYNC = X2 frame.
if (cycleIdx === 2 && phaseSinceSync === 6) {
wmpX2Drive = board.readBus('D', 4);
}
if (phaseSinceSync >= 0) phaseSinceSync++;
}
expect(wmpX2Drive, 'WMP X2 must drive ACC = 5 on D bus').toBe(5);
board.dispose();
});
it.skipIf(skip)('FIM loads an 8-bit immediate into a register pair', async () => {
// Program: FIM P0, 0x57 ; SRC P0 ; NOP...
// FIM is 2-byte; cycles 0+1 fetch+execute → P0 = (R0=5, R1=7).
// Cycle 2 is SRC P0; X2 drives high nibble (5), X3 drives low (7).
const prog = new Uint8Array(0x40);
prog[0] = 0x20; // FIM P0 (even = FIM)
prog[1] = 0x57; // operand
prog[2] = 0x21; // SRC P0
const board = new BoardHarness();
await bootChip(board);
const bus = new Bus4004(board, prog);
let cycleIdx = -1;
let phaseSinceSync = -1;
let srcX2Drive = null, srcX3Drive = null;
board.watchNet('SYNC', (high) => {
if (high) { cycleIdx++; phaseSinceSync = 0; }
});
for (let i = 0; i < 24; i++) {
bus.step();
if (cycleIdx === 2 && phaseSinceSync === 6) srcX2Drive = board.readBus('D', 4);
if (cycleIdx === 2 && phaseSinceSync === 7) srcX3Drive = board.readBus('D', 4);
if (phaseSinceSync >= 0) phaseSinceSync++;
}
expect(srcX2Drive, 'SRC X2 must drive R0 (high nibble) = 5').toBe(5);
expect(srcX3Drive, 'SRC X3 must drive R1 (low nibble) = 7').toBe(7);
board.dispose();
});
});
describe('integration', () => {
it.skipIf(skip || !chipWasmExists('4002-ram'))(
'runs a Busicom-style increment-and-blink program', async () => {
// The Busicom 141-PF firmware is not in this repo; this test
// exercises the same kind of inner loop the firmware ran:
// SRC + WMP + IAC + JUN, with the 4002's output port playing
// the role of the printer/display latch.
//
// Program (under 16 bytes so it fits the 4001 ROM page):
// 0x00: F0 CLB ; ACC = 0
// 0x01: 21 SRC P0 ; ← loop label
// 0x02: E1 WMP ; latch ACC into the 4002 output
// 0x03: F2 IAC ; ACC++
// 0x04: 40 01 JUN 0x001 ; jump back to SRC
//
// Each iteration of the loop is 4 instructions = 5 machine cycles
// (JUN is 2-byte). Run until ACC has been incremented several
// times and assert the 4002 output port reflects the latest
// value.
const PROG = new Uint8Array(0x40);
PROG[0x00] = 0xF0; // CLB
PROG[0x01] = 0x21; // SRC P0
PROG[0x02] = 0xE1; // WMP
PROG[0x03] = 0xF2; // IAC
PROG[0x04] = 0x40; // JUN 0x001 (high nibble = 0)
PROG[0x05] = 0x01; // operand = low byte of target
const board = new BoardHarness();
// 4002 first so its on_phase fires before the 4004's per
// advanceNanos — the one-frame-behind protocol.
await board.addChip('4002-ram', {
SYNC: 'SYNC', CL: 'CLK1', RESET: 'RESET', CM: 'CMRAM0',
VDD: 'VDD', VSS: 'VSS',
D0: 'D0', D1: 'D1', D2: 'D2', D3: 'D3',
O0: 'O0', O1: 'O1', O2: 'O2', O3: 'O3',
});
await bootChip(board);
let phaseSinceSync = -1;
let observedPc = 0;
let pcLow = 0, pcMid = 0;
board.watchNet('SYNC', (high) => { if (high) phaseSinceSync = 0; });
function driveDNibble(n) {
for (let i = 0; i < 4; i++) {
board.setNet(`D${i}`, ((n >> i) & 1) === 1);
}
}
// Capture the 4002 output port after each WMP cycle so we can
// verify the BLINK SEQUENCE — not just the final value.
const outputs = [];
let prevOut = -1; // -1 so the very first sample (= 0) registers
// Run lots of cycles — enough for ACC to roll past 9 a few times.
const PHASES = 8 * 80; // 80 instruction cycles
for (let p = 0; p < PHASES; p++) {
if (phaseSinceSync === 3) {
driveDNibble((PROG[observedPc & 0x3F] >> 4) & 0xF);
} else if (phaseSinceSync === 4) {
driveDNibble(PROG[observedPc & 0x3F] & 0xF);
}
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) {
const pcHigh = board.readBus('D', 4);
observedPc = pcLow | (pcMid << 4) | (pcHigh << 8);
}
if (phaseSinceSync >= 0) phaseSinceSync++;
// Sample output at end of each cycle (phase 7).
if (phaseSinceSync === 8) {
let out = 0;
for (let i = 0; i < 4; i++) if (board.getNet(`O${i}`)) out |= (1 << i);
if (out !== prevOut) {
outputs.push(out);
prevOut = out;
}
}
}
// Each loop iteration produces a fresh WMP. The output should
// walk 0, 1, 2, 3, ... 0xF, 0, 1, ... — i.e. an incrementing
// sequence (modulo 16). Verify the first several distinct
// outputs follow that pattern.
expect(outputs.length, 'must blink at least 6 distinct values').toBeGreaterThanOrEqual(6);
for (let i = 0; i < Math.min(6, outputs.length); i++) {
expect(outputs[i], `tick ${i} of the increment-and-blink loop`).toBe(i);
}
board.dispose();
});
});
});