x16emu is a riscv64 program: it builds cleanly with the Makefile's -O3 -Wall -Werror on a real RISC-V board, passes the control-API conformance suite there, emulates bit-for-bit as it does on x86, keeps up with a real X16 at the BASIC prompt and in programs that wait with WAI, and is built and booted by OxygenFlow on narvi-1 for every push.
What changed, a line a commit
4135f58cbuild: pin retro-remote-debug-controller as an oxy subrepo. The oxy tree lacked it (a nested Git checkout), so a fresh workspace failed withretro_control.h: No such file or directory. Pinned at the hub state whose files equal Git's pin7b2fd15, short of the hub's contract 0.6, which this emulator does not implement.ffa902bevideo: align the framebuffer for theuint32_twrites into it (auint8_tarray cast touint32_t *; in narvi's build it was 8-aligned by luck).59b66164build: name the revision without Git where there is no Git. Outside a Git checkout every compile printed Git's usage text; nowoxy describenames the state, else00000000. A Git checkout still gives the same hash as before.30ba236eaudio: work out the sample clock ratios once, not after every instruction (several 64-bit divisions by the run-timeMHZper instruction).74f67006main: step the MIDI card's UARTs only when there is a MIDI card.ea204ademain: test the KERNAL IEEE address range before calling the intercept (the call's prologue and epilogue were most of its cost).632655b7video: keep the beam step per clock count instead of dividing each time.b97528d2video: keep the bitmap layer's settings in registers across the line (byte stores alias everything, so every field was reloaded per pixel;x % tilewper pixel is now a counter).e9b7911amain: while the CPU waits inWAI, step the machine to its next event at once. The big one; see below.9909b2advideo: draw 8 bpp bitmap lines without the per-pixel shift and mask.94cbcffaci: build and test x16emu natively on narvi-1, OxygenHub's RISC-V runner.d9db64baci: boot the R49 ROM on narvi-1 instead of the conformance suite (the syscall tier refuses TCP sockets; see below).0aa690b5README: building and running on riscv64 Linux.c710878fci: point at oxygenflow#73 for the syscall tier's loopback sockets.4916c7e4oxyignore: thex16emuandmakecarta build leaves in the tree.
Every commit message quotes the warning or failure it fixes, or the measurement that justified it.
narvi
- Board: OrangePi RV2, Ky X1 (8 × X60 cores, rv64imafdcv, 1.6 GHz, performance governor), 7.7 GiB.
- Ubuntu 24.04.5 LTS, kernel 6.6.63-ky.
- gcc 13.3.0 (Ubuntu 13.3.0-6ubuntu2~24.04.1), SDL2 2.30.0, zlib 1.3.
- oxy 0.5.0 is installed there, so the bookmark was fetched from the hub (
oxy clonethenoxy bookmark switch riscv-1, thenoxy pull); all history was recorded on this machine. - The baseline,
makeon the unchanged tree (main plus the subrepo), passed: no compiler warnings at all, 2 min 7 s. RISC-V needed no build fixes. On purpose I looked for unsignedchar(a-funsigned-char -Wtype-limits -Wchar-subscriptssweep: nothing), unaligned access (-Wcast-align=strict: the framebuffer, fixed), endianness (src/endian.hkeys off the compiler's__BYTE_ORDER__, right for riscv64) and architecture-specific code (none).
Conformance on narvi
22/22 passed, run natively as .github/workflows/conformance.yml does on x86, with the final build: with the zero-filled ROM (1.35 s) and with the real R49 ROM (22/22).
One run in about 30 with the zero-filled ROM fails part-way: the emulator exits with "CPU program counter reached $ffff". With no ROM code, the CPU runs from $0000 in RAM, which is randomised at power-on from time(). With the seed pinned (an LD_PRELOAD time()), 30 seeds give identical states at frame 300 for the base build and this one, and seed 28 reaches $FFFF in both. So the GitHub workflow has the same latent flake; adding -zeroram to its command would remove it. Not changed here.
It also boots to READY. headless on narvi, and was run once windowed: there is no display on narvi, so under Xvfb, with SDL's X11 driver. The screen was right (the banner, then BASIC), but by its own title figure it ran at 3-4% of a real X16, with the time in Mesa's software GL (swrast_dri.so) drawing the window. SDL_RENDER_DRIVER=software and -quality nearest did not change that. A board with a working GPU driver may differ; not measured.
Correctness beyond the suite. From power-on with -zeroram -nohostfs, five BASIC workloads, each with sound on and off, are stepped to frame 900 over the control API, and the registers, low RAM plus all 64 RAM banks, and the screen are hashed. The workloads are: VERA scanline and VIA timer samples; LINEs on the 8 bpp bitmap; the same with FM and PSG notes; SLEEP in a loop; and machine code taking VIA1 Timer 1 interrupts every 16K clocks and WAIting between them. All ten are identical on x86 and riscv64, and identical before and after every commit here.
Speed
Measured over the control API: boot headless (SDL dummy drivers), settle, pause, then step 600 frames in chunks of 60 and time them. One X16 frame is 1/59.52 s, so % of a real X16 = emulated seconds / wall seconds. In real-time mode that caps at 100%, so the figure that compares builds is the process's CPU seconds per emulated second, all threads included. "idle" is the BASIC prompt; "busy" is a BASIC program drawing random LINEs on the 320×240×256 bitmap and playing FM and PSG notes without pause. The narvi and x86 base and final runs were interleaved: 3 runs each on narvi, 5 each on x86, medians given.
| base | this proposal | |
|---|---|---|
| narvi, idle, real time | 2.41 s CPU/emulated s: 42% of a real X16 | 0.76: 100% (131% flat out) |
| narvi, busy, real time | 1.95: 51% | 1.63: 61% |
narvi, idle, -warp | 50% | 310% |
narvi, busy, -warp | 85% | 98% |
| x86 (i7-6700K, gcc 15.2, SDL 2.32.10), idle | 0.629 | 0.207 |
| x86, busy | 0.577 | 0.383 |
x86, idle, -warp | 183% | 1016% |
x86, busy, -warp | 291% | 374% |
x86 did not get slower anywhere. That machine was loaded by other work (load average 7-10 on 8 threads), so its figures are noisier than narvi's, but the medians are consistent.
Where the time went on narvi (perf 7.0, cpu-clock sampling, plus subtraction runs with -sound none and -warp). At the base, the BASIC prompt was mostly the main loop going round once per clock while the 65C02 sat in WAI, stepping VIA1, VERA's beam, I2C, the RTC and audio each time: about 8 million passes a second. e9b7911a steps them by many clocks in one pass instead, but only as far as the next clock on which something could raise an interrupt: VERA to the end of the line, audio to its next render, and VIA1 a clock at a time, stopping on the clock its interrupt rises. That keeps the result bit-for-bit (the ten-run check above includes the timer-interrupt WAI loop). Devices not shown to add up that way keep the clock-by-clock loop: VIA2, the serial bus, the MIDI card, an SPI byte in flight, YM2151 IRQs, the debugger, BASIC pasting, and the KERNAL hooks.
The busy workload never waits. On narvi it now goes about 40% to VERA's rendering (render_line's compositing and palette, the bitmap and text layers), about 30% to the 65C02 core and memory decoding, about 8% to video_step, and about 8% to audio (ymfm, the PSG, mixing). To reach 100% there would need roughly another 1.6×. That means rendering at least twice as cheaply (compositing straight into the framebuffer, a line at a time, without the col_line round trip; per-byte text and tile rendering), a cheaper 65C02 dispatch, and stepping the devices by events rather than per instruction while the CPU runs too. That is the same idea as the WAI change, but with the CPU running it is a larger rework, and I did not attempt it tonight.
Tried and not kept.
- I2C stepped once per instruction, on its own: −0.8% idle, +1.6% busy, a wash. It went in with the
WAIchange, where it matters. -falign-functions=64 -falign-loops=16 -falign-jumps=16in a riscv64 Makefile block: 7% faster at the prompt on one tree, 2-3% slower on the final one. On the X60, where hot code lands moves run time by several per cent: one unrelated change once slowed the prompt by 7% on its own. So every figure here is a median, and the layout-sensitive comparisons were rechecked aligned both ways.
No RISC-V assembly or intrinsics; every change is portable C and helps x86 as well.
The pipeline (.flow/)
.flow/pipeline.kdl runs on narvi-1 (runs-on "riscv64", the syscall tier):
- build:
make -j6, the binary kept as artifactx16emu-linux-riscv64with the receipt. - boot: takes that binary, boots the R49 ROM headless, and has BASIC print
SUM 333833500.
Runs: proposal #1 build passed, conformance failed (below); #2 and #3 passed (build 124 s and 106 s, boot 6 s): https://oxygenhub.io/andymccall/x16-emulator/flow/3
The image is Ubuntu Base 24.04.5 riscv64 (checked against its SHA-256) plus build-essential, make, libsdl2-dev, zlib1g-dev, python3 and python3-pytest by apt in a chroot, and the R49 rom.bin from the emulator's R49 release (zip and ROM checked against their SHA-256; it is the same file as the repository's). It is built by .flow/image/build-riscv64.sh on narvi (no Docker on the board) and imported into narvi-1's Flow home as cas://blob/127605ea…c157. It has not been flow rootfs pushed to the hub, so only narvi-1 holds it; if its CAS is cleared, rerun the script and re-import. Importing the same tarball into a second Flow home gave a different hash, so a re-import may need a re-pin. An earlier import of the first image (a1405289…) is still in narvi-1's CAS, unused, if you want the space back.
The conformance suite could not be the second job. The syscall tier brings up lo for network "loopback", but its seccomp filter refuses every socket family except AF_UNIX, so the suite's HTTP to 127.0.0.1 fails with EAFNOSUPPORT and every test skips (run proposal #1). Filed as oxygenflow#73. Python and pytest are already in the image for when that is lifted.
For the enhanced fork (~/development/enhanced-x16-emulator), a later night
- The
WAIfast-forward, and the per-instruction costs before it (audio's run-timeMHZdivisions, the MIDI step without a card, the IEEE intercept call, the beam's double divisions, the per-pixel%and alias reloads in the bitmap layer), are all in code it shares; the same changes should apply. Its/perfAPI will show them directly. via_step()'s Timer 1 inaccuracy, below, is shared too.- Its CMake build will hit the same code-placement noise on the X60: measure in medians.
Found and left alone
via_step()Timer 1: a multi-clock step that ends exactly on the timer's −1 state consumes 1 clock where it should consumecnt + 1, so the timer runscntclocks ahead. Rare (only when an instruction's clocks land exactly there), the same on every architecture, and kept bit-for-bit as it is.- HostFS ties emulated time to the host: when a KERNAL call is serviced by HostFS, the CPU's clock is advanced by the wall time it took (
main.c, "missed_ticks"). So any run that touches HostFS (BASIC's AUTOBOOT probe at power-on does) is not reproducible, even on one machine.-nohostfsmakes it so. - A paused emulator ignores SIGTERM: its main loop sits in the control API's pause, so the SDL quit event is never handled.
SDL_VIDEODRIVER=waylandwithout a compositor (set in narvi's session) makesSDL_Initfail; noted in the README.
Not done
- Busy programs are at 61% of a real X16 on narvi, not 100%; what it would take is above.
- No GitHub Actions job for riscv64 (the optional item): the OxygenFlow pipeline on a real board covers it, and I did not add a QEMU job I had not seen pass.
- The conformance suite does not run in CI on RISC-V until oxygenflow#73 is resolved.
flow runon narvi stopped part-way through copying the tree, with a bareNo such file or directory, before any job ran; I tested the pipeline by pushing instead. Not investigated.