Tachibana

Astéroric › Articles › Porter Asteroids sur un Oric-1 : ce qui a résisté

Porter Asteroids sur un Oric-1 : ce qui a résisté

Quarante phases pour faire tenir un jeu vectoriel de 1979 dans 20 000 cycles par trame. Ce qui a cédé, ce qui a résisté, et ce que l'émulateur nous cachait.

Par bmarty · Astéroric 1.0.0-beta · 14 septembre 2026

Asteroids tourne sur une borne à écran vectoriel : un faisceau qui trace des segments, pas de pixels, pas de mémoire d'image. L'Oric-1 est l'inverse exact — un bitmap de 240 × 200 points, un 6502 à 1 MHz, 48 Ko dont une partie occupée par l'écran, et une puce sonore à trois canaux carrés. Le projet Astéroric est un clone d'étude : reprendre la logique de l'arcade telle qu'elle est écrite dans la ROM rev 4 (même processeur, ça aide) et la faire tenir sur cette machine. Cet article raconte ce qui a résisté. Les numéros de phase renvoient au CHANGELOG du dépôt, qui fait foi.

1. Le budget qui ne tenait pas

Le cadrage initial posait l'arithmétique : à 1 MHz et 50 trames par seconde, on dispose de 20 000 cycles par trame. Une routine de Bresenham 6502 correcte mais sans code auto-modifiant coûte 15 à 22 cycles par pixel ; avec 30 segments de 20 pixels, tracés puis effacés, on dépasse le budget avant d'avoir calculé quoi que ce soit. Le document listait trois leviers : accepter 25 Hz, réduire la complexité des formes, ou investir lourdement dans la routine de ligne.

La mesure de la phase 2 a tranché : ~97 cycles par pixel, cinq fois l'hypothèse. Le 25 Hz a été acté immédiatement — comme cible nominale, pas comme dégradation, et c'est la cadence de beaucoup de jeux Oric. Le code auto-modifiant de la phase 2b (opérandes dx/dy patchés en immédiats, sens du pas Y patché à l'entrée) n'a rapporté que 4 cycles par pixel : loin des 50 visés. La vraie économie est venue plus tard, et d'ailleurs.

2. Dessiner des vecteurs sur un bitmap

Tout est tracé en XOR : dessiner un segment une seconde fois l'efface, sans sauvegarder le fond. C'est élégant et ça a un défaut immédiat, découvert en phase 9g : un pixel partagé par deux segments — la pointe du vaisseau, chaque sommet d'un polygone fermé — est XORé deux fois, donc effacé. Tous les sommets des astéroïdes étaient invisibles. Le premier correctif a été un replot explicite des sommets partagés (trois XOR = pixel allumé). Le bon correctif est arrivé en phase 24 : un Bresenham semi-ouvert qui trace ]P0, P1], tous les pixels sauf le départ. Sur un polygone parcouru en cycle, chaque sommet est alors XORé exactement une fois. Coût net nul — un pixel de moins par segment paie le test d'entrée.

Le gain de performance décisif n'est pas venu de la routine de ligne mais de l'idée de ne plus l'appeler. Les astéroïdes ne tournent jamais : leurs douze silhouettes (quatre formes × trois tailles) sont rasterisées à la compilation par un script Python qui reproduit le Bresenham de line.s, puis émises en bitmaps HIRES pré-décalés dans leurs six phases intra-octet (3 402 octets de données). Le rendu devient un XOR-blit octet par octet, ~23 cycles par octet non nul. Détail qui compte : 240 étant divisible par 6, l'instance fantôme du wraparound (±240 px) garde la même phase — un seul jeu de bitmaps. La décimation des formes à 6-7 sommets, imposée en phase 18h pour tenir la cadence, a pu être supprimée : les creux fins des formes Atari sont revenus, puisque le coût ne dépend plus du nombre de sommets.

Une surprise au profileur, au passage : sizeof(Asteroid) = 13. N'étant pas une puissance de deux, chaque accès asteroids[i].champ forçait cc65 à appeler la multiplication logicielle mul8x167,1 % du temps CPU. Itérer par pointeur l'a fait disparaître du profil. Au total, les phases 24 à 26 ont fait passer le temps CPU libre de 17,9 % à 39,8 %.

3. Le temps : le VSync qui n'existe pas

La phase 9 synchronisait la boucle sur la broche CB1 du VIA, censée recevoir le signal VSync de l'ULA. Le jeu tournait parfaitement sous Phosphoric. Puis Phosphoric 1.16.11 s'est aligné sur le matériel, et le jeu s'est mis à boucler indéfiniment sur l'écran titre : sur un Oric réel, CB1 n'est pas câblée au VSync. L'émulateur pulsait la broche par commodité et masquait l'erreur. Le remplaçant est le Timer 1 du VIA en libre-cours à 20 ms — portable sur matériel réel, Phosphoric, Oricutron et MAME, au prix d'un tearing théorique jamais perçu.

Restait un bug de cadence plus subtil, présent depuis la phase 18 sous le nom de « 17 Hz effectifs en scène chargée ». frame_wait() échantillonnait le compteur à son entrée, puis attendait deux ticks : tant que le travail tenait dans le premier tick, la grille du timer donnait 40 ms par trame ; dès qu'une trame dépassait 20 ms, elle repartait pour deux ticks pleins — 60 ms au lieu de 40. La phase 28 a installé un ordonnanceur à pas fixe avec cible persistante (frame_target += 2), rattrapage sur les trames suivantes et resynchronisation après un gel long. Le 50 Hz adaptatif a été étudié et rejeté : toute la physique est en pixels par trame, une quinzaine de familles de temporisateurs comptent en trames, et l'UFO avance de ±1 pixel — passer à 50 Hz par moments doublerait la vitesse du jeu sauf à tout rescaler. Un 25 Hz constant et fiable vaut mieux qu'un 50 Hz intermittent et risqué.

4. Le scintillement

À 25 Hz, un objet effacé en début de trame et redessiné en fin de trame est absent de l'écran pendant ~80 % du temps : l'œil voit un cycle présent/absent à la pire fréquence possible. Le correctif a été structurel — resserrer erase → tick → draw par entité et sortir tout ce qui n'est pas du rendu (son, score, textes) hors de cette fenêtre. Pour les astéroïdes, la fenêtre est passée d'environ 20 ms à 7 ms.

Les torpilles ont montré une variante plus vicieuse, révélée par un testeur sur matériel réel (« fire beam hardly visible »). L'ancien pipeline les effaçait en début de boucle et les redessinait en fin : absentes de la mémoire vidéo pendant presque tout le calcul. Sur matériel réel, la phase entre le balayage CRT et le Timer 1 dérive, donc elles apparaissaient par intermittence ; sous Phosphoric, la phase est verrouillée et on obtenait 0 capture sur 20 avec une torpille visible — alors que la RAM la disait active. Le schéma commit (effacer à la position du dernier tracé, redessiner juste après, état écran découplé de l'état logique) a donné 8 captures sur 8. Les torpilles sont aussi passées en 2 × 2 px.

