Skip to content
Phosphoricwiki
Contents

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:

FolderContents
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:

WorkstreamVersionWhat changed
A, hygiene2.1.3a single build system
B, reliable build2.2.0objects outside the sources, one folder per configuration
C, main() split2.3.0from 2,043 to 32 lines
D, peripheral contract2.4.0ticks and presence in io_device_t, explicit order
E, quality and security2.8.0 to 2.9.xservers on 127.0.0.1 by default, fuzzed HTTP parsing, sanitisers in CI
F, monoliths split2.12.0control.c from 1,352 to 261 lines, debugger.c from 2,395 to 637
G, cards as modules2.13.0 to 2.19.0one 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.

Sources in the repository : README.md, docs/architecture/AUDIT-ARCHITECTURE.md, docs/architecture/io-bus.md, docs/architecture/master-clock.md, docs/adr/README.md.