6.3 KiB
Emulation strategy: mapping a CPU onto a velxio custom chip
The velxio runtime is reactive — there is no loop() hook. Every
chip is woken by either a pin edge or a timer fire. This document
describes how to fit an instruction-level CPU emulator into that model.
Two viable execution models
A. Timer-driven step (recommended starting point)
chip_setup() {
register all pins
reset_cpu_state()
timer = vx_timer_create()
vx_timer_start(timer, INSTR_PERIOD_NS, /* repeating */ true,
on_clock_tick, NULL)
}
on_clock_tick() {
step_one_instruction() // or step_one_machine_cycle()
drive_bus_pins_for_phase() // RD/WR/MREQ/ALE/etc.
}
- One timer fires at a chosen period (e.g. 250 ns for a 4 MHz Z80, or coarser if we step a whole instruction per tick).
- The callback runs CPU work, sets the appropriate output pins for the bus phase, and returns.
- Memory and I/O reads happen in two passes: in callback
N, the CPU drives the address pins and assertsRD; in callbackN+1, the CPU reads the data pins (which the memory chip on the canvas has had a full tick to settle). - This is not cycle-accurate with respect to the real silicon unless the period equals one T-state. For most retro-computing demos that's fine.
Pro: simple, deterministic, easy to reason about, easy to slow
down for visualization.
Con: can't react instantly to a wait state — has to wait for the
next tick. Mitigated by adding a pin watch on WAIT/READY that
stalls the timer-driven step.
B. Pin-edge-driven step
chip_setup() {
register pins
vx_pin_watch(CLK_PIN, VX_RISING, on_clk_rising, NULL)
}
on_clk_rising() {
advance_one_t_state()
}
- The chip itself does not generate the clock; an external clock
chip on the canvas drives
CLKand the CPU advances one T-state per rising edge. - Closer to how the real silicon behaves — the user can plug in a slow manual clock and single-step.
- More expensive per second (one host callback per clock edge) but matches user expectations and lets wait-state logic just be more pin watches.
Recommendation: ship model A first for each chip (proves the ISA implementation), then optionally upgrade to model B for chips where single-stepping is part of the demo (Z80, 8080).
Bus-cycle implementation
For separate-bus CPUs (8080, Z80) one machine read cycle looks like:
T1: drive A0..A15 with addr; assert MREQ; assert RD; release D0..D7
T2: (wait one tick for memory to respond)
T3: sample D0..D7 → byte = vx_pin_read_byte(D0..D7)
T4: deassert RD, MREQ
In the timer-driven model we expose this as a small state machine
inside step_one_instruction(). We do not need the runtime to
know anything about it — to the runtime we are just toggling pins.
For multiplexed-bus CPUs (4004, 8086) the chip drives a richer phase sequence:
8086 T1: drive AD0..AD15 with low addr, A16..A19 with high addr,
assert ALE high then low (latches address into external 8282)
8086 T2: switch AD0..AD15 to data direction (input for read,
output for write), assert RD or WR
8086 T3: sample/drive data
8086 T4: deassert RD/WR
The chip does not need to implement the 8282 latch — it just needs to
drive ALE faithfully. A separate "address latch" custom chip on the
canvas (or a built-in primitive — see open question Q3) consumes
ALE+AD0..15 and produces stable demultiplexed A0..15.
Memory and I/O on the canvas
Each CPU needs at minimum a ROM holding the program. Three options, in increasing order of effort:
- Bake the ROM into the CPU chip itself. Simplest, allows zero external wiring for a "hello world" demo. Bad for the long-term pedagogical goal (the user can't see the bus working).
- Author a separate
rom-32k.ccustom chip with 16 address pins, 8 data pins,OE̅,CS̅. Reads the ROM image from a chip-config blob. Reusable across CPUs. - Author a separate
ram-64k.ccustom chip with the same pinout plusWE̅. Even more reusable.
The right call is to do (2) and (3) once, in a sibling test_buses/
folder, and reuse them across all five CPUs. Tracked as Q1 in
05_open_questions.md.
Decoder representation
For the 8-bit CPUs, the conventional approach is a 256-entry
dispatch[] table whose entries are small functions or compact
microcode steps. Andre Weissflog's chips/z80.h uses a flat
table-driven decoder generated by an offline script — porting that
script into our build pipeline is feasible but non-trivial.
Pragmatic call: for the first cut, use a giant switch (opcode)
in C. WASI-SDK's clang compiles big switches into reasonable jump
tables. Premature optimization is the enemy here.
Timing and the simulated clock
vx_sim_now_nanos() is monotonic in simulated time. The host
advances simulated time as fast as it can, throttled by frame rendering.
This means:
- A 4 MHz Z80 emulator running 1 instruction per 1 µs of simulated time will appear to run at whatever speed the host can sustain in wall time — could be slower than 4 MHz, could be faster.
- Don't use
vx_sim_now_nanosfor "realistic" wall-clock pacing. Use it for ordering events. - For pedagogy, expose a "clock rate" config knob in the chip JSON so users can set their Z80 to 1 Hz and watch instructions execute one at a time.
Determinism
All the CPUs in scope are deterministic given the same inputs (no async errata of consequence). Two important invariants:
- Avoid host-side wall-clock dependence. Only
vx_sim_now_nanosis safe; never call out to anything that observes real time. - Avoid floating-point in the CPU core unless we explicitly want it
(none of these chips have hardware FP). Stick to integer
uint8_t/uint16_t/uint32_t.
Suggested implementation order
- 8080 first. Cleanest bus, smallest ISA per pin, MIT-licensed
reference emulator (
superzazu/8080) available for porting. - Z80 second. Same bus shape as 8080 + extensions; reuse what we
learned. Reference emulator:
floooh/chips/z80.h. - 4004 third. Different in shape (4-bit, multiplexed) — proves the multiplexed-bus pattern that 8086 will need.
- 4040 fourth. Increment on 4004.
- 8086 last. Most complex; benefits from everything learned above.
This ordering also matches what reference material we already trust.