velxio/test/test_intel/test_8080/8080.c

648 lines
23 KiB
C

/*
* Intel 8080 emulator — clean-room implementation as a velxio custom chip.
*
* Source: Intel 8080 Programmer's Manual (1975) and Intel 8080A Data Sheet.
* No third-party emulator code is used — this is written from the public
* Intel specifications.
*
* Bus protocol: instruction-per-tick (collapses T1..T5 into one timer
* fire). Each bus cycle drives the address bus, pulses SYNC with a status
* byte on D0..D7, then either asserts DBIN to read or drives D and pulses
* WR̅ to write. This is faithful to the real 8080's *observable* signals;
* it is not strictly cycle-accurate.
*
* Pin contract (38 pins, see test_8080/README.md):
* A0..A15 output 16-bit address bus
* D0..D7 I/O 8-bit data bus (tristated when chip not driving)
* SYNC output Pulses high at start of every machine cycle
* DBIN output Active high — chip is reading the data bus
* WR output Active LOW — chip is writing the data bus
* INTE output Interrupt enable status (mirrors IME)
* WAIT output Asserted when chip stalled by READY=0
* HLDA output Acknowledges HOLD request
* READY input Active high — memory ready
* HOLD input Active high — bus request
* INT input Active high — interrupt request
* RESET input Active high — power-on reset
* PHI1, PHI2 input Two-phase clock (informational; the chip's
* internal pacing uses vx_timer_*)
* VCC, GND power
*/
#include "velxio-chip.h"
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
/* ─── Status-byte encoding (real 8080) ──────────────────────────────────── */
#define STATUS_INTA 0x01 /* interrupt acknowledge */
#define STATUS_WO 0x02 /* "Write/Output" — HIGH on read, LOW on write */
#define STATUS_STACK 0x04
#define STATUS_HLTA 0x08
#define STATUS_OUT 0x10
#define STATUS_M1 0x20
#define STATUS_INP 0x40
#define STATUS_MEMR 0x80
#define ST_FETCH (STATUS_M1 | STATUS_MEMR | STATUS_WO) /* 0xA2 */
#define ST_MEMR (STATUS_MEMR | STATUS_WO) /* 0x82 */
#define ST_MEMW (0) /* 0x00 */
#define ST_STACKR (STATUS_STACK | STATUS_MEMR | STATUS_WO) /* 0x86 */
#define ST_STACKW (STATUS_STACK) /* 0x04 */
#define ST_INP (STATUS_INP | STATUS_WO) /* 0x42 */
#define ST_OUT (STATUS_OUT) /* 0x10 */
#define ST_INTA (STATUS_M1 | STATUS_INTA | STATUS_WO) /* 0x23 */
#define ST_HLTA (STATUS_HLTA | STATUS_MEMR | STATUS_WO) /* 0x8A */
/* ─── Flag bit positions in the packed PSW byte ─────────────────────────── */
#define F_S 0x80
#define F_Z 0x40
#define F_AC 0x10
#define F_P 0x04
#define F_RES1 0x02 /* always 1 on 8080 */
#define F_CY 0x01
/* ─── State ─────────────────────────────────────────────────────────────── */
typedef struct {
/* Pin handles — apin/dpin to avoid clash with register names a/d. */
vx_pin apin[16];
vx_pin dpin[8];
vx_pin sync, dbin, wr, inte, wait_, hlda;
vx_pin ready, hold, intn, reset;
vx_pin phi1, phi2;
vx_pin vcc, gnd;
vx_timer cycle_timer;
/* Registers — pairs aliased via union, low byte first (WASM is LE).
8080 BC pair: B is high byte, C is low byte. With this layout,
bc = (b << 8) | c. */
union { struct { uint8_t c, b; }; uint16_t bc; };
union { struct { uint8_t e, d; }; uint16_t de; };
union { struct { uint8_t l, h; }; uint16_t hl; };
uint16_t sp;
uint16_t pc;
uint8_t acc; /* accumulator A — named acc to avoid clash with .a array */
/* Flags as separate bools — packed only when needed for PUSH PSW. */
bool fs, fz, fac, fp, fcy;
bool halted;
bool ime;
bool int_pending;
bool reset_active;
} cpu_t;
static cpu_t G;
/* ─── Helpers ───────────────────────────────────────────────────────────── */
static uint8_t pack_flags(void) {
return (G.fs ? F_S : 0) | (G.fz ? F_Z : 0) | (G.fac ? F_AC : 0)
| (G.fp ? F_P : 0) | F_RES1 | (G.fcy ? F_CY : 0);
}
static void unpack_flags(uint8_t f) {
G.fs = (f & F_S) != 0;
G.fz = (f & F_Z) != 0;
G.fac = (f & F_AC) != 0;
G.fp = (f & F_P) != 0;
G.fcy = (f & F_CY) != 0;
}
static bool parity8(uint8_t v) {
v ^= v >> 4; v ^= v >> 2; v ^= v >> 1;
return (v & 1) == 0; /* 8080 P = 1 if EVEN parity */
}
static void set_szp(uint8_t v) {
G.fs = (v & 0x80) != 0;
G.fz = v == 0;
G.fp = parity8(v);
}
/* Drive an 8-bit value out on D0..D7 with the data pins as outputs. */
static void drive_data(uint8_t v) {
for (int i = 0; i < 8; i++) {
vx_pin_set_mode(G.dpin[i], VX_OUTPUT);
vx_pin_write(G.dpin[i], (v >> i) & 1);
}
}
/* Release the data bus: switch D0..D7 back to input so other masters drive. */
static void release_data(void) {
for (int i = 0; i < 8; i++) vx_pin_set_mode(G.dpin[i], VX_INPUT);
}
static void drive_addr(uint16_t a) {
for (int i = 0; i < 16; i++) vx_pin_write(G.apin[i], (a >> i) & 1);
}
static uint8_t read_data(void) {
uint8_t v = 0;
for (int i = 0; i < 8; i++) if (vx_pin_read(G.dpin[i])) v |= (1 << i);
return v;
}
/* ─── Bus cycles ────────────────────────────────────────────────────────── */
/*
* Real 8080 emits status during T1 by driving D0..D7 with the status byte
* while SYNC is high. External 8228 latches it. We emit the same observable
* pattern: drive data lines as output, raise SYNC, drop SYNC, release data
* lines, then either DBIN-read or WR̅-write.
*/
static uint8_t bus_read(uint16_t addr, uint8_t status) {
drive_addr(addr);
drive_data(status);
vx_pin_write(G.sync, 1);
vx_pin_write(G.sync, 0);
release_data();
vx_pin_write(G.dbin, 1);
uint8_t v = read_data();
vx_pin_write(G.dbin, 0);
return v;
}
static void bus_write(uint16_t addr, uint8_t data, uint8_t status) {
drive_addr(addr);
drive_data(status);
vx_pin_write(G.sync, 1);
vx_pin_write(G.sync, 0);
drive_data(data);
vx_pin_write(G.wr, 0); /* WR̅ asserted */
vx_pin_write(G.wr, 1); /* rising edge — external latches data */
/* We leave D pins as outputs holding `data` briefly; next bus cycle
will switch them again. This matches real 8080's hold-time behavior. */
}
static uint8_t mem_read(uint16_t a) { return bus_read(a, ST_MEMR); }
static void mem_write(uint16_t a, uint8_t v) { bus_write(a, v, ST_MEMW); }
static uint8_t opcode_fetch(uint16_t a) { return bus_read(a, ST_FETCH); }
static uint8_t stack_pop(void) { uint8_t v = bus_read(G.sp, ST_STACKR); G.sp++; return v; }
static void stack_push(uint8_t v) { G.sp--; bus_write(G.sp, v, ST_STACKW); }
static uint8_t fetch8(void) { return opcode_fetch(G.pc++); }
static uint8_t imm8(void) { return mem_read(G.pc++); }
static uint16_t imm16(void) { uint16_t lo = imm8(); return lo | ((uint16_t)imm8() << 8); }
static void push16(uint16_t v) { stack_push(v >> 8); stack_push(v & 0xff); }
static uint16_t pop16(void) { uint8_t lo = stack_pop(); uint8_t hi = stack_pop(); return lo | ((uint16_t)hi << 8); }
/* ─── Register-by-code helpers (000=B,001=C,010=D,011=E,100=H,101=L,110=M,111=A) ─ */
static uint8_t reg_read_code(uint8_t code) {
switch (code & 7) {
case 0: return G.b;
case 1: return G.c;
case 2: return G.d;
case 3: return G.e;
case 4: return G.h;
case 5: return G.l;
case 6: return mem_read(G.hl);
default: return G.acc;
}
}
static void reg_write_code(uint8_t code, uint8_t v) {
switch (code & 7) {
case 0: G.b = v; break;
case 1: G.c = v; break;
case 2: G.d = v; break;
case 3: G.e = v; break;
case 4: G.h = v; break;
case 5: G.l = v; break;
case 6: mem_write(G.hl, v); break;
default: G.acc = v; break;
}
}
/* ─── ALU ───────────────────────────────────────────────────────────────── */
static void alu_add(uint8_t v, bool with_carry) {
uint16_t cin = (with_carry && G.fcy) ? 1 : 0;
uint16_t r = (uint16_t)G.acc + v + cin;
G.fac = (((G.acc & 0x0F) + (v & 0x0F) + cin) & 0x10) != 0;
G.fcy = (r & 0x100) != 0;
G.acc = (uint8_t)r;
set_szp(G.acc);
}
static void alu_sub(uint8_t v, bool with_borrow, bool store) {
uint16_t cin = (with_borrow && G.fcy) ? 1 : 0;
uint16_t r = (uint16_t)G.acc - v - cin;
/* AC for SUB: set when there is NO borrow from bit 4, i.e. the low
nibble subtraction did not underflow. */
G.fac = (((G.acc & 0x0F) - (v & 0x0F) - cin) & 0x10) == 0;
G.fcy = (r & 0x100) != 0;
uint8_t r8 = (uint8_t)r;
set_szp(r8);
if (store) G.acc = r8;
}
static void alu_and(uint8_t v) {
/* 8080 AC behaviour for AND: AC = (A | val) bit 3 — the actual 8080
sets AC to ((A | val) & 0x08) >> 3 per the official Intel docs. */
G.fac = ((G.acc | v) & 0x08) != 0;
G.acc &= v;
G.fcy = false;
set_szp(G.acc);
}
static void alu_xor(uint8_t v) {
G.acc ^= v;
G.fcy = false;
G.fac = false;
set_szp(G.acc);
}
static void alu_or(uint8_t v) {
G.acc |= v;
G.fcy = false;
G.fac = false;
set_szp(G.acc);
}
static void alu_cmp(uint8_t v) {
/* CMP: like SUB but discard result. Flags reflect A - v. */
alu_sub(v, false, false);
}
static void alu_op(uint8_t op, uint8_t v) {
switch (op) {
case 0: alu_add(v, false); break; /* ADD */
case 1: alu_add(v, true); break; /* ADC */
case 2: alu_sub(v, false, true); break; /* SUB */
case 3: alu_sub(v, true, true); break; /* SBB */
case 4: alu_and(v); break;
case 5: alu_xor(v); break;
case 6: alu_or(v); break;
case 7: alu_cmp(v); break;
}
}
static uint8_t inr(uint8_t v) {
uint8_t r = v + 1;
G.fac = (v & 0x0F) == 0x0F;
set_szp(r);
return r;
}
static uint8_t dcr(uint8_t v) {
uint8_t r = v - 1;
/* AC: set when no borrow from bit 4. Borrow happens iff low nibble
of v was 0. So AC = (low_nibble != 0). */
G.fac = (v & 0x0F) != 0;
set_szp(r);
return r;
}
static void daa(void) {
uint8_t correction = 0;
bool new_cy = G.fcy;
uint8_t low = G.acc & 0x0F;
uint8_t high = G.acc >> 4;
if (low > 9 || G.fac) correction |= 0x06;
if (high > 9 || G.fcy || (high >= 9 && low > 9)) {
correction |= 0x60;
new_cy = true;
}
uint8_t old = G.acc;
G.acc = G.acc + correction;
G.fac = ((old & 0x0F) + (correction & 0x0F)) > 0x0F;
G.fcy = new_cy;
set_szp(G.acc);
}
static void dad(uint16_t v) {
/* DAD: 16-bit add into HL, only CY is affected. */
uint32_t r = (uint32_t)G.hl + v;
G.fcy = r > 0xFFFF;
G.hl = (uint16_t)r;
}
/* ─── Conditional helpers ───────────────────────────────────────────────── */
static bool cond_met(uint8_t cc) {
switch (cc & 7) {
case 0: return !G.fz; /* NZ */
case 1: return G.fz; /* Z */
case 2: return !G.fcy; /* NC */
case 3: return G.fcy; /* C */
case 4: return !G.fp; /* PO (odd) */
case 5: return G.fp; /* PE (even) */
case 6: return !G.fs; /* P (positive) */
case 7: return G.fs; /* M (minus) */
}
return false;
}
/* ─── One-instruction step ──────────────────────────────────────────────── */
static void step(void) {
/* Service interrupt if pending and IME. Real 8080 INT acknowledge:
run an INTA bus cycle (status byte 0x23 = M1+INTA+WO̅), read the
opcode that external hardware (8259 PIC or hard-wired logic)
jams onto the data bus, and execute it. The opcode is typically
a RST n (0xC7..0xFF); we support that fully. Other opcodes
during INTA are documented to work too (e.g. CALL nnn) but
require multi-byte fetches with INTA status — deferred. */
if (G.int_pending && G.ime) {
G.ime = false;
G.int_pending = false;
G.halted = false;
vx_pin_write(G.inte, 0);
/* Address driven on A0..A15 during INTA is undefined per
datasheet; we drive PC for clarity. */
uint8_t opcode = bus_read(G.pc, ST_INTA);
if ((opcode & 0xC7) == 0xC7) {
/* RST n */
push16(G.pc);
G.pc = (uint16_t)((opcode >> 3) & 7) * 8;
}
/* If opcode is a non-RST (e.g. CALL nnn = 0xCD), full fidelity
would require additional INTA bus cycles to fetch the
operand bytes — not implemented yet. Treat as a NOP. */
return;
}
if (G.halted) {
/* Real 8080 emits a HLTA bus cycle once on entry, then is silent
until INTR or RESET. We approximate "silent" — no further bus
activity — which matches the test's expectation. */
return;
}
uint8_t op = fetch8();
/* MOV r, r' — 0b01_DDD_SSS, except 0x76 = HLT */
if ((op & 0xC0) == 0x40) {
if (op == 0x76) { G.halted = true; return; }
uint8_t v = reg_read_code(op);
reg_write_code(op >> 3, v);
return;
}
/* ALU op A, r — 0b10_OOO_SSS */
if ((op & 0xC0) == 0x80) {
alu_op((op >> 3) & 7, reg_read_code(op));
return;
}
/* MVI r, n — 0b00_DDD_110 */
if ((op & 0xC7) == 0x06) {
reg_write_code(op >> 3, imm8());
return;
}
/* INR r — 0b00_DDD_100 */
if ((op & 0xC7) == 0x04) {
uint8_t code = (op >> 3) & 7;
reg_write_code(code, inr(reg_read_code(code)));
return;
}
/* DCR r — 0b00_DDD_101 */
if ((op & 0xC7) == 0x05) {
uint8_t code = (op >> 3) & 7;
reg_write_code(code, dcr(reg_read_code(code)));
return;
}
/* RST n — 0b11_NNN_111 */
if ((op & 0xC7) == 0xC7) {
uint8_t n = (op >> 3) & 7;
push16(G.pc);
G.pc = (uint16_t)n * 8;
return;
}
/* Conditional jumps — 0b11_CCC_010 */
if ((op & 0xC7) == 0xC2) {
uint16_t a = imm16();
if (cond_met((op >> 3) & 7)) G.pc = a;
return;
}
/* Conditional calls — 0b11_CCC_100 */
if ((op & 0xC7) == 0xC4) {
uint16_t a = imm16();
if (cond_met((op >> 3) & 7)) { push16(G.pc); G.pc = a; }
return;
}
/* Conditional returns — 0b11_CCC_000 */
if ((op & 0xC7) == 0xC0) {
if (cond_met((op >> 3) & 7)) G.pc = pop16();
return;
}
/* Remaining opcodes — explicit dispatch. */
switch (op) {
case 0x00: /* NOP */ break;
/* LXI rp, nn */
case 0x01: G.bc = imm16(); break;
case 0x11: G.de = imm16(); break;
case 0x21: G.hl = imm16(); break;
case 0x31: G.sp = imm16(); break;
/* INX rp / DCX rp */
case 0x03: G.bc++; break;
case 0x13: G.de++; break;
case 0x23: G.hl++; break;
case 0x33: G.sp++; break;
case 0x0B: G.bc--; break;
case 0x1B: G.de--; break;
case 0x2B: G.hl--; break;
case 0x3B: G.sp--; break;
/* DAD rp */
case 0x09: dad(G.bc); break;
case 0x19: dad(G.de); break;
case 0x29: dad(G.hl); break;
case 0x39: dad(G.sp); break;
/* STAX / LDAX */
case 0x02: mem_write(G.bc, G.acc); break;
case 0x12: mem_write(G.de, G.acc); break;
case 0x0A: G.acc = mem_read(G.bc); break;
case 0x1A: G.acc = mem_read(G.de); break;
/* STA / LDA */
case 0x32: { uint16_t a = imm16(); mem_write(a, G.acc); break; }
case 0x3A: { uint16_t a = imm16(); G.acc = mem_read(a); break; }
/* SHLD / LHLD */
case 0x22: { uint16_t a = imm16(); mem_write(a, G.l); mem_write(a+1, G.h); break; }
case 0x2A: { uint16_t a = imm16(); G.l = mem_read(a); G.h = mem_read(a+1); break; }
/* Rotates */
case 0x07: { /* RLC */
uint8_t b7 = (G.acc >> 7) & 1;
G.acc = (G.acc << 1) | b7;
G.fcy = b7;
break;
}
case 0x0F: { /* RRC */
uint8_t b0 = G.acc & 1;
G.acc = (G.acc >> 1) | (b0 << 7);
G.fcy = b0;
break;
}
case 0x17: { /* RAL */
uint8_t b7 = (G.acc >> 7) & 1;
G.acc = (G.acc << 1) | (G.fcy ? 1 : 0);
G.fcy = b7;
break;
}
case 0x1F: { /* RAR */
uint8_t b0 = G.acc & 1;
G.acc = (G.acc >> 1) | ((G.fcy ? 1 : 0) << 7);
G.fcy = b0;
break;
}
case 0x27: daa(); break;
case 0x2F: G.acc = ~G.acc; break; /* CMA */
case 0x37: G.fcy = true; break; /* STC */
case 0x3F: G.fcy = !G.fcy; break; /* CMC */
/* Immediate ALU — ADI/ACI/SUI/SBI/ANI/XRI/ORI/CPI */
case 0xC6: alu_add(imm8(), false); break;
case 0xCE: alu_add(imm8(), true); break;
case 0xD6: alu_sub(imm8(), false, true); break;
case 0xDE: alu_sub(imm8(), true, true); break;
case 0xE6: alu_and(imm8()); break;
case 0xEE: alu_xor(imm8()); break;
case 0xF6: alu_or(imm8()); break;
case 0xFE: alu_cmp(imm8()); break;
/* Unconditional jump / call / return */
case 0xC3: G.pc = imm16(); break;
case 0xCD: { uint16_t a = imm16(); push16(G.pc); G.pc = a; break; }
case 0xC9: G.pc = pop16(); break;
case 0xE9: G.pc = G.hl; break; /* PCHL */
/* Stack — PUSH / POP rp */
case 0xC5: stack_push(G.b); stack_push(G.c); break;
case 0xD5: stack_push(G.d); stack_push(G.e); break;
case 0xE5: stack_push(G.h); stack_push(G.l); break;
case 0xF5: stack_push(G.acc); stack_push(pack_flags()); break;
case 0xC1: G.c = stack_pop(); G.b = stack_pop(); break;
case 0xD1: G.e = stack_pop(); G.d = stack_pop(); break;
case 0xE1: G.l = stack_pop(); G.h = stack_pop(); break;
case 0xF1: { unpack_flags(stack_pop()); G.acc = stack_pop(); break; }
case 0xE3: { /* XTHL */
uint8_t lo = mem_read(G.sp), hi = mem_read(G.sp + 1);
mem_write(G.sp, G.l); mem_write(G.sp + 1, G.h);
G.l = lo; G.h = hi;
break;
}
case 0xF9: G.sp = G.hl; break; /* SPHL */
case 0xEB: { /* XCHG */
uint16_t t = G.de; G.de = G.hl; G.hl = t; break;
}
/* I/O — IN / OUT mirror port number on both halves of address bus. */
case 0xDB: { /* IN n */
uint8_t port = imm8();
G.acc = bus_read(((uint16_t)port << 8) | port, ST_INP);
break;
}
case 0xD3: { /* OUT n */
uint8_t port = imm8();
bus_write(((uint16_t)port << 8) | port, G.acc, ST_OUT);
break;
}
/* Interrupt control */
case 0xFB: G.ime = true; vx_pin_write(G.inte, 1); break;
case 0xF3: G.ime = false; vx_pin_write(G.inte, 0); break;
default:
/* Undocumented / unimplemented — treat as NOP. Real 8080 has a
handful of duplicate opcodes (e.g. 0x08, 0x10, 0x18 = NOP
aliases) which is fine to ignore here. */
break;
}
}
/* ─── Reset and clock callbacks ─────────────────────────────────────────── */
static void reset_state(void) {
G.pc = 0;
G.sp = 0;
G.acc = 0; G.b = G.c = G.d = G.e = G.h = G.l = 0;
G.fs = G.fz = G.fac = G.fp = false;
G.fcy = false;
G.halted = false;
G.ime = false;
G.int_pending = false;
vx_pin_write(G.sync, 0);
vx_pin_write(G.dbin, 0);
vx_pin_write(G.wr, 1); /* WR̅ idle = HIGH */
vx_pin_write(G.inte, 0);
vx_pin_write(G.wait_, 0);
vx_pin_write(G.hlda, 0);
release_data();
}
static void on_reset(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin;
if (value) {
G.reset_active = true;
reset_state();
} else {
G.reset_active = false;
}
}
static void on_int(void* user_data, vx_pin pin, int value) {
(void)user_data; (void)pin;
if (value) G.int_pending = true;
}
static void on_clock(void* user_data) {
(void)user_data;
if (G.reset_active) return;
/* READY low → wait state. Active high. */
if (vx_pin_read(G.ready) == 0) {
vx_pin_write(G.wait_, 1);
return;
}
vx_pin_write(G.wait_, 0);
/* HOLD high → bus release (ack and idle). */
if (vx_pin_read(G.hold)) {
vx_pin_write(G.hlda, 1);
return;
}
vx_pin_write(G.hlda, 0);
step();
}
/* ─── Setup ─────────────────────────────────────────────────────────────── */
void chip_setup(void) {
char name[8];
/* A0..A15 outputs */
for (int i = 0; i < 16; i++) {
name[0]='A'; if (i<10) { name[1]='0'+i; name[2]=0; }
else { name[1]='1'; name[2]='0'+(i-10); name[3]=0; }
G.apin[i] = vx_pin_register(name, VX_OUTPUT_LOW);
}
/* D0..D7 — start as input (chip not driving) */
for (int i = 0; i < 8; i++) {
name[0]='D'; name[1]='0'+i; name[2]=0;
G.dpin[i] = vx_pin_register(name, VX_INPUT);
}
G.sync = vx_pin_register("SYNC", VX_OUTPUT_LOW);
G.dbin = vx_pin_register("DBIN", VX_OUTPUT_LOW);
G.wr = vx_pin_register("WR", VX_OUTPUT_HIGH); /* idle high */
G.inte = vx_pin_register("INTE", VX_OUTPUT_LOW);
G.wait_ = vx_pin_register("WAIT", VX_OUTPUT_LOW);
G.hlda = vx_pin_register("HLDA", VX_OUTPUT_LOW);
G.ready = vx_pin_register("READY", VX_INPUT);
G.hold = vx_pin_register("HOLD", VX_INPUT);
G.intn = vx_pin_register("INT", VX_INPUT);
G.reset = vx_pin_register("RESET", VX_INPUT);
G.phi1 = vx_pin_register("PHI1", VX_INPUT);
G.phi2 = vx_pin_register("PHI2", VX_INPUT);
G.vcc = vx_pin_register("VCC", VX_INPUT);
G.gnd = vx_pin_register("GND", VX_INPUT);
reset_state();
vx_pin_watch(G.reset, VX_EDGE_BOTH, on_reset, 0);
vx_pin_watch(G.intn, VX_EDGE_RISING, on_int, 0);
/* Timer-driven step. 500 ns period == 2 MHz pseudo-clock.
One instruction per fire — simple, deterministic, plenty fast for tests. */
G.cycle_timer = vx_timer_create(on_clock, 0);
vx_timer_start(G.cycle_timer, 500, true);
}