velxio/test/test_intel/test_z80/galaksija.test.js

159 lines
5.9 KiB
JavaScript

/**
* Galaksija — end-to-end Z80 ROM integration test.
*
* Galaksija is a 1983 Yugoslav DIY home computer designed by Voja
* Antonić. The complete schematics + ROM source were published in
* the magazine "Galaksija" #6 and explicitly placed in the public
* domain by the author.
*
* ROMs:
* ROM A (4 KB) at 0x0000..0x0FFF — Z80 monitor + integer BASIC.
* ROM B (4 KB) at 0x1000..0x1FFF — floating-point + extra BASIC.
*
* RAM (this ROM A "init ver 29"):
* 0x2000..0x27FF — main RAM (system stack grows down from 0x2800).
* 0x2800..0x3FFF — video + user RAM. The "READY" banner string
* appears at 0x2802 after init completes.
*
* Boot path (from ROM A, verified by disassembly):
* 0x0000: DI ; disable interrupts
* 0x0001: SUB A ; A=0, all flags set
* 0x0002: JP 0x03DA ; main init
*
* What we verify
* --------------
* 1) The chip executes the boot sequence without locking up — PC
* visits the JP target 0x03DA within the first few hundred
* machine cycles, and continues past it.
* 2) After running for a few hundred thousand cycles, the ASCII
* string "READY" appears in RAM. That's the Galaksija monitor's
* "ready for input" prompt — a real, recognisable boot artifact
* that proves: the Z80 chip's full ISA is correct enough to run
* a ~500-instruction-long initialisation sequence end-to-end,
* its bus protocol drives MREQ̅+RD̅ correctly across 4 KB of ROM,
* and writes via WR̅ correctly land in fake RAM.
*
* We don't simulate the keyboard — Galaksija scans rows by issuing
* `IN A,(0xnn)` and decoding the address bus, which is irrelevant to
* proving boot. The CPU sees no keys pressed and stays in the input
* polling loop after init, which is exactly the right behaviour to
* observe.
*/
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', 'z80', 'galaksija_rom_a.bin');
const ROM_B_PATH = join(__dirname, '..', 'roms', 'z80', 'galaksija_rom_b.bin');
const skip = !chipWasmExists('z80') || !existsSync(ROM_PATH);
const CLOCK_NS = 250; // 4 MHz Z80 (Galaksija ran at 3.072 MHz, close enough)
function fullPinMap() {
const m = {
M1: 'M1', MREQ: 'MREQ', IORQ: 'IORQ', RD: 'RD', WR: 'WR',
RFSH: 'RFSH', HALT: 'HALT', WAIT: 'WAIT',
INT: 'INT', NMI: 'NMI', RESET: 'RESET', BUSREQ: 'BUSREQ',
BUSACK: 'BUSACK', CLK: 'CLK', 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;
}
describe.skipIf(skip)('Galaksija ROM (Z80) integration', () => {
it('Z80 boots Galaksija ROM A and initialises video framebuffer', async () => {
const romA = readFileSync(ROM_PATH);
expect(romA.length, 'ROM A must be exactly 4 KB').toBe(4096);
// Concatenate ROM A + ROM B → 8 KB image at 0x0000..0x1FFF.
let romImage = new Uint8Array(8192);
romImage.set(romA, 0);
if (existsSync(ROM_B_PATH)) {
const romB = readFileSync(ROM_B_PATH);
romImage.set(romB, 0x1000);
}
const board = new BoardHarness();
await board.addChip('z80', fullPinMap());
// ROM at 0x0000..0x1FFF (read-only, MREQ̅+RD̅).
board.installFakeRom(romImage, {
rd: 'RD', rdActiveLow: true,
cs: 'MREQ', csActiveLow: true,
baseAddr: 0,
});
// System RAM at 0x2000..0x3FFF (writable).
const ram = board.installFakeRam(0x2000, {
rd: 'RD', wr: 'WR',
cs: 'MREQ', csActiveLow: true,
baseAddr: 0x2000,
});
// The 0x4000..0xFFFF region isn't mapped on a real Galaksija;
// any access there returns floating bus. We don't model that —
// the Z80 should never go there if the ROM is correct.
// Watch M1 fetches so we can prove PC advances and visits the
// JP target 0x03DA from the reset vector.
const m1Addrs = [];
let visited3DA = false;
let lastPc = -1;
let stuckCount = 0;
let everMoved = false;
board.watchNet('M1', (low) => {
if (low === false) {
const pc = board.readBus('A', 16);
m1Addrs.push(pc);
if (pc === 0x03DA) visited3DA = true;
if (pc !== lastPc) { everMoved = true; stuckCount = 0; }
else stuckCount++;
lastPc = pc;
}
});
// Quiet inputs.
board.setNet('WAIT', true);
board.setNet('INT', true);
board.setNet('NMI', true);
board.setNet('BUSREQ', true);
board.setNet('RESET', false);
board.advanceNanos(CLOCK_NS * 4);
board.setNet('RESET', true);
// Run a few hundred thousand cycles. ROM A's init routine clears
// the screen + draws the welcome banner; that's well under 100K
// cycles even on real hardware (~3 MHz).
const TARGET_CYCLES = 500_000;
for (let i = 0; i < TARGET_CYCLES; i++) board.advanceNanos(CLOCK_NS);
expect(everMoved, 'PC must advance past 0x0000 (chip not stuck at reset)').toBe(true);
expect(visited3DA, 'PC must reach 0x03DA (the JP target from reset)').toBe(true);
expect(m1Addrs.length, 'M1 fetches counted during the run').toBeGreaterThan(1000);
// Search RAM for the ASCII string "READY". The Galaksija monitor
// writes this prompt during init.
const READY = [0x52, 0x45, 0x41, 0x44, 0x59]; // 'R','E','A','D','Y'
let readyAt = -1;
for (let addr = 0x2000; addr < 0x3FFB && readyAt === -1; addr++) {
let match = true;
for (let i = 0; i < 5; i++) {
if (ram.peek(addr + i) !== READY[i]) { match = false; break; }
}
if (match) readyAt = addr;
}
expect(readyAt, 'ASCII "READY" prompt must appear in RAM after init')
.toBeGreaterThanOrEqual(0);
board.dispose();
}, { timeout: 30_000 });
});