323 lines
12 KiB
JavaScript
323 lines
12 KiB
JavaScript
/**
|
||
* Intel 4040 emulator chip — TDD spec.
|
||
*
|
||
* The 4040 is a strict superset of the 4004. It adds:
|
||
* - Interrupts (INT pin, fixed vector — verify exact addr from datasheet)
|
||
* - Single-step / STOP / STOP-ACK
|
||
* - Expanded register file (16 → 24 4-bit registers)
|
||
* - Deeper PC stack (3 → 7)
|
||
* - 14 new opcodes (interrupt enable/disable, return-from-interrupt,
|
||
* stop, additional register-pair ops)
|
||
* - 24-pin DIP, 2 CM-ROM lines (vs 1 on 4004)
|
||
*
|
||
* Tests focus on the deltas from 4004. The shared 4004-subset behavior
|
||
* should be exercised by a parametrised re-run of test_4004's suite once
|
||
* both chips are implemented (deferred).
|
||
*/
|
||
import { describe, it, expect } from 'vitest';
|
||
import { BoardHarness } from '../src/BoardHarness.js';
|
||
import { chipWasmExists } from '../src/helpers.js';
|
||
|
||
const CHIP = '4040';
|
||
const skip = !chipWasmExists(CHIP);
|
||
|
||
const CLOCK_HZ = 740_000;
|
||
const CLOCK_NS = Math.round(1e9 / CLOCK_HZ);
|
||
|
||
/** Same shape as Bus4004 in test_4004/4004.test.js — 4040 inherits the
|
||
* 4004's 8-phase nibble-multiplexed bus protocol. See those comments. */
|
||
class Bus4040 {
|
||
constructor(board, program) {
|
||
this.board = board;
|
||
this.program = program;
|
||
this.phase = -1;
|
||
this.pcLow = 0; this.pcMid = 0; this.pcHigh = 0;
|
||
this.observedPc = 0;
|
||
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() {
|
||
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);
|
||
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);
|
||
if (this.phase === 2) {
|
||
this.observedPc = this.pcLow | (this.pcMid << 4) | (this.pcHigh << 8);
|
||
}
|
||
if (this.phase >= 0) this.phase = (this.phase + 1) & 7;
|
||
}
|
||
runCycle() { for (let i = 0; i < 8; i++) this.step(); }
|
||
runCycles(n) { for (let i = 0; i < n; i++) this.runCycle(); }
|
||
pc() { return this.observedPc; }
|
||
}
|
||
|
||
/**
|
||
* Pin names match the Intel MCS-40 User's Manual (Nov 1974) pin-description
|
||
* table on pages 1-5/1-6. Φ1/Φ2 are renamed CLK1/CLK2 (no Greek letters in
|
||
* C identifiers); the three −15 V supply pins (Vdd, Vdd1, Vdd2) are kept
|
||
* separate even though velxio is digital and treats them all as power.
|
||
*/
|
||
function fullPinMap() {
|
||
const m = {
|
||
SYNC: 'SYNC', RESET: 'RESET', TEST: 'TEST',
|
||
CMROM0: 'CMROM0', CMROM1: 'CMROM1',
|
||
CMRAM0: 'CMRAM0', CMRAM1: 'CMRAM1', CMRAM2: 'CMRAM2', CMRAM3: 'CMRAM3',
|
||
CLK1: 'CLK1', CLK2: 'CLK2',
|
||
STP: 'STP', STPA: 'STPA', // Stop input + Stop-acknowledge output
|
||
INT: 'INT', INTA: 'INTA', // Interrupt input + ack output
|
||
CY: 'CY', // Carry output buffer (open drain)
|
||
VDD: 'VDD', VDD1: 'VDD1', VDD2: 'VDD2', VSS: 'VSS',
|
||
};
|
||
for (let i = 0; i < 4; i++) m[`D${i}`] = `D${i}`;
|
||
return m;
|
||
}
|
||
|
||
describe('Intel 4040 chip', () => {
|
||
|
||
describe('pin contract', () => {
|
||
it.skipIf(skip)('registers the 24-pin contract (4004 superset)', async () => {
|
||
const board = new BoardHarness();
|
||
await expect(board.addChip(CHIP, fullPinMap())).resolves.toBeDefined();
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('STP / STPA', () => {
|
||
it.skipIf(skip)('asserting STP causes STPA to assert within one cycle', async () => {
|
||
// Per MCS-40 manual p. 1-10: when STP is latched at M2, the STOP FF
|
||
// sets at X3; the CPU then executes NOPs in a loop (clock and SYNC
|
||
// KEEP RUNNING) and STPA asserts. So the assertion here is that
|
||
// STPA goes high — we deliberately do NOT assert that SYNC stops.
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
|
||
// Reset and run a few cycles freely.
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12); // ≥96 clk per p. 1-5 RESET min
|
||
board.setNet('RESET', false);
|
||
for (let i = 0; i < 16; i++) board.advanceNanos(CLOCK_NS);
|
||
|
||
// Now assert STP (active high per pin description, p. 1-5) and watch.
|
||
let acked = false;
|
||
board.watchNet('STPA', (high) => { if (high) acked = true; });
|
||
board.setNet('STP', true);
|
||
|
||
// Allow up to 2 instruction cycles for the chip to latch STP at M2
|
||
// and assert STPA at X3.
|
||
for (let i = 0; i < 24; i++) board.advanceNanos(CLOCK_NS);
|
||
|
||
expect(acked, 'STPA must rise within ~two instruction cycles').toBe(true);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('interrupts', () => {
|
||
it.skipIf(skip)('INT high after EIN vectors PC to 0x003 and asserts INTA', async () => {
|
||
// Program: EIN ; NOP ; NOP ; BBS (at 0x003)
|
||
const prog = new Uint8Array(0x100);
|
||
prog[0] = 0x0C; // EIN
|
||
prog[1] = 0x00; // NOP
|
||
prog[2] = 0x00; // NOP
|
||
prog[3] = 0x02; // BBS (executes when interrupt fires)
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
|
||
// Boot
|
||
board.setNet('STP', false);
|
||
board.setNet('INT', false);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
const bus = new Bus4040(board, prog);
|
||
|
||
let intaSeen = false;
|
||
board.watchNet('INTA', (high) => { if (high) intaSeen = true; });
|
||
|
||
// Cycle 0 executes EIN → IFF=1.
|
||
// Cycle 1 fetches NOP at 0x001. Before its M2, the test asserts
|
||
// INT; M2 latches it; X3 vectors to 0x003.
|
||
bus.runCycle(); // EIN
|
||
board.setNet('INT', true);
|
||
bus.runCycle(); // NOP at 0x001 — INT latched at M2, vector at X3.
|
||
// Cycle 2 fetches at 0x003 (the vector address).
|
||
bus.runCycle();
|
||
expect(bus.pc(), 'PC after interrupt vector').toBe(0x003);
|
||
expect(intaSeen, 'INTA must have asserted').toBe(true);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('BBS pops PC and clears INTA', async () => {
|
||
// Per MCS-40 manual p. 1-12: INT pushes the "pre-interrupt PC (NOT
|
||
// incremented)" — i.e. the address of the instruction the CPU was
|
||
// about to execute (0x001, the NOP we hadn't run yet). BBS pops
|
||
// that PC, so control returns to re-execute that NOP. After it
|
||
// runs, PC advances to 0x002.
|
||
const prog = new Uint8Array(0x100);
|
||
prog[0] = 0x0C; // EIN
|
||
prog[1] = 0x00; // NOP — INT latched during this cycle
|
||
prog[3] = 0x02; // BBS at vector
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
board.setNet('STP', false);
|
||
board.setNet('INT', false);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
const bus = new Bus4040(board, prog);
|
||
let intaWasHigh = false;
|
||
let intaFell = false;
|
||
board.watchNet('INTA', (high) => {
|
||
if (high) intaWasHigh = true;
|
||
else if (intaWasHigh) intaFell = true;
|
||
});
|
||
|
||
bus.runCycle(); // EIN @ 0x000 → IFF=1
|
||
board.setNet('INT', true);
|
||
bus.runCycle(); // NOP @ 0x001 → INT latched at M2; vector at X3
|
||
board.setNet('INT', false);
|
||
bus.runCycle(); // BBS @ 0x003 → pop PC → 0x001; INTA cleared
|
||
bus.runCycle(); // re-execute NOP @ 0x001 → PC=0x002
|
||
bus.runCycle(); // observe at PC=0x002
|
||
|
||
expect(bus.pc()).toBe(0x002);
|
||
expect(intaFell, 'INTA must de-assert during BBS').toBe(true);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('extended register file', () => {
|
||
it.skipIf(skip)('SB1 + FIM writes to bank-1 R0..R7 (R16..R23 region)', async () => {
|
||
// Strategy: distinguish bank-0 from bank-1 by setting up registers
|
||
// such that only bank-1 access produces a non-branch on ISZ.
|
||
// 1. FIM P0, 0xFF ; bank-0 R0=F, R1=F (the chip starts at SB0)
|
||
// 2. SB1 ; switch to bank 1
|
||
// 3. FIM P0, 0x10 ; bank-1 R0=1, R1=0
|
||
// 4. SB0 ; back to bank 0
|
||
// 5. ISZ R0, target=0x20; bank-0 R0 was F → INC wraps to 0 →
|
||
// NO branch (PC falls through to next op)
|
||
// If SB1 didn't work, step 3 would have overwritten bank-0 R0 with 1,
|
||
// and step 5's ISZ would INC 1→2 → branch taken → PC=0x020.
|
||
const prog = new Uint8Array(0x80);
|
||
prog[0] = 0x20; prog[1] = 0xFF; // FIM P0, 0xFF
|
||
prog[2] = 0x0B; // SB1
|
||
prog[3] = 0x20; prog[4] = 0x10; // FIM P0, 0x10
|
||
prog[5] = 0x0A; // SB0
|
||
prog[6] = 0x70; prog[7] = 0x20; // ISZ R0, target 0x020
|
||
prog[8] = 0x00; // NOP (fall-through path)
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
board.setNet('STP', false);
|
||
board.setNet('INT', false);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
const bus = new Bus4040(board, prog);
|
||
// 6 instructions + observation. ISZ is 2-byte (2 cycles). FIMs
|
||
// are 2-byte (2 cycles each). SB0/SB1 are 1-byte. Total cycles
|
||
// through ISZ end: FIM(2) + SB1(1) + FIM(2) + SB0(1) + ISZ(2) = 8.
|
||
// Cycle 9 will fetch the next instruction — at 0x008 if not taken.
|
||
bus.runCycles(9);
|
||
// Bank-1 worked → R0 stayed F → ISZ wraps to 0 → no branch → PC=8.
|
||
expect(bus.pc()).toBe(0x008);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('4040 + 4002 RAM integration', () => {
|
||
const RAM = '4002-ram';
|
||
const skipIntegration = skip || !chipWasmExists(RAM);
|
||
|
||
it.skipIf(skipIntegration)(
|
||
'SRC + WMP drives the 4002 output port from ACC',
|
||
async () => {
|
||
// PC=0x00: 0xD3 LDM 3 → ACC=3
|
||
// PC=0x01: 0x21 SRC P0 → drive R0:R1=0:0 → chip-pair=0
|
||
// PC=0x02: 0xE1 WMP → 4002.O0..O3 = 3
|
||
const PROG = new Uint8Array(0x40);
|
||
PROG[0] = 0xD3;
|
||
PROG[1] = 0x21;
|
||
PROG[2] = 0xE1;
|
||
|
||
const board = new BoardHarness();
|
||
// Register the 4002 BEFORE the 4040 (same ordering trick as
|
||
// 4004/4002 integration). 4040.CMRAM0 → 4002.CM.
|
||
await board.addChip(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 board.addChip(CHIP, fullPinMap());
|
||
|
||
board.setNet('STP', false);
|
||
board.setNet('INT', false);
|
||
board.setNet('TEST', false);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
const bus = new Bus4040(board, PROG);
|
||
for (let cyc = 0; cyc < 8; cyc++) bus.runCycle();
|
||
|
||
let out = 0;
|
||
for (let i = 0; i < 4; i++) if (board.getNet(`O${i}`)) out |= (1 << i);
|
||
expect(out, '4002 output port after WMP must equal ACC (= 3)').toBe(3);
|
||
board.dispose();
|
||
}
|
||
);
|
||
|
||
it.skipIf(skipIntegration)(
|
||
'WRM stores into RAM and RDM reads it back through the bus',
|
||
async () => {
|
||
// 0xD5 LDM 5 ; 0x21 SRC P0 ; 0xE0 WRM ; 0xF0 CLB
|
||
// 0xE9 RDM ; 0xE1 WMP ; 0x00 NOP
|
||
const PROG = new Uint8Array(0x40);
|
||
PROG[0] = 0xD5;
|
||
PROG[1] = 0x21;
|
||
PROG[2] = 0xE0;
|
||
PROG[3] = 0xF0;
|
||
PROG[4] = 0xE9;
|
||
PROG[5] = 0xE1;
|
||
|
||
const board = new BoardHarness();
|
||
await board.addChip(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 board.addChip(CHIP, fullPinMap());
|
||
|
||
board.setNet('STP', false);
|
||
board.setNet('INT', false);
|
||
board.setNet('TEST', false);
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
board.setNet('RESET', false);
|
||
|
||
const bus = new Bus4040(board, PROG);
|
||
for (let cyc = 0; cyc < 12; cyc++) bus.runCycle();
|
||
|
||
let out = 0;
|
||
for (let i = 0; i < 4; i++) if (board.getNet(`O${i}`)) out |= (1 << i);
|
||
expect(out, 'WMP after RDM must surface the mem-stored 5').toBe(5);
|
||
board.dispose();
|
||
}
|
||
);
|
||
});
|
||
});
|