5. La ROM qu'on contourne

Le clavier est lu sans le tampon de la ROM, directement par la matrice — ce qui suppose d'avoir compris la matrice. Première version fausse : le code testait des colonnes différentes en gardant un masque R14 fixe. Sur le matériel, SPACE et les quatre flèches sont toutes sur la colonne 4 et se distinguent par leur ligne, sélectionnée via le registre 14 du PSG. Seul SHIFT droit produisait un faux positif. Deuxième piège, propre au 6522 : lire ORB renvoie ce qu'on a écrit sur les bits configurés en sortie, pas l'état des broches. Si la ROM a laissé DDRB = $FF, PB3 relit sa propre valeur et le scan devient aléatoire — d'où des tirs ratés et des rotations incohérentes. key_scan force désormais PB3 en entrée le temps du scan.

Troisième piège, entendu avant d'être compris : pour piloter le port A du PSG en mode matrice, key_scan écrivait R7 = $7F — « mixer tout muet » — à chaque trame. Les six bits de tonalité et de bruit étaient coupés une fois par trame et restaient coupés jusqu'au tick sonore pair suivant : un hachage à 25 Hz de tous les effets, surtout audible sur les longs. C'était le « bug audio de l'explosion du vaisseau » du backlog. Le correctif réécrit la dernière valeur réelle du mixeur, conservée dans un mixer_shadow.

Le player sonore sous interruption (phase 20) a exigé de savoir ce que la ROM fait avant de sauter au vecteur utilisateur : rien. Le 6502 n'empile que PC et P ; la sauvegarde de A, X et Y se fait à l'intérieur du handler ROM, qu'on court-circuite en patchant le vecteur. Sans PHA/TXA/TYA maison, le triple PLA final dépile P et l'adresse de retour, et le RTI part vers n'importe où. Il faut aussi désactiver toutes les autres sources d'IRQ du VIA avant d'armer T1, sinon un résidu CA1/CB1/T2 de la ROM déclenche un handler qui ne l'acquitte pas — boucle infinie. Validation sous Phosphoric avec --trace-irq : 493 entrées, 493 RTI, 19 930 à 19 970 cycles entre deux IRQ.

