velxio/test/test_intel/test_8080/8080.test.js

496 lines
18 KiB
JavaScript
Raw Normal View History

/**
* 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. */
});
});