169 lines
6.7 KiB
JavaScript
169 lines
6.7 KiB
JavaScript
/**
|
||
* Busicom 141-PF firmware — end-to-end Intel 4004 integration test.
|
||
*
|
||
* The Busicom 141-PF was the printing electronic calculator that
|
||
* Intel built the 4004 *for* in 1971. The full 1 KB firmware (4× 256-
|
||
* byte 4001 ROMs) was released to the public domain by Intel in 2009
|
||
* via Tim McNerney's restoration project on 4004.com. This test runs
|
||
* the original silicon's binary on our clean-room 4004 + 4002.
|
||
*
|
||
* What we verify
|
||
* --------------
|
||
* 1) The 4004 chip executes >2000 instruction cycles of the real
|
||
* firmware without crashing or stalling on a single PC.
|
||
* 2) PC visits a wide spread of unique addresses across the 1 KB
|
||
* image — proving the chip's full ISA + bus protocol cope with
|
||
* code Intel actually shipped to customers, not just hand-crafted
|
||
* micro-tests.
|
||
* 3) The firmware exercises SRC + WMP + WRR over the shared nibble
|
||
* bus — visible as CMRAM/CMROM strobes and writes to the 4002.
|
||
*
|
||
* What we do NOT model
|
||
* --------------------
|
||
* - 4003 shift registers for keyboard / printer scanning. The
|
||
* firmware's scanning loops will read all-zero (no keys), so the
|
||
* chip stays in the polling state — that's the correct behaviour
|
||
* for an unattended Busicom; the goal here is "code executes
|
||
* without breaking", not "produces a printed receipt".
|
||
* - 4× 4001 ROM chip-id variants. Instead of compiling 4 separate
|
||
* chip variants we use a JS-side nibble-bus driver that serves
|
||
* bytes from the 1 KB image regardless of the chip-id — the 4004
|
||
* side of the bus protocol is identical, only the source of the
|
||
* nibbles differs.
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import { readFileSync, existsSync } from 'fs';
|
||
import { dirname, join } from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { BoardHarness } from '../src/BoardHarness.js';
|
||
import { chipWasmExists } from '../src/helpers.js';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const ROM_PATH = join(__dirname, '..', 'roms', '4004', 'busicom_141pf.bin');
|
||
|
||
const skip = !chipWasmExists('4004') || !chipWasmExists('4002-ram')
|
||
|| !existsSync(ROM_PATH);
|
||
|
||
const CLOCK_NS = 1351; // 4004 ran at 740 kHz → 1351 ns per phase
|
||
|
||
function cpuPinMap() {
|
||
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;
|
||
}
|
||
|
||
function ramPinMap() {
|
||
const m = {
|
||
SYNC: 'SYNC', CL: 'CLK1', RESET: 'RESET', CM: 'CMRAM0',
|
||
VDD: 'VDD', VSS: 'VSS',
|
||
};
|
||
for (let i = 0; i < 4; i++) m[`D${i}`] = `D${i}`;
|
||
for (let i = 0; i < 4; i++) m[`O${i}`] = `O${i}`;
|
||
return m;
|
||
}
|
||
|
||
describe.skipIf(skip)('Busicom 141-PF firmware (4004) integration', () => {
|
||
|
||
it('runs >2000 cycles of the original Intel firmware without crashing', async () => {
|
||
const rom = readFileSync(ROM_PATH);
|
||
expect(rom.length, 'Busicom firmware must be at least 1 KB').toBeGreaterThanOrEqual(1024);
|
||
|
||
// Use the first 1 KB (4× 256-byte ROMs concatenated in order).
|
||
// The firmware references PCs in [0x000..0x3FF].
|
||
const PROG = new Uint8Array(0x400);
|
||
PROG.set(rom.subarray(0, 0x400), 0);
|
||
|
||
const board = new BoardHarness();
|
||
// 4002 first so its on_phase fires before the 4004 (one-frame-behind
|
||
// protocol, see 4002-ram.c documentation).
|
||
await board.addChip('4002-ram', ramPinMap());
|
||
await board.addChip('4004', cpuPinMap());
|
||
|
||
// The 4004's TEST pin on a real Busicom is wired to the printer
|
||
// drum encoder — it pulses every few ms as the drum rotates. The
|
||
// very first instruction is JCN (jump-if-TEST-low) waiting for
|
||
// that pulse, so without toggling TEST the firmware spins forever
|
||
// on the first JCN. Toggle TEST every ~5000 phases of simulated
|
||
// time below to mimic the drum sync.
|
||
board.setNet('TEST', true);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
// JS-side nibble-bus driver: feeds opcode high/low nibbles during
|
||
// M1/M2 from the firmware image. Captures the 4004's PC via the
|
||
// address drives at A1/A2/A3.
|
||
let phaseSinceSync = -1;
|
||
let observedPc = 0;
|
||
let pcLow = 0, pcMid = 0;
|
||
const pcHistogram = new Map();
|
||
const cmramStrobes = [0, 0, 0, 0];
|
||
let cmromStrobes = 0;
|
||
|
||
board.watchNet('SYNC', (high) => { if (high) phaseSinceSync = 0; });
|
||
for (let i = 0; i < 4; i++) {
|
||
const idx = i;
|
||
board.watchNet(`CMRAM${i}`, (high) => { if (high) cmramStrobes[idx]++; });
|
||
}
|
||
board.watchNet('CMROM', (high) => { if (high) cmromStrobes++; });
|
||
|
||
function driveDNibble(n) {
|
||
for (let i = 0; i < 4; i++) {
|
||
board.setNet(`D${i}`, ((n >> i) & 1) === 1);
|
||
}
|
||
}
|
||
|
||
// 8 phases × 2500 cycles = 20_000 phases ≈ 27 ms simulated time.
|
||
const PHASES = 8 * 2500;
|
||
let m1FetchCount = 0;
|
||
let testHigh = true;
|
||
for (let p = 0; p < PHASES; p++) {
|
||
// Pulse TEST every ~400 phases to mimic the printer-drum encoder
|
||
// sync the firmware polls in its main loop.
|
||
if ((p % 400) === 0) {
|
||
testHigh = !testHigh;
|
||
board.setNet('TEST', testHigh);
|
||
}
|
||
if (phaseSinceSync === 3) {
|
||
driveDNibble((PROG[observedPc & 0x3FF] >> 4) & 0xF);
|
||
} else if (phaseSinceSync === 4) {
|
||
driveDNibble(PROG[observedPc & 0x3FF] & 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);
|
||
pcHistogram.set(observedPc, (pcHistogram.get(observedPc) ?? 0) + 1);
|
||
m1FetchCount++;
|
||
}
|
||
if (phaseSinceSync >= 0) phaseSinceSync++;
|
||
}
|
||
|
||
// Sanity: chip kept fetching new instructions across the run.
|
||
expect(m1FetchCount, 'opcode-fetch cycles in the run').toBeGreaterThan(2000);
|
||
|
||
// Sanity: chip explored a meaningful slice of the firmware, not
|
||
// just a 1-byte halt loop. Real Busicom firmware visits dozens
|
||
// of distinct addresses even in its idle keyboard-scan state.
|
||
expect(pcHistogram.size, 'unique PC addresses visited').toBeGreaterThan(15);
|
||
|
||
// Sanity: bus protocol fired CMROM (instruction fetch strobe)
|
||
// many times, and at least one CMRAM strobe (firmware does talk
|
||
// to RAM during init).
|
||
expect(cmromStrobes, 'CMROM strobes during the run').toBeGreaterThan(100);
|
||
const totalCmram = cmramStrobes.reduce((a, b) => a + b, 0);
|
||
expect(totalCmram, 'CMRAM strobes during the run').toBeGreaterThan(0);
|
||
|
||
board.dispose();
|
||
}, { timeout: 30_000 });
|
||
|
||
});
|