Le handler est accroché aux deux vecteurs, $0228 (Oric-1) et $0244 (Atmos). Cela n'a pas suffi : avec la ROM Atmos, le jeu ne fonctionne pas, sous Phosphoric comme sous Oricutron. La compatibilité Atmos reste un sprint à part, et le site le dit.

6. La BSS, deux fois

Le C suppose qu'une variable statique vaut zéro au démarrage ; c'est le crt0 qui doit le garantir. Phase 9b : avec une BSS de plus de $83 octets, l'écran HIRES ne s'activait plus — le zerobss maison différait subtilement de celui de cc65, et la solution a été d'adopter la routine officielle via initlib. Phase 36, un mois plus tard : un bloc fantôme apparaît à la position (85, 85) au premier tir. 85, c'est $55, le motif de la RAM non initialisée. Le commentaire « initlib appelle zerobss » était faux — cc65 les appelle séparément — et la BSS n'était en fait toujours pas nettoyée. Deux revues de code n'avaient pas relu un commentaire.

7. Le son d'une autre machine

La borne de 1979 produit ses sons avec des oscillateurs analogiques discrets ; il n'y a rien à porter. Le point de départ a été un autre 8 bits : Mine Storm, le clone d'Asteroids de la Vectrex (1982). Processeur différent (6809), mais même puce AY-3-8912 et même VIA 6522 — le code ne se transpose pas, les valeurs de registres si. Les tables de SS.THR, SS.BLT, SS.EXP ont servi de première approximation.

La suite s'est faite à l'oreille et au spectre : analyse FFT d'enregistrements de la borne. Les trois explosions (petit, moyen, gros astéroïde) ont des durées similaires et ne diffèrent que par la fréquence du bruit — ~167, 246 et 306 Hz, soit R6 = 12, 8 et 6. Le tir est passé d'un bruit grave à ~740 Hz. L'architecture a suivi la borne : trois circuits indépendants deviennent trois canaux dédiés — A pour les effets, B pour le thump-thump jamais interrompu par une explosion, C pour la soucoupe — avec un mixeur recalculé depuis l'état des trois. Le jingle de titre (Grieg, domaine public) a été inaudible pendant trois phases : la routine rangeait l'index de note dans sound_tmp, puis appelait psg_write… qui utilise sound_tmp comme brouillon. La note relue était la dernière valeur écrite au PSG. Un scratch dédié a suffi, placé en BSS parce que la page zéro était pleine à l'octet près.

8. L'émulateur qui ment, le matériel qui répond

Astéroric a été développé sous Phosphoric, et les deux projets se sont corrigés l'un l'autre. L'émulateur a masqué le VSync inexistant (§3), puis un modèle IJK erroné : la première lecture du joystick passait par le registre 14 du PSG, comme Phosphoric le simulait, et ne fonctionnait pas sur l'interface physique d'un testeur. Le vrai protocole passe par le port imprimante — port A du VIA en direct, strobe PB4, bit 5 de présence — et a été validé contre Oricutron ; Phosphoric a été corrigé en parallèle, avec un rejeu de la séquence 6502 exacte du jeu dans ses tests.

Le matériel a aussi révélé ce qu'aucun émulateur ne pouvait montrer. L'attribut vidéo $1C écrit à l'initialisation sélectionne HIRES à 60 Hz ; le bit 1 sélectionne 50 Hz, et il faut $1E. Le contenu de la mémoire vidéo est identique dans les deux cas, donc la capture d'écran est identique — mais le RGB2HDMI du testeur ne verrouillait pas le signal. Sans conséquence sur la vitesse du jeu, cadencée par le Timer 1 et non par le balayage. En sens inverse, l'émulateur a fourni les instruments : captures headless comparées bit à bit (make check), profil de 25 millions de cycles, trace des IRQ, dumps de RAM symbolisés, scénarios scriptés --type-keys. Avec un piège : un appui scripté dure 40 ms, exactement une trame à 25 Hz, et peut tomber systématiquement entre deux scans clavier en exécution déterministe.

9. Ce qui reste

Sources & liens

← Astéroric · Fonctionnalités →