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