L'architecture du code
Un émulateur grossit par sa périphérie : chaque carte, chaque option, chaque sortie ajoute du code. Voici comment Phosphoric est découpé, et comment il a été remis en ordre sans changer son comportement.
Dans cette page
Le dépôt en un coup d’œil
Phosphoric est écrit en C11, environ 45 000 lignes dans src/. Le cœur est découpé en
couches sans dépendance circulaire :
| Dossier | Contenu |
|---|---|
src/cpu/ | le 6502 : les deux moteurs, le micro-séquenceur, le calcul commun |
src/memory/ | la mémoire, les ROM, la surcouche du Microdisc |
src/video/ | l’ULA, l’ULA-NG, le rendu, le menu F1, les exports d’image |
src/audio/ | le PSG, le mixage, les sources des cartes |
src/io/ | le VIA, le clavier, la cassette, les puces série, le LOCI, le bus d’entrées-sorties |
src/storage/ | les contrôleurs de disquette, les formats .dsk, Sedoric |
src/cards/ | une carte d’extension par fichier |
src/network/ | l’API HTTP, le serveur GDB, la diffusion Chromecast |
src/cli/ | l’analyse de la ligne de commande et l’aide |
racine de src/ | l’horloge maître, le débogueur, le protocole de pilotage, les sauvegardes d’état, main.c |
Le Makefile est le seul système de construction (décision 0001). Les objets sont construits
hors des sources, un dossier par configuration (build/<config>/ : SDL2, HTTPAPI, CAST, MIDI, TLS,
TUI, LOCI, DEBUG, COVERAGE…) : changer d’options ne mélange jamais des objets.
Un audit, puis six chantiers
En juillet 2026, un audit d’architecture mesuré (graphe des inclusions, découpage de main.c,
ordre des ticks, fils d’exécution) a conclu : le cœur est proprement modularisé, mais la
périphérie s’entasse dans un main.c de 4 690 lignes, dont 1 487 pour la seule fonction
main(). La dette est périphérique, pas centrale : elle se règle par extraction, sans toucher au
moteur.
Le plan qui a suivi, mené de la 2.1 à la 2.19, a enchaîné des chantiers :
| Chantier | Version | Ce qui a changé |
|---|---|---|
| A, hygiène | 2.1.3 | un seul système de construction |
| B, construction fiable | 2.2.0 | objets hors des sources, un dossier par configuration |
C, main() découpée | 2.3.0 | de 2 043 à 32 lignes |
| D, contrat des périphériques | 2.4.0 | ticks et présence dans io_device_t, ordre explicite |
| E, qualité et sécurité | 2.8.0 à 2.9.x | serveurs sur 127.0.0.1 par défaut, analyse HTTP fuzzée, assainisseurs en CI |
| F, monolithes découpés | 2.12.0 | control.c de 1 352 à 261 lignes, debugger.c de 2 395 à 637 |
| G, cartes en modules | 2.13.0 à 2.19.0 | une carte = un fichier et une ligne |
Chaque déplacement a été prouvé sans effet par cli_golden : 116 lignes de commande rejouées sur
l’ancien et le nouveau binaire, zéro écart.
main() en 32 lignes
cli_parse_args() remplit une structure cli_opts_t, puis 14 étapes nommées main_setup_*
montent la machine : le processus, phosphoric.cfg, la machine, les entrées et l’imprimante, les
cartes série, les enregistrements, le LOCI, les outils de débogage, les captures, la ROM et le modèle,
la cassette, les disquettes et la synthèse vocale, les services, les traces. emulator_run() fait
tourner la machine, main_finish() écrit les sorties. Chaque étape renvoie −1 pour continuer, ou un
code de sortie.
La boucle d’une trame
emulator_run() déroule chaque trame en étapes nommées, dans un ordre fixe : les instructions de
la trame (avec débogueur, trace, profileur, corrections de la cassette), les crochets du LOCI, le son
sans affichage, les commandes externes, le chargement rapide, la frappe automatique, la présentation
et les événements (menu F1, affichage à l’écran, clavier, souris), les captures datées, la vidéo, les
captures conditionnelles, les POKE injectés, la cadence (limiteur à 50 Hz ou --realtime), et les
conditions de sortie.
Avant la 2.0.3, tout cela tenait dans une seule fonction de 1 300 lignes. Chaque étape est désormais
une fonction static de 150 lignes au plus, qui partage un run_state_t.
Les commandes viennent d’une seule file
Le processeur, le VIA et la vidéo tournent dans un seul fil d’exécution, une trame après
l’autre. Or des commandes arrivent d’ailleurs : --control sur l’entrée standard, l’API HTTP depuis
son propre fil, le menu F1. Exécutées au milieu d’une instruction, elles corrompraient l’état.
D’où la décision 0002 : un seul interpréteur, control_dispatch(), et une file que la boucle de
l’émulateur vide une fois par trame, à la frontière de trame. Une commande HTTP attend donc au
plus 20 ms plus son exécution. À l’arrêt, la file est fermée avant le serveur, pour libérer les
producteurs en attente.
Le contrat des périphériques et les cartes
Chaque périphérique du bus est une structure io_device_t : est-ce à moi ?, lecture, écriture
(qui peut décliner vers le VIA), lecture sans effet de bord, section de sauvegarde, tick. Chaque
carte est un module de src/cards/, listé une fois dans include/cards_list.h. Le détail, avec le
schéma, est dans La page 3 et les cartes.
Deux règles de performance : l’ordre des ticks est une boucle déroulée (une boucle générique coûtait 22 % par trame), et les ticks des cartes sont des appels directs générés depuis la liste (une table de pointeurs coûtait 5 % d’instructions).
Changer de cartes : relancer
Une carte se branche au démarrage : sa ROM, sa surcouche, sa place sur le bus, son transport ouvert,
et pour le LOCI en co-simulation, des fils d’exécution et un firmware en marche. Les rebrancher dans
le même processus réutiliserait des états globaux. Décision 0004 : « Appliquer et redémarrer » dans
le menu F1 remplace le processus (execv) par l’émulateur relancé avec la même ligne de
commande, moins les options de cartes, plus celles des cartes choisies. C’est un vrai démarrage à
froid, et le menu le dit.
Deux langues, un seul code
Le projet est écrit en français : commentaires, documentation, messages de commit. Il est publié aussi
pour un public anglophone. Décision 0005 : deux branches miroir. main (française) est publiée sur
Framagit et sur la forge du projet ; main-en est main plus des commits de traduction, publiée en main sur GitHub
et Codeberg. Seuls les commentaires et la documentation diffèrent : le code, les chaînes affichées et
les données sont identiques, ce que vérifie tools/check_comment_only_diff.py.
Les décisions sont résumées dans Les décisions.