The architecture of the code
An emulator grows at its edges: every card, every option, every output adds code. Here is how Phosphoric is split up, and how it was put back in order without changing its behaviour.
On this page
The repository at a glance
Phosphoric is written in C11, about 45,000 lines in src/. The core is split into layers with
no circular dependency:
| Folder | Contents |
|---|---|
src/cpu/ | the 6502: both engines, the micro-sequencer, the shared computation |
src/memory/ | memory, ROMs, the Microdisc overlay |
src/video/ | the ULA, the ULA-NG, rendering, the F1 menu, picture exports |
src/audio/ | the PSG, mixing, the cards’ sound sources |
src/io/ | the VIA, keyboard, tape, serial chips, the LOCI, the I/O bus |
src/storage/ | floppy controllers, .dsk formats, Sedoric |
src/cards/ | one expansion card per file |
src/network/ | the HTTP API, the GDB server, Chromecast streaming |
src/cli/ | command-line parsing and help |
root of src/ | the master clock, the debugger, the control protocol, save states, main.c |
The Makefile is the only build system (decision 0001). Objects are built outside the sources,
one folder per configuration (build/<config>/: SDL2, HTTPAPI, CAST, MIDI, TLS, TUI, LOCI, DEBUG,
COVERAGE…): changing options never mixes objects.
An audit, then several workstreams
In July 2026, a measured architecture audit (include graph, breakdown of main.c, tick order,
threads) concluded: the core is cleanly modular, but the periphery piles up in a 4,690-line
main.c, 1,487 of them for the main() function alone. The debt is peripheral, not central: it is
paid by extraction, without touching the engine.
The plan that followed, carried out from 2.1 to 2.19, chained workstreams:
| Workstream | Version | What changed |
|---|---|---|
| A, hygiene | 2.1.3 | a single build system |
| B, reliable build | 2.2.0 | objects outside the sources, one folder per configuration |
C, main() split | 2.3.0 | from 2,043 to 32 lines |
| D, peripheral contract | 2.4.0 | ticks and presence in io_device_t, explicit order |
| E, quality and security | 2.8.0 to 2.9.x | servers on 127.0.0.1 by default, fuzzed HTTP parsing, sanitisers in CI |
| F, monoliths split | 2.12.0 | control.c from 1,352 to 261 lines, debugger.c from 2,395 to 637 |
| G, cards as modules | 2.13.0 to 2.19.0 | one card = one file and one line |
Each move was proved harmless by cli_golden: 116 command lines replayed on the old and the new
binary, zero difference.
main() in 32 lines
cli_parse_args() fills a cli_opts_t structure, then 14 named steps main_setup_* set up the
machine: the process, phosphoric.cfg, the machine, inputs and printer, serial cards, recordings, the
LOCI, debugging front-ends, captures, ROM and model, tape, floppies and speech, services, traces.
emulator_run() runs the machine, main_finish() writes the outputs. Each step returns −1 to
continue, or an exit code.
The loop of a frame
emulator_run() runs each frame as named steps, in a fixed order: the frame’s instructions (with
debugger, trace, profiler, tape patches), the LOCI hooks, sound with no display, external commands,
fast loading, automatic typing, presentation and events (F1 menu, on-screen display, keyboard,
mouse), timed screenshots, video, conditional screenshots, injected POKEs, pacing (50 Hz limiter or
--realtime), and the exit conditions.
Before 2.0.3, all this fitted in a single 1,300-line function. Each step is now a static function of
at most 150 lines, sharing a run_state_t.
Commands come from a single queue
The processor, the VIA and the video run in a single thread, one frame after another. Yet commands
arrive from elsewhere: --control on standard input, the HTTP API from its own thread, the F1 menu.
Run in the middle of an instruction, they would corrupt the state.
Hence decision 0002: a single interpreter, control_dispatch(), and a queue that the emulator
loop drains once per frame, at the frame boundary. An HTTP command therefore waits at most 20 ms
plus its execution. On shutdown, the queue is closed before the server, to release waiting producers.
The peripheral contract and the cards
Each bus peripheral is an io_device_t structure: is this mine?, read, write (which can decline to
the VIA), side-effect-free read, save section, tick. Each card is a module in src/cards/, listed
once in include/cards_list.h. The details, with the diagram, are in Page 3 and the
cards.
Two performance rules: the tick order is an unrolled loop (a generic loop cost 22% per frame), and the cards’ ticks are direct calls generated from the list (a table of pointers cost 5% of instructions).
Changing cards: relaunching
A card plugs in at start-up: its ROM, its overlay, its place on the bus, its transport opened, and for
the co-simulated LOCI, threads and a running firmware. Plugging them again in the same process would
reuse global states. Decision 0004: “Apply and restart” in the F1 menu replaces the process
(execv) with the emulator relaunched with the same command line, minus the card options, plus those
of the chosen cards. It is a true cold start, and the menu says so.
Two languages, one code
The project is written in French: comments, documentation, commit messages. It is also published for
an English-speaking audience. Decision 0005: two mirror branches. main (French) is published on
Framagit and on the project’s forge; main-en is main plus translation commits, published as main
on GitHub and Codeberg. Only comments and documentation differ: the code, the displayed strings and
the data are identical, which tools/check_comment_only_diff.py checks.
The decisions are summed up in The decisions.