496 lines
18 KiB
JavaScript
496 lines
18 KiB
JavaScript
/**
|
||
* Intel 8080 emulator chip — comprehensive TDD spec.
|
||
*
|
||
* The chip is implemented in `8080.c` (TBD). Until that file exists and
|
||
* compiles to fixtures/8080.wasm, all tests skip cleanly.
|
||
*
|
||
* Test strategy:
|
||
* - Most tests use BoardHarness.installFakeRom() to feed a hand-crafted
|
||
* opcode stream into the CPU's bus protocol. The fake ROM watches
|
||
* RD̅ and drives D0..D7 from a JS array — no per-test recompile.
|
||
* - Internal CPU state (registers) is observed indirectly: programs
|
||
* end with STA storing a register to a known RAM address; tests
|
||
* inspect that address via the fake RAM.
|
||
* - Bus traces (write cycles, address sequences) are captured via
|
||
* captureWrites() for protocol-level assertions.
|
||
*
|
||
* Pin contract assumed (see test_8080/README.md):
|
||
* A0..A15 — output (16-bit address)
|
||
* D0..D7 — bidirectional (data; tristated when chip not driving)
|
||
* SYNC, DBIN, WR, INTE, WAIT, HLDA — output
|
||
* READY, HOLD, INT, RESET — input
|
||
* PHI1, PHI2 — clock inputs (we drive both phases)
|
||
* VCC, GND
|
||
*
|
||
* Total registered: 16 + 8 + 6 + 4 + 2 + 2 = 38 named pins.
|
||
*/
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import { BoardHarness } from '../src/BoardHarness.js';
|
||
import { chipWasmExists, hex8, hex16 } from '../src/helpers.js';
|
||
import { I8080, asm, imm16 } from '../src/isa/8080-opcodes.js';
|
||
|
||
const CHIP = '8080';
|
||
const skip = !chipWasmExists(CHIP);
|
||
|
||
const CLOCK_HZ = 2_000_000; // 2 MHz reference
|
||
const CLOCK_NS = Math.round(1e9 / CLOCK_HZ);
|
||
|
||
/** Wire the CPU pins to nets of the same name. */
|
||
function fullPinMap() {
|
||
const m = {
|
||
SYNC: 'SYNC', DBIN: 'DBIN', WR: 'WR', INTE: 'INTE',
|
||
WAIT: 'WAIT', HLDA: 'HLDA',
|
||
READY: 'READY', HOLD: 'HOLD', INT: 'INT', RESET: 'RESET',
|
||
PHI1: 'PHI1', PHI2: 'PHI2',
|
||
VCC: 'VCC', GND: 'GND',
|
||
};
|
||
for (let i = 0; i < 16; i++) m[`A${i}`] = `A${i}`;
|
||
for (let i = 0; i < 8; i++) m[`D${i}`] = `D${i}`;
|
||
return m;
|
||
}
|
||
|
||
/** Set up a board with CPU + fake ROM (program at 0x0000) + fake RAM (0x8000+). */
|
||
async function bootCpu(program, opts = {}) {
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
|
||
// Program lives at 0x0000 in fake ROM.
|
||
board.installFakeRom(program, {
|
||
addrPrefix: 'A', addrWidth: 16,
|
||
dataPrefix: 'D', dataWidth: 8,
|
||
rd: 'DBIN', rdActiveLow: false, // 8080 DBIN is active HIGH
|
||
baseAddr: 0,
|
||
});
|
||
|
||
// RAM at 0x8000..0xFFFF for stores and stack.
|
||
const ram = board.installFakeRam(0x8000, {
|
||
addrPrefix: 'A', addrWidth: 16,
|
||
dataPrefix: 'D', dataWidth: 8,
|
||
rd: 'DBIN', rdActiveLow: false, // 8080 DBIN active HIGH
|
||
wr: 'WR',
|
||
baseAddr: 0x8000,
|
||
});
|
||
|
||
// Tie inputs that stay quiet during these tests.
|
||
board.setNet('READY', true);
|
||
board.setNet('HOLD', false);
|
||
board.setNet('INT', false);
|
||
|
||
// Pulse RESET̅ to start clean. 8080 RESET is active HIGH (unlike Z80).
|
||
// Hold RESET high for a few cycles, then release. Do NOT advance time
|
||
// after release — let the caller do that (e.g. via runUntilHlt) so the
|
||
// caller has a chance to set up RAM contents before instructions run.
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 4);
|
||
board.setNet('RESET', false);
|
||
|
||
return { board, ram };
|
||
}
|
||
|
||
/** Run program until HLT (chip enters halt state) or fail. */
|
||
function runUntilHlt(board, maxCycles = 50_000) {
|
||
// 8080 halt is observable: HLTA bit on status byte during T1, OR a
|
||
// simpler convention — many emulators expose a HALT pin we can watch.
|
||
// We use the HLDA pin convention here: when chip is halted, it stops
|
||
// issuing new SYNC pulses and HLDA will stay low. A more portable
|
||
// proxy: the program ends with HLT and we just count enough cycles.
|
||
for (let i = 0; i < maxCycles; i++) board.advanceNanos(CLOCK_NS);
|
||
}
|
||
|
||
describe('Intel 8080 chip', () => {
|
||
|
||
describe('pin contract', () => {
|
||
it.skipIf(skip)('registers all 38 named pins', async () => {
|
||
const board = new BoardHarness();
|
||
await expect(board.addChip(CHIP, fullPinMap())).resolves.toBeDefined();
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('reset behavior', () => {
|
||
it.skipIf(skip)('first opcode fetch reads from address 0x0000', async () => {
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
|
||
const fetchAddrs = [];
|
||
// Capture every address asserted while DBIN is high.
|
||
board.watchNet('DBIN', (high) => {
|
||
if (high) fetchAddrs.push(board.readBus('A', 16));
|
||
});
|
||
|
||
// Provide a NOP-loop ROM so the CPU has something to fetch.
|
||
board.installFakeRom([I8080.NOP, I8080.NOP, I8080.NOP, I8080.HLT],
|
||
{ rd: 'DBIN', rdActiveLow: false });
|
||
board.setNet('READY', true);
|
||
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 4);
|
||
board.setNet('RESET', false);
|
||
board.advanceNanos(CLOCK_NS * 12);
|
||
|
||
expect(fetchAddrs.length).toBeGreaterThan(0);
|
||
expect(fetchAddrs[0], 'first fetch must be from PC=0x0000').toBe(0x0000);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('bus protocol — M1 cycle', () => {
|
||
it.skipIf(skip)('asserts SYNC during T1 and drives the data bus with a status byte', async () => {
|
||
const board = new BoardHarness();
|
||
await board.addChip(CHIP, fullPinMap());
|
||
|
||
const syncPulses = [];
|
||
board.watchNet('SYNC', (high) => {
|
||
if (high) {
|
||
syncPulses.push({
|
||
atNanos: board.nowNanos,
|
||
statusByte: board.readBus('D', 8),
|
||
});
|
||
}
|
||
});
|
||
|
||
board.installFakeRom([I8080.NOP, I8080.HLT], { rd: 'DBIN', rdActiveLow: false });
|
||
board.setNet('READY', true);
|
||
|
||
board.setNet('RESET', true);
|
||
board.advanceNanos(CLOCK_NS * 4);
|
||
board.setNet('RESET', false);
|
||
board.advanceNanos(CLOCK_NS * 20);
|
||
|
||
expect(syncPulses.length).toBeGreaterThan(0);
|
||
// The first SYNC should carry the M1 status byte (bit 5 = M1, bit 7 = MEMR).
|
||
const first = syncPulses[0].statusByte;
|
||
expect(first & 0b00100000, 'M1 bit must be set on first fetch').toBeTruthy();
|
||
expect(first & 0b10000000, 'MEMR bit must be set on instruction fetch').toBeTruthy();
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('data movement', () => {
|
||
it.skipIf(skip)('MVI A, n loads immediate into accumulator', async () => {
|
||
// Program: MVI A, 0x42 ; STA 0x8000 ; HLT
|
||
const program = asm(I8080.MVI_A, 0x42, I8080.STA, ...imm16(0x8000), I8080.HLT);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x42);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('MOV A, B copies B into A', async () => {
|
||
// MVI B, 0x37 ; MOV A, B ; STA 0x8000 ; HLT
|
||
const program = asm(
|
||
I8080.MVI_B, 0x37,
|
||
I8080.MOV_A_B,
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x37);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('LXI H, nnnn loads 16-bit immediate', async () => {
|
||
// LXI H, 0x8042 ; MVI A, 0xAB ; MOV M, A ; HLT
|
||
// Effect: writes 0xAB to address 0x8042
|
||
const program = asm(
|
||
I8080.LXI_H, ...imm16(0x8042),
|
||
I8080.MVI_A, 0xAB,
|
||
I8080.MOV_M_A,
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8042)).toBe(0xAB);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('LDA n loads accumulator from memory', async () => {
|
||
// Pre-populate RAM[0x8050]=0xCD ; LDA 0x8050 ; STA 0x8000 ; HLT
|
||
const program = asm(
|
||
I8080.LDA, ...imm16(0x8050),
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
ram.poke(0x8050, 0xCD);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0xCD);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('arithmetic', () => {
|
||
it.skipIf(skip)('ADD with no carry', async () => {
|
||
// MVI A, 0x33 ; MVI B, 0x44 ; ADD B ; STA 0x8000 ; HLT → 0x77
|
||
const program = asm(
|
||
I8080.MVI_A, 0x33, I8080.MVI_B, 0x44,
|
||
I8080.ADD_B, I8080.STA, ...imm16(0x8000), I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x77);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('ADD with carry-out wraps and stores low byte', async () => {
|
||
// 0xFF + 0x01 = 0x100 → A=0x00, CY=1
|
||
const program = asm(
|
||
I8080.MVI_A, 0xFF, I8080.MVI_B, 0x01,
|
||
I8080.ADD_B, I8080.STA, ...imm16(0x8000),
|
||
// Push PSW + capture flags via PUSH PSW into stack at 0x9000
|
||
I8080.LXI_SP, ...imm16(0x9002),
|
||
I8080.PUSH_PSW,
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x00);
|
||
// Flags byte stored at 0x9000 (PUSH PSW writes flags then A, SP--).
|
||
// Bit 0 of flags = CY (must be 1 here).
|
||
expect(ram.peek(0x9000) & 0x01, 'CY flag after 0xFF+0x01').toBe(0x01);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('SUB sets Z flag when result is zero', async () => {
|
||
const program = asm(
|
||
I8080.MVI_A, 0x55, I8080.MVI_B, 0x55,
|
||
I8080.SUB_B,
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.LXI_SP, ...imm16(0x9002),
|
||
I8080.PUSH_PSW,
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x00);
|
||
// Bit 6 of flags byte = Z
|
||
expect(ram.peek(0x9000) & 0x40, 'Z flag').toBe(0x40);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('INR sets / clears Z flag without affecting CY', async () => {
|
||
// INR from 0xFF wraps to 0x00 → Z=1, but CY is unchanged.
|
||
// Pre-set CY via STC before the INR. Standard 8080 behaviour:
|
||
// INR does NOT affect CY.
|
||
const program = asm(
|
||
I8080.STC, // CY = 1
|
||
I8080.MVI_A, 0xFF,
|
||
I8080.INR_A, // A = 0x00, Z=1, CY unchanged
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.LXI_SP, ...imm16(0x9002),
|
||
I8080.PUSH_PSW,
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x00);
|
||
const flags = ram.peek(0x9000);
|
||
expect(flags & 0x40, 'Z=1 after wrap').toBe(0x40);
|
||
expect(flags & 0x01, 'CY unchanged by INR').toBe(0x01);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('DAA decimal-adjusts after addition', async () => {
|
||
// 0x35 + 0x47 = 0x7C ; DAA → 0x82 (BCD: 35 + 47 = 82)
|
||
const program = asm(
|
||
I8080.MVI_A, 0x35, I8080.MVI_B, 0x47,
|
||
I8080.ADD_B,
|
||
I8080.DAA,
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x82);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('control flow', () => {
|
||
it.skipIf(skip)('JMP transfers PC unconditionally', async () => {
|
||
// JMP 0x0006 ; (skip 1 byte) ; HLT ; MVI A, 0xAA ; STA 0x8000 ; HLT
|
||
// After JMP we land at 0x0006 (the MVI A).
|
||
const program = new Uint8Array(16);
|
||
program.fill(I8080.NOP);
|
||
program[0] = I8080.JMP; program[1] = 0x06; program[2] = 0x00;
|
||
program[3] = I8080.HLT; // unreachable
|
||
program[6] = I8080.MVI_A; program[7] = 0xAA;
|
||
program[8] = I8080.STA; program[9] = 0x00; program[10] = 0x80;
|
||
program[11] = I8080.HLT;
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0xAA);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('JZ taken when Z=1, skipped when Z=0', async () => {
|
||
// SUB A=A → Z=1 ; JZ taken ; STA 0x8000=0x11 ; HLT ; ... unreached
|
||
const program = asm(
|
||
I8080.MVI_A, 0x05,
|
||
I8080.SUB_B, // B is 0 by reset → A=5 still, Z=0 (NOT zero)
|
||
// Actually B's reset value is undocumented; force it.
|
||
// Let's make this deterministic:
|
||
I8080.HLT, // placeholder; we'll rewrite as a proper sequence
|
||
);
|
||
// Simpler explicit version:
|
||
// Layout (bytes): 0:MVI_A 1:00 2:MVI_B 3:00 4:SUB_B
|
||
// 5:JZ 6:0B 7:00 (target = byte 0x0B)
|
||
// 8:MVI_A 9:EE 10:HLT (unreached if jump taken)
|
||
// 11:MVI_A 12:11 13:STA 14:00 15:80 16:HLT
|
||
const p2 = asm(
|
||
I8080.MVI_A, 0x00, I8080.MVI_B, 0x00,
|
||
I8080.SUB_B, // Z=1
|
||
I8080.JZ, ...imm16(0x000B), // jump past the unreachable HLT
|
||
I8080.MVI_A, 0xEE, // unreached
|
||
I8080.HLT, // unreached
|
||
I8080.MVI_A, 0x11,
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.HLT,
|
||
);
|
||
const { board, ram } = await bootCpu(p2);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x11);
|
||
board.dispose();
|
||
});
|
||
|
||
it.skipIf(skip)('CALL pushes return address, RET pops it', async () => {
|
||
// SP = 0x9000 ; CALL sub ; STA 0x8000 (after RET) ; HLT
|
||
// sub: MVI A, 0x77 ; RET
|
||
const program = asm(
|
||
I8080.LXI_SP, ...imm16(0x9000),
|
||
I8080.CALL, ...imm16(0x000A), // call to offset 10
|
||
I8080.STA, ...imm16(0x8000),
|
||
I8080.HLT,
|
||
// padding to offset 10
|
||
I8080.NOP,
|
||
// offset 10:
|
||
I8080.MVI_A, 0x77,
|
||
I8080.RET,
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000)).toBe(0x77);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('I/O ports', () => {
|
||
it.skipIf(skip)('OUT drives address bus with port number and asserts WR̅', async () => {
|
||
// MVI A, 0x99 ; OUT 0x42 ; HLT
|
||
const program = asm(I8080.MVI_A, 0x99, I8080.OUT, 0x42, I8080.HLT);
|
||
const { board } = await bootCpu(program);
|
||
|
||
const outWrites = board.captureWrites({ wr: 'WR' });
|
||
|
||
runUntilHlt(board, 1000);
|
||
|
||
// The 8080 mirrors the port number on both A0..A7 and A8..A15
|
||
// during an OUT cycle.
|
||
const port = outWrites.find((w) => (w.addr & 0xff) === 0x42);
|
||
expect(port, 'OUT cycle to port 0x42').toBeDefined();
|
||
expect(port.data).toBe(0x99);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('halt', () => {
|
||
it.skipIf(skip)('HLT stops further opcode fetches', async () => {
|
||
const program = asm(I8080.HLT);
|
||
const { board } = await bootCpu(program);
|
||
|
||
let fetchesAfterHalt = 0;
|
||
// Hook DBIN to count fetches *after* a settling period.
|
||
board.watchNet('DBIN', (high) => {
|
||
if (high && board.nowNanos > BigInt(CLOCK_NS * 30)) fetchesAfterHalt++;
|
||
});
|
||
board.advanceNanos(CLOCK_NS * 100);
|
||
|
||
// HLT should freeze fetches; allow up to 1 extra fetch for the
|
||
// halt-state status update, beyond that is a bug.
|
||
expect(fetchesAfterHalt).toBeLessThanOrEqual(1);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('interrupts', () => {
|
||
it.skipIf(skip)('INT pin + INTA bus cycle vectors via RST opcode jammed on bus', async () => {
|
||
// EI ; loop: NOP ; JMP loop
|
||
// ISR at 0x0028 (RST 5): MVI A, 0x55 ; STA 0x8000 ; HLT
|
||
const program = new Uint8Array(0x40);
|
||
program.fill(I8080.NOP);
|
||
program[0x00] = I8080.EI;
|
||
program[0x01] = I8080.JMP; program[0x02] = 0x01; program[0x03] = 0x00;
|
||
program[0x28] = I8080.MVI_A; program[0x29] = 0x55;
|
||
program[0x2A] = I8080.STA; program[0x2B] = 0x00; program[0x2C] = 0x80;
|
||
program[0x2D] = I8080.HLT;
|
||
|
||
const { board, ram } = await bootCpu(program);
|
||
|
||
// INTA bus driver. Two-stage:
|
||
// 1. Watch SYNC. When high, sample the status byte. If INTA bit
|
||
// is set (status 0x23 = M1 + INTA + WO̅), latch a flag.
|
||
// 2. Watch DBIN AFTER bootCpu (so we register last and our drive
|
||
// overrides the fake_rom's drive on the same DBIN edge).
|
||
// When DBIN rises during a latched INTA cycle, drive the RST
|
||
// opcode on D — the chip will read it.
|
||
let intaPending = false;
|
||
board.watchNet('SYNC', (high) => {
|
||
if (!high) return;
|
||
const status = board.readBus('D', 8);
|
||
intaPending = (status & 0x01) !== 0;
|
||
});
|
||
board.watchNet('DBIN', (high) => {
|
||
if (!high || !intaPending) return;
|
||
intaPending = false;
|
||
const RST5 = 0xEF;
|
||
for (let i = 0; i < 8; i++) {
|
||
board.setNet(`D${i}`, ((RST5 >> i) & 1) === 1);
|
||
}
|
||
});
|
||
|
||
// Let EI + a few NOPs run.
|
||
board.advanceNanos(CLOCK_NS * 20);
|
||
// Pulse INT high.
|
||
board.setNet('INT', true);
|
||
board.advanceNanos(CLOCK_NS * 5);
|
||
board.setNet('INT', false);
|
||
// Let the ISR run to HLT.
|
||
board.advanceNanos(CLOCK_NS * 200);
|
||
|
||
expect(ram.peek(0x8000)).toBe(0x55);
|
||
board.dispose();
|
||
});
|
||
});
|
||
|
||
describe('integration', () => {
|
||
it.skipIf(skip)('runs a hand-built loop that increments memory 10× and stores final count', async () => {
|
||
// Loop: B = 10; mem[0x8000] = 0; do { mem[0x8000]++; B--; } while (B != 0);
|
||
//
|
||
// LXI H, 0x8000 ; HL ← 0x8000 (memory pointer)
|
||
// MVI M, 0x00 ; mem[HL] = 0
|
||
// MVI B, 10 ; B = 10 (loop count)
|
||
// loop: INR M ; mem[HL]++
|
||
// DCR B ; B--
|
||
// JNZ loop ; while B != 0
|
||
// HLT ; stop
|
||
const program = asm(
|
||
I8080.LXI_H, ...imm16(0x8000), // 0x00..0x02
|
||
I8080.MVI_M, 0x00, // 0x03..0x04
|
||
I8080.MVI_B, 0x0A, // 0x05..0x06
|
||
I8080.INR_M, // 0x07 ← loop label
|
||
I8080.DCR_B, // 0x08
|
||
I8080.JNZ, ...imm16(0x0007), // 0x09..0x0B
|
||
I8080.HLT, // 0x0C
|
||
);
|
||
const { board, ram } = await bootCpu(program);
|
||
runUntilHlt(board);
|
||
expect(ram.peek(0x8000), 'memory must hold the final loop count = 10').toBe(10);
|
||
board.dispose();
|
||
});
|
||
|
||
/* CPUDIAG end-to-end run lives in its own file (`cpudiag.test.js`)
|
||
— it requires a much longer time budget than the unit suite. */
|
||
});
|
||
});
|