emulator/¶
p8xemu.c — a cycle-accurate P8X emulator. It does not hard-code instruction
behavior; it loads the microcode images u0–u3.bin and steps the same control
word the real hardware does, so it is a faithful model of the machine, not an
approximation.
Build & run¶
make # builds p8xemu and regenerates the microcode (u0-u3.bin)
./p8xemu [-t] [-T] [-N] [-i F] [-l N] [-c disk.img] [-c2 disk2.img] [-s NN] [-L]
[-g out.ppm] [-G] rom.bin
rom.bin— the EEPROM image (origin$0000), e.g. the monitor or combined the monitor. The emulator expectsu0–u3.binin the current directory.-c disk.img— attach a CompactFlash image as drive 0 (boot); models the 8-bit True IDE task file at$FF10–$FF17.-c2 disk2.img— attach a second CompactFlash image as drive 1. Both cards share the$FF10task-file port and are selected by the ATA device-select bit (CFHEAD/$FF16bit 0), which the firmware drives from itsDRVSEL. An absent drive 1 (no-c2) reads back$FF, so the firmware's bounded CF waits detect it instead of hanging. The task-file (feature + LBA) is a shared bus: both drives latch those register writes and the device bit only picks who executes the command — so loading the LBA before selecting the drive (asCFSETLdoes) still targets the right card.-s NN— value the I/O card switches present at$FF00(hex or decimal, e.g.-s 0xA5); defaults to 0. SoPEEK(65280)/ monitor reads see it.-L— trace LED writes: each change to$FF02prints to stderr as[LED $FF02] $NN *.*..*.*(*= lit). The final LED byte is always shown in the halt status line.-g out.ppm— write the graphics display toout.ppmwhen the run ends, and-G— render it as text to stderr. "Ends" includes Ctrl-C, so you can drive BASIC by hand and then quit to get the picture.- Ctrl-\ shows the display without ending the run (SIGQUIT), so you can draw,
look, and carry on in the same session — which is the only way experimenting
with graphics is bearable. It works with or without
-g/-G: with neither, you get the text view anyway, since pressing it means you want to see something. The display itself is always present behind the GL port at$FF50; these flags only decide whether you get to see it. See The graphics display below. -t— instruction trace.-l N— halt after N cycles.- The 6850 ACIA is wired to stdin/stdout, so the monitor/OS/BASIC are interactive.
FPGA co-simulation flags¶
These exist so fpga/sim/run.sh can diff the emulator
against the Verilog RTL cycle for cycle. They are all off by default and change
nothing about a normal run.
-T— the canonical machine trace: one line per cycle on stderr,cyc IR stp A B T T2 P0..P5 flags. The RTL emits the identical line, so the two tracesdiffdirectly.-N— console RX always reports empty. Needed because$FF04's RDRF bit otherwise depends on what stdin happens to be: a TTY with no keystrokes reads not-ready, but a redirected or closed stdin is at EOF, whichselect()calls readable. Without-Nthe same command passes from a terminal and fails from a script.-Nalso suppresses interactive mode, so an explicit-lis honoured rather than silently dropped.-i FILE— scripted console input. RDRF is simply "the script still has a byte" and one byte is consumed per$FF05read, with no host timing in it, so the RTL can mirror the rule exactly and both models step identically.
On halt it prints PC/A/B/... register state (A=00 is the convention for "test
passed" in the self-checking suites).
Tests¶
make test # everything below
make test-quick # representative ~3 min subset: ISA, compiler core + self-host
# differential + BIOS-file library, a command pipeline, host/native
# assembler identity, disassembler twins, nested launch, glass TTY.
# After every change; the full `make test` before committing a
# compiler/microcode/assembler/OS change and before a sync.
make test-isa # per-instruction self-check (halts A=00 on success)
make test-cf # monitor format/boot against the CF model
make test-os # P8X/OS boot + shell on flat and v2 volumes
make test-basic # monitor smoke test, disk BASIC (B), SAVE/LOAD
make test-io # switch input (-s) -> $FF00 and LED writes ($FF02, -L)
make test-gfx # graphics: the GL port, the C library, the image twins
Test scripts and fixtures live in test/; their build artifacts
(*.bin, *.img, *.hex, …) are gitignored.
The graphics display¶
A 480x272 framebuffer in RGB565 direct colour — a pixel IS its colour —
with a drawing engine behind the GL/PGC graphics-language port at
$FF50–$FF57, the ONE graphics interface since the single-interface
migration. On the board the device is the Tang Nano 20K graphics card: two
framebuffer pages live in the FPGA's in-package SDRAM behind a streaming
controller (FLIP swaps them). The emulator is the golden model — the RTL
is byte-compared against it frame by frame.
(The original stage-4 device was 240x136 with four palettized pens in block
RAM; direct colour retired the palette, SETPAL and the modes in stages
5–6, and the SDRAM controller bought the full panel resolution. Until
2026-09-01 the engine was also CPU-poked directly through a register window
at $FF20–$FF2F — the "device door". That window is CLOSED: those
addresses float $FF here and on the fabric, and the register file behind
it survives only as the GL walker's internal property. Its primitives —
PIXELW, LINE, BOXFILL, PIXELR, ELLIPSE(FILL), the LINPAT latch — are
exactly what the walker issues while interpreting GL.)
The drawing engine is in the card, not in software. Software streams GL
bytes; the gpu_* functions here are the engine those bytes drive, and the
RTL must reproduce them step for step.
Inside the walker's register file, coordinates are 16-bit pairs and a low-byte write CLEARS its high partner — the rule that once protected 8-bit software from stale high bytes, now simply how the walker loads its own registers. The pairs exist because 480x272 needs 9 bits of X.
That matters for speed: a filled box is a handful of stream bytes instead of
a read-modify-write per pixel through a data port -- and bulk pixel traffic
rides the GL BLIT verb, whose payload is two wire bytes per pixel.
Two engine behaviours are load-bearing, and the GL RTL battery
(test/c_gl_rtl_test.sh) pins both down because the RTL engine has to match
them exactly:
- Endpoints are inclusive. A box from (0,0) to (479,271) paints all four extreme edges.
- Off-screen pixels are discarded, not clipped. The engine's coordinates are 16-bit, so far-off-screen values are reachable, and the address arithmetic would fold them onto the start of the next row. Discarding per pixel is the one rule that is trivially identical in C and in Verilog; a real clipper would be two implementations that have to agree. (The GL 2D primitives ALSO window-clip before the engine sees them -- this rule is about what the engine itself does with what arrives.)
p8xemu is the golden model for the FPGA, so this is the specification the
Verilog engine gets written against — including gpu_line's Bresenham, which
the RTL must reproduce step for step. The battery streams identical GL bytes
to both and byte-compares whole frames.
BUSY is real on hardware, and this model hides it¶
Software must poll GLSTAT bit 6 to see its own GL work finish. Here,
interpretation and drawing are instantaneous and busy always reads 0 — but the
RTL walker takes real clocks (a full-screen fill is milliseconds), and inside
the engine a command issued while another is running aborts it — which is
why the RTL walker polls the engine's busy between every primitive it issues,
the discipline software used to carry when the device door was open.
That asymmetry is deliberate and it is the same licence the CF model takes with
BSY, but it is worth stating plainly because of how it fails: code developed
against this model alone looks perfect here and misbehaves on the FPGA.
Because the poll costs nothing when busy is never set, one binary is correct
on both — which is exactly what makes the battery's frame comparisons
meaningful. BASIC drains through glv_dn after every drawing statement.
Other targets¶
make rom builds the persistent burnable image set into ../rom/.
Theory of operation¶
This section explains, in detail, how p8xemu.c works — and therefore how the
real P8X works, because the emulator is a direct model of the hardware rather
than a behavioral re-implementation. If you understand this file you understand
the machine.
1. The central idea: a microcode interpreter¶
The P8X is a microcoded CPU. It has no hard-wired instruction logic; instead every machine instruction is carried out by a short sequence of micro-steps, and each micro-step is one 32-bit control word read from a control store (four 28C64 EEPROMs on the real control card). The control word's bits are wired directly to the datapath's enables, selects, and load lines.
The emulator does exactly the same thing. It loads the same four ROM images
(u0.bin…u3.bin, produced by ../microcode/genucode.py)
that get burned to the hardware, and its main loop reads one control word per
iteration and applies its bits to a modeled datapath. There is no switch on
opcode that "implements ADD"; ADD happens because the microcode for opcode
$09 drives the ALU select lines to the add function across a couple of steps.
Emulator and silicon cannot drift, because they execute the identical control
store. The only things the C file hard-codes are the physical building blocks
the control word commands: the 74181 ALU, the shifter, the register file, the
bus multiplexer, memory, and the I/O devices.
2. The micro-cycle¶
One pass of the while(!halted && cycles<lim) loop = one micro-step = one
hardware clock. Each pass does four things in order, mirroring a real clocked
datapath (combinational settle, then a clock edge that latches results):
- Form the micro-address from current state and read the control word.
- Compute combinational results — the ALU, the shifter, the next flags, and the value currently on the bus — from the current register contents.
- Commit on the (modeled) clock edge — load whatever register/memory the control word selects from the bus, bump pointers, latch flags.
- Advance the micro-sequencer — pick the next micro-step (or reset to step 0 to fetch the next instruction).
3. The control store and the micro-address¶
The control store is addressed by a 13-bit micro-address built from three
fields (int ad = IR | stp<<8 | cond<<12;):
| Bits | Field | Meaning |
|---|---|---|
| 0–7 | IR |
the current opcode (instruction register) — 256 instructions |
| 8–11 | stp |
the micro-step counter, 0–15 within the instruction |
| 12 | cond |
the condition plane (A12): selects taken vs not-taken microcode |
Each ROM is 8 KB = 2¹³, so the four ROMs together supply a 32-bit word at every address. The word is reassembled little-endian across the four images:
uint32_t cw = rom[0][ad] | rom[1][ad]<<8 | rom[2][ad]<<16 | rom[3][ad]<<24;
4. The 32-bit control word¶
Every micro-step is fully described by these fields (exactly the bit positions
the C decodes, which match genucode.py's packing — the single source of truth):
| Bits | Field | Function |
|---|---|---|
| 0–3 | DOE |
bus source / output-enable — who drives the internal bus this cycle |
| 4–7 | DLD |
load destination — who latches the bus on the clock edge |
| 8–10 | PSEL |
pointer select: P0=PC, P1, P2, P3=SP, P4=PT, P5=PT2 (hidden scratch) |
| 11 | PINC |
post-increment the selected pointer |
| 12 | PDEC |
post-decrement the selected pointer |
| 13–16 | ALUS |
74181 function select S3–S0 |
| 17 | M |
74181 mode: 0 = arithmetic, 1 = logic |
| 18 | CINP |
74181 carry-in pin (active-low at the silicon) |
| 19 | SH0 |
shifter stage 1 enable (shift left) |
| 20 | SH1 |
shifter stage 2 enable (shift right) |
| 21 | LDF |
latch C/Z/N/V from the ALU+shifter result this cycle |
| 22–24 | FCOND |
condition code that selects the next cycle's condition plane |
| 25 | URST |
micro-reset: next step = 0 (i.e. the instruction retires → fetch) |
| 26 | HALT |
stop the machine |
| 27 | LDZN |
set Z and N from the bus byte (used by load/move ops) |
| 28 | SHCIN |
shift-in bit = current C (rotate-through-carry); else 0 |
| 29 | SETC |
force C = 1 (SEC) |
| 30 | CLRC |
force C = 0 (CLC) |
| 31 | BSEL |
ALU B-input mux: 0 = B register, 1 = T register |
5. The datapath and the internal bus¶
Registers modeled: accumulator A, operand B, temporaries T/T2, the
instruction register IR, and the six 16-bit pointers P[0..5]
(P0=program counter, P1/P2 general, P3=stack pointer, P4=PT hidden
scratch for absolute addressing in call/return/16-bit-move microcode,
P5=PT2 hidden scratch — the write cursor for MOVW).
Exactly one source drives the bus per cycle (DOE); exactly one destination
latches it (DLD). addr = P[PSEL] is the address presented to memory.
DOE |
bus source | DLD |
latches bus into | |
|---|---|---|---|---|
| 1 | A |
1 | A |
|
| 2 | B |
2 | B |
|
| 3 | T |
3 | T |
|
| 4 | T2 |
4 | T2 |
|
| 5 | ALU+shifter result r |
5 | flags (C/Z/N/V from bus bits 0–3) | |
| 6 | flags packed as C,Z,N,V in bits 0–3 | 6 | IR (instruction fetch) |
|
| 7 | memrd(addr) |
7 | memwr(addr, bus) |
|
| 8 | addr low byte |
8 | P[PSEL] low byte |
|
| 9 | addr high byte |
9 | P[PSEL] high byte |
|
| 0 | idle (0xFF) |
0 | nothing |
A memory→register move is thus two coordinated fields in one word: DOE=7
(read [addr]) and DLD=1 (latch into A), with PSEL choosing which pointer
addresses memory and PINC optionally walking it — that is the LDA (P1)+
primitive.
6. The ALU (74181), flags, and shifter¶
alu181() models a 74181 with active-high data. It computes the arithmetic
result for the chosen function ALUS, adds the logical carry-in
(c = !CINP, because the pin is active-low), and reports the conventional
carry-out in *cn4: C=1 means carry (after ADD) or "no borrow / A≥B" (after
SUB/CMP) — this rev-B convention is what the firmware's JC/CMP rely on. The
carry chain is computed regardless of M, so a logic op still latches the
carry the silicon would (a deliberate fidelity detail). When M=1 the function
table switches to the bitwise-logic column.
The shifter is two stages fed by the ALU result f: stage 1 (SH0) shifts
left, stage 2 (SH1) shifts right. The bit shifted out is captured; with
SHCIN the bit shifted in is the current carry (rotate through carry),
otherwise 0. The final datapath result r is what DOE=5 puts on the bus.
Flag updates at the clock edge:
- LDF → latch all four flags from this cycle's results: C = shifted-out bit
for shift ops else the ALU carry-out; Z = (r==0); N = bit 7 of r;
V = signed overflow.
- else LDZN → set only Z/N from the bus byte (so a plain load reflects
the value moved).
- SETC/CLRC force C independently (the SEC/CLC instructions).
V (signed overflow) is derived by the sign-bit method, matching the ALU
card's XOR+AND nets exactly: V = (A7 ^ F7) & (A7 ^ B7 ^ isADD), where F7 is
the raw (pre-shifter) result sign and isADD = ~ALUS2. It is computed every op
but only meaningful after ADD/SUB/CMP — which is exactly where the signed
branches are documented.
7. The micro-sequencer and conditional branching¶
After the commit, the sequencer chooses the next micro-step:
stp = URST ? 0 : (stp+1)&15;
URST ends an instruction (next address has stp=0, so the next fetch reads a
fresh opcode into IR via DLD=6). Otherwise the step counter simply advances.
Conditional execution is pipelined. The FCOND field of the word currently
in the pipeline selects the condition plane (ROM A12) used for the next
look-up — so the emulator remembers it in prev_fcond and evaluates it at the
top of the next loop:
FCOND |
condition | used by |
|---|---|---|
| 0 | false | (fall through) |
| 1 | true (unconditional) | JMP/JSR |
| 2 | C |
JC / BCP |
| 3 | Z |
JZ / BZ |
| 4 | N |
(negative) |
| 5 | V |
(overflow) |
| 6 | N ^ V |
BLT / BGE (signed <) |
| 7 | (N ^ V) \| Z |
BLE / BGT (signed ≤) |
The branch microcode places its two possible continuations on the cond=0 and
cond=1 planes; the condition simply routes the sequencer to one or the other,
so the hardware never "stalls" to decide.
8. Reset¶
P[0]=0; P[1]=P[2]=0; P[3]=0xFEFF; P[4]=P[5]=0; stp=0; IR=0;
PC is forced to $0000 (the reset vector — a JMP to the monitor cold start),
the stack pointer starts at $FEFF (top of RAM, growing down), and step 0 with
IR=0 begins the first fetch.
9. Memory map and I/O¶
0000–1FFF ROM (8K, decoded from the EEPROM image)
2000–FEFF RAM (56K: 2×62256, $2000–7FFF and $8000–FEFF)
FF00–FFFF I/O
memrd/memwr implement the decode. Writes below $2000 are refused with a
warning (you can't write ROM). The I/O page:
| Addr | R/W | Device |
|---|---|---|
$FF00 |
R | I/O-card switches — value set with -s |
$FF02 |
W | LEDs — traced with -L |
$FF04 |
R | 6850 ACIA status: bit1 TDRE (always ready), bit0 RDRF (key waiting) |
$FF05 |
R/W | ACIA data: read = next console byte, write = transmit |
$FF06 |
W | raise a maskable IRQ (models an external device, rev C) |
$FF10–$FF17 |
R/W | CF-IDE task file (only when -c attaches an image) |
10. The CompactFlash model¶
When -c disk.img is given, $FF10–$FF17 model a CompactFlash in 8-bit True
IDE mode, matching the driver in firmware/p8xmon.asm. The firmware writes the
24-bit LBA ($FF13–$FF15), sector count/head, then a command to $FF17, polls
the status register for BSY/DRQ, and streams 512 bytes through the data port
$FF10. The model implements that handshake: BSY is never asserted (the
host-side transfer is instantaneous), and DRQ is raised while a 512-byte buffer
is draining/filling and dropped when it empties. Commands handled: SET FEATURES
($EF), IDENTIFY ($EC, returns a byte-swapped model string in words 27–46
for the monitor's I command), READ SECTORS ($20), WRITE SECTORS ($30).
The image is a flat file of 512-byte sectors at LBA×512; a missing file is
created and zero-filled to 256 sectors.
11. Interrupts (rev C)¶
Three pieces model the maskable-interrupt path:
IE— interrupt-enable latch. It is not a microcode bit; it is set/cleared by an opcode decode as the instruction retires (URST):EI/RTI(IR=$02/$04) set it,DI(IR=$03) clears it. This mirrors the control card's discrete decode.irq_pending— raised when a device writes$FF06.- The forcing buffer — at fetch (
stp==0,DOE==7) withIEset and an IRQ pending, the buffer overrides the memory read and injects opcode$08onto the bus, then acknowledges the interrupt (irq_pending=0; IE=0, masking nesting). While the$08micro-routine then runs with an idleDOE, the buffer keeps driving$08, so the two pointer-load micro-steps buildP0 = $0808— the ROM interrupt vector.RTIlater re-enablesIEand returns.
12. Console / ACIA host integration¶
The 6850 ACIA is bridged to the host's stdin/stdout so the monitor, OS, and BASIC are interactive:
- Interactive (a TTY): the terminal is put in raw mode —
ICANON/ECHOoff (the firmware echoes), andICRNLoff so Enter arrives as CR, matching the hardware serial line. A one-character lookahead (peeked) lets theRDRFstatus read and the data read stay consistent. - Non-blocking RX with idle-block:
$FF04RX-ready must never block, because the same status register carriesTDRE, whichPUTCpolls before every transmitted byte — a blocking status read would freeze all output until a key is pressed. So RX-ready is non-blocking; but to keep an idle prompt from spinning the host at 100% CPU, afterRX_SPIN(4000) consecutive "no key, no output" polls it blocks for a single key. Any console output ($FF05write) resets that counter, so transmit and bulk output never block. - Batch (piped stdin): RX-ready uses
select(), reads consume the stream, and EOF exits cleanly — which is how the test scripts drive the machine.
The cycle cap (-l, default 200M) bounds batch runs; an interactive session
sets it to unlimited.
13. Fidelity summary¶
Exact: instruction semantics (same control store as hardware); the 74181 function set and conventional-carry convention; V by the card's sign-bit nets; the two-stage shifter and rotate-through-carry; pipelined conditional branching; the reset vector and stack origin; the CF True-IDE register handshake; switch and LED I/O.
Idealized (timing only, not behavior): one host loop iteration ≈ one clock,
so the cycle count is a micro-step count, not wall-clock nanoseconds; CF
transfers complete instantly (BSY never set); propagation delays, refresh, and
bus capacitance are not modeled. None of these affect the values a program
computes — they are exactly why the emulator is trusted as the reference oracle
for the firmware and the differential compiler tests.