Articles

Article · · 16 min de lecture

J’ai donné à un agent IA des disquettes Amiga. Au bout d’une soirée, Pirates! tournait sous Unity

J’ai confié à un agent de programmation quatre images de disquettes de la version Amiga de Sid Meier's Pirates!, sortie en 1990, et je lui ai demandé de porter le jeu sous Unity. Le travail a été fait par Claude Opus 5.5 dans Claude Code. Au bout d’une soirée, on pouvait jouer de l’écran-titre jusqu’à une bataille navale, en passant par un duel, une ville et la navigation sur la carte, avec les graphismes et le son d’origine. Plus important que le rythme : la façon dont le résultat a été vérifié. Le même scénario de décisions a été exécuté sur le portage en C# et sur le code machine d’origine, lancé dans un émulateur de processeur. Sur 802 exécutions, soit environ 6 millions de lignes comparées, les deux versions ont montré la même chose au joueur.

Je décris ici les étapes successives de ce travail, parce qu’elles forment une méthode que l’on peut reproduire avec un autre jeu et un autre modèle. À la fin, je présente un second projet mené selon le même schéma : le portage de Centurion: Defender of Rome de DOS vers JavaScript. Ce sont deux projets privés, destinés à apprendre : les jeux d’origine, leurs graphismes et leur son restent protégés par le droit d’auteur, et je ne les diffuse pas.

L’écran-titre de Pirates! dans le portage Unity : un galion battant pavillon pirate au coucher du soleil, dessiné avec les graphismes d’origine de 1990.

L’écran-titre sous Unity. L’image est décodée à l’exécution à partir des fichiers d’origine du jeu.

De l’image de disquette au code décompilé

L’agent a commencé par identifier les fichiers. Une image de disquette Amiga (ADF) est une copie brute des secteurs ; quelques dizaines de lignes de Python ont donc suffi pour en extraire le contenu. Le programme du jeu s’est révélé être un exécutable à recouvrement (overlay) : un noyau reste en mémoire en permanence, et trois autres segments de code sont chargés depuis le disque au fur et à mesure des besoins. Ghidra, un décompilateur gratuit, ne prend pas ce format en charge nativement. L’agent a écrit un script qui place tous les segments à des adresses fixes dans une seule image et transforme 45 sauts indirects vers les overlays en appels ordinaires. Le décompilateur a ainsi vu environ 630 fonctions comme un seul programme.

Les données du jeu réservaient une surprise. Les fichiers portant l’extension .rez ont la structure des ressources Macintosh, car la version Amiga était un portage depuis le Mac. Une fois le format reconnu, l’agent en a tiré 210 textes, 82 listes de chaînes, les données des villes pour six époques, des images, des animations, des sprites et des sons. Les textes se sont révélés être la meilleure carte du code : le numéro de la chaîne passée à la routine « afficher le texte » indique quel écran du jeu une fonction donnée gère.

Treize agents lisent le code, neuf le réécrivent

Le code décompilé comptait près de 29 000 lignes, sans aucun nom de fonction ni de variable. L’agent principal l’a découpé en morceaux d’environ 2 000 lignes et a confié chacun à un agent distinct, avec le même brief écrit. Chacun devait consigner dans un fichier la description de toutes les fonctions de son morceau : formules exactes, constantes, plages de tirage aléatoire, numéros des textes affichés, variables globales lues et écrites. Le brief contenait une phrase qui s’est révélée importante : une formule fausse est pire qu’une lacune signalée. Les agents devaient donc indiquer ce qu’ils n’avaient pas pu établir et vérifier les points obscurs dans l’assembleur. Le décompilateur perdait ici beaucoup d’arguments de fonctions, parce que le compilateur, vieux de 35 ans, utilisait un type int de 16 bits.

La deuxième étape reprenait le même découpage. Neuf agents ont réécrit les règles du jeu en C#, chacun pour sa plage d’adresses. Il en est sorti environ 16 000 lignes de code, et l’état du jeu a été placé dans un tableau d’octets adressé exactement comme la mémoire de l’original. Ce n’est pas très élégant, mais cela élimine toute une classe d’erreurs. L’ancien code superpose librement des structures les unes sur les autres et lit des tableaux à partir de l’octet qui précède leur début. Un portage bâti sur une image mémoire peut être traduit ligne à ligne et comparé à l’assembleur, et les tables de constantes restent là où le code les cherche. Le dessin, le son et les commandes ont été remplacés par les agents par des appels à une interface que le moteur implémente séparément.

L’oracle : le code d’origine dans un émulateur de processeur

Lire du code décompilé ne prouve pas que le portage se comporte comme l’original. L’agent a donc construit un outil de comparaison. Le code machine d’origine, écrit pour le processeur Motorola 68000, tourne dans l’émulateur Unicorn. Les routines système, c’est-à-dire les menus, les fenêtres de texte, la lecture des commandes et le dessin, ont été remplacées par des fonctions Python. Ces fonctions répondent à chaque question du jeu selon un générateur de décisions très simple et enregistrent tout ce que le jeu a affiché. Le portage en C# reçoit la même suite de décisions par l’intermédiaire d’un « joueur scripté » qui fonctionne en dehors d’Unity. Pour finir, les deux enregistrements sont comparés ligne à ligne. Les textes contiennent l’or, l’effectif de l’équipage, les dates et les noms des villes : la concordance des enregistrements constitue donc un test solide des règles.

La première mesure complète a donné 800 exécutions concordantes sur 802. Les deux écarts avaient une seule et même cause : l’arithmétique en virgule flottante. Le jeu s’appuie sur une bibliothèque logicielle qui n’arrondit pas comme les processeurs d’aujourd’hui. Sa racine carrée de 81 vaut 8,999999999999998, et le jeu tronque les résultats en entiers ; le moral de l’équipage sortait donc un point trop haut dans le portage pour certains rapports entre l’or et l’équipage. Le correctif a consisté à réécrire les routines de la bibliothèque instruction par instruction : 1 572 instructions, 43 routines et 21 701 vecteurs de test calculés par le code d’origine, sans la moindre différence. Depuis ce changement, les 802 exécutions concordent toutes, et dans 120 exécutions supplémentaires l’état du jeu a été comparé image par image pendant le duel, la navigation et les batailles navale et terrestre.

La comparaison a aussi mis au jour deux bugs du jeu de 1990 lui-même : une lecture de la carte à travers un pointeur nul lorsqu’on attaque une ville en diagonale, et une division par zéro quand l’équipage tombe à zéro. À ces endroits, le portage doit choisir un comportement, et l’outil de comparaison dispose d’un commutateur qui force l’original à emprunter le même chemin. Le résultat a aussi ses limites. L’outil ne compare ni le dessin ni le son, et les chemins rares, comme le mariage, la carte au trésor complète ou les grades les plus élevés, n’ont été que peu exercés.

La présentation et des ajouts qui n’existaient pas en 1990

La couche visible par le joueur, les agents l’ont construite sous Unity à partir du code, sans scènes toutes faites. Les images et les animations sont décodées à partir des ressources d’origine, les sprites prennent leurs couleurs dans la palette de l’écran en cours, et la musique a été rendue dans des fichiers audio en émulant le séquenceur à quatre canaux du jeu. L’agent pilotait le jeu en cours d’exécution depuis la ligne de commande, prenait des captures d’écran et les regardait. C’est ainsi que sont apparus plusieurs défauts qu’aucun test des règles ne détecte, par exemple l’absence de son dans la version navigateur : la conversion audio nécessitait le programme ffmpeg, qui n’était pas installé sur le système.

La carte des Caraïbes dans le portage Unity : des îles avec leurs villes, le navire du joueur avec son indicateur de cap et des nuages d’orage.

La navigation sur la carte. Le fond est construit à partir d’une grille de terrain de 160 cases sur 200 et des tuiles d’origine.

Après mes premiers essais du jeu, j’ai demandé trois modifications. La question sur la date d’arrivée de la flotte au trésor servait, dans l’original, de protection contre la copie et exigeait le manuel papier ; désormais le quiz reste, mais la bonne réponse apparaît dans les « notes du capitaine ». La liste des villes n’affichait que les données d’une ville, parce que le jeu supposait que le joueur disposait d’une carte imprimée ; désormais la ville choisie est indiquée sur une carte des Caraïbes. L’écran de duel a reçu un tableau donnant la force, le moral et l’arme des deux camps, disposé comme dans l’original. Ces ajouts peuvent être désactivés, et les tests de comparaison tournent sans eux, afin que les enregistrements continuent de concorder avec le code de 1990.

Capture d’écran du jeu d’origine sur Amiga : deux escrimeurs devant un mur, avec en dessous le titre du duel et un tableau Force, Morale, Weapon.

Le duel dans l’original. Source de la capture : OpenRetro Game Database.

Le duel dans le portage Unity : des escrimeurs sur le pont d’un navire, avec en dessous le titre et un tableau donnant la force, le moral et l’arme des deux camps.

Le même écran dans le portage. Les chiffres du tableau sont lus en direct dans l’état du duel.

Une carte des Caraïbes où sont indiqués la ville de Port-de-Paix, la position du joueur et un encadré contenant les données de la ville et le drapeau français.

Un ajout par rapport à l’original : la ville choisie sur une carte des Caraïbes.

Centurion : le même schéma en JavaScript, avec un gameplay étendu

Le second projet est Centurion: Defender of Rome, un jeu DOS de 1990, porté vers JavaScript et l’élément Canvas, sans émulateur. Les étapes étaient les mêmes, sous d’autres noms. Il a d’abord fallu décompresser l’exécutable, car il était compressé avec l’outil EXEPACK. Cinq modules du jeu (bataille terrestre, arène des gladiateurs, course de chars, bataille navale et introduction) se trouvaient dans un fichier d’overlays et partageaient un seul segment de données. L’état de la campagne fonctionne sur une copie vivante des 64 kilo-octets de ce segment, car le moteur du jeu repose sur le dépassement sur 16 bits, la division entière et les octets signés, que les nombres à virgule flottante de JavaScript ne reproduisent pas d’eux-mêmes. La sauvegarde est conforme au format d’origine à l’octet près, ce que les tests vérifient.

L’écran-titre de Centurion dans le portage navigateur : l’inscription CENTURION au-dessus d’une carte de la Méditerranée, un panneau de crédits et les boutons Start Game, Difficulty et Game Options.

L’écran-titre de Centurion dans le navigateur, avec les graphismes d’origine.

Ici, je suis allé plus loin qu’un portage fidèle. J’ai ajouté un commutateur de règles étendues, désactivé par défaut : fusion de légions et plusieurs légions dans une même province, vélites (infanterie légère), éléphants de guerre et débarquement depuis la mer. Le code de ce portage a été écrit par d’autres modèles que celui de Pirates : surtout Gemini 3.8 Flash, et en partie gpt-6.1-sol d’OpenAI, lancé via Codex. Ce choix tenait aux outils que j’avais sous la main, pas à une comparaison de modèles.

Le menu des options du jeu dans le portage de Centurion : l’entrée Extra Features: OFF, le niveau de difficulté, la sauvegarde et le chargement, et à côté le mandat du Sénat avec le grade et l’état du trésor.

Le menu des options. La première entrée active les règles étendues, absentes de l’original.

L’écran de la province Italia dans le portage de Centurion : une vue de la ville, le menu des jeux avec la construction de l’amphithéâtre, et l’état de la province : humeur, tribut, troupes.

L’écran de province avec le menu des jeux.

Les problèmes sur lesquels Gemini est tombé avec Centurion

L’essentiel du temps est parti dans les endroits où le code décompilé ou les noms de fichiers suggéraient autre chose que ce que le jeu fait réellement. Cinq exemples tirés des notes du projet :

  • Des overlays sans données. Les cinq modules du jeu se trouvent dans un seul fichier, OVL0.OVL, et sont chargés à la même adresse, mais tous utilisent un segment de données commun. Le code d’un overlay ouvert seul dans Ghidra ne montrait donc ni chaînes ni tables. Un script a assemblé chaque overlay avec le segment de données pour obtenir cinq exécutables synthétiques, et c’est seulement alors que le décompilateur a relié le code des mini-jeux aux structures de la campagne.
  • Les frontières des provinces conquises. Tracer le contour d’une province d’après la carte des zones cliquables à la souris donnait des lignes anguleuses, qui ne collaient pas à la carte dessinée à la main. Le code a révélé un autre mécanisme : dans l’image de la carte, chaque province possède un tracé caché, large d’un pixel et de couleur sombre, et une table dans les données fournit un point de départ pour chacune des 27 provinces. Le jeu repeint ce tracé aux couleurs de l’empire en partant du point de départ, et le portage fait de même.
  • Des images qui reculent. La boucle de jeu d’origine tournait à 9–12 Hz, alors qu’un navigateur dessine 60 fois par seconde. À trois endroits, un sprite reculait un instant d’une image. Le globe de l’introduction tourne par décalage de 14 couleurs de la palette en 10 pas, mais le premier pas de la table vaut zéro ; décaler la palette à chaque pas provoquait un à-coup des continents à chaque tour. Pour la marche des cohortes et les fentes des gladiateurs, il a fallu reproduire exactement l’ordre : d’abord l’image intermédiaire, ensuite le changement de position.
  • Des noms de fichiers trompeurs. Les bitmaps BB1.CBM et BB2.CBM ressemblaient à des bannières de bataille, et, dessinées en haut de l’écran, elles ont recouvert tout le champ de bataille. Le code ne les charge que si l’ordinateur n’a pas de carte VGA : ce sont des fonds plein écran destinés aux cartes plus anciennes, EGA et CGA. La même routine a révélé que le déploiement avant une bataille comporte deux étapes. Le choix d’une formation remplace la liste des tactiques disponibles, et la manœuvre de Cannes n’apparaît qu’une fois la formation en coin choisie.
  • Une musique à peine audible. Après le décodage de 17 morceaux et de 24 instruments du pilote de la carte son AdLib, les mélodies étaient justes, mais jouées à environ −55 dB. Le désassemblage du pilote a montré qu’à chaque note il soustrait un quart de sa vélocité de l’atténuation enregistrée dans l’instrument. Une ligne de code a rendu environ 32 dB. Un second défaut se situait du côté du navigateur : sur les notes très courtes, l’annulation des changements de volume planifiés dans la Web Audio API effaçait l’attaque du son encore en cours et rendait la note muette.

Une bataille terrestre dans le portage de Centurion : une liste de cinq tactiques pour la formation en coin, dont Cannae Tactic, et des unités déployées sur le champ de bataille.

La seconde étape du déploiement avant une bataille : les tactiques disponibles pour la formation en coin.

Les courses de chars du Circus Maximus ont été un chantier à part. Un char n’y est pas une image unique. Des bitmaps distincts pour chaque combinaison de trois classes de chars, quatre couleurs d’auriges, quatre actions, seize inclinaisons en virage et trois images de galop auraient représenté plus de 2 300 images ; les auteurs du jeu ont donc construit en mémoire un arbre de nœuds de 16 octets. Chaque nœud pointe vers l’élément suivant du même niveau et vers son premier enfant, et contient un indice d’image, une table de décalages pour les images successives de l’animation et un indicateur de visibilité. La racine, ce sont les quatre chevaux. La caisse du char possède un décalage horizontal supplémentaire, dans lequel la physique du virage inscrit une inclinaison de 0 à 15 pixels, et l’aurige est son enfant et hérite de cette position. C’est pourquoi, dans une courbe, l’aurige penche avec la caisse tandis que les chevaux continuent de courir dans l’axe de la piste. Les branches de l’essieu qui casse et de l’épave qui culbute existent en permanence ; leur indicateur de visibilité est simplement désactivé.

Un schéma de l’arbre de sprites du char dans Centurion : un nœud avec quatre chevaux, le nœud de la caisse en trois classes, les nœuds des états d’accident, quatre animations de l’aurige, et le char assemblé sans inclinaison puis avec une inclinaison de 12 pixels.

L’arbre de sprites du char reconstitué à partir des données du jeu. Le schéma a été réalisé dans le projet à partir des ressources d’origine décodées.

La piste non plus n’est pas faite de tuiles. Le jeu développe 72 paires de coordonnées enregistrées en 368 points de contrôle et divise la position en travers de la piste en 16 couloirs. Du couloir dépend la vitesse sûre en virage, calculée comme (numéro du couloir + 15) × 4 : elle vaut 60 le long du mur intérieur et 116–120 contre la barrière extérieure. Le couloir intérieur offre le chemin le plus court, mais aborder l’épingle au-dessus de ce seuil incline la caisse, pousse le char vers l’extérieur et déclenche un tirage au sort de l’accident, d’autant plus probable que le dépassement est grand. Le repère rouge sur la barre de vitesse indique ce seuil au joueur.

La géométrie de la piste du Circus Maximus : lignes droites, zones de freinage et épingles indiquées en couleur sur l’image du stade, et un graphique de la vitesse sûre qui passe de 60 dans le couloir intérieur à 120 dans le couloir extérieur.

Les 368 points de la piste et la vitesse sûre en virage selon le couloir.

Ce qui reste de ce travail, en plus des jeux

J’ai consigné la méthode sous la forme d’un skill, c’est-à-dire un ensemble d’instructions que l’agent charge lorsqu’il reçoit une tâche semblable. Il contient trois principes issus des deux projets. Les règles du jeu sont portées fidèlement, et la présentation est remplacée par une interface. Le portage conserve l’organisation mémoire de l’original. On ne fait pas confiance au résultat tant qu’il n’a pas passé la comparaison avec le code d’origine. Le skill comprend aussi les briefs destinés aux agents d’analyse et de portage, ainsi que les scripts du projet Pirates.

La conclusion la plus intéressante concerne les fans, pas les agents. Celui qui aime un jeu et à qui il manque quelque chose depuis des années peut aujourd’hui le refaire à sa façon, vite et à peu de frais, sans le code source. Dans Pirates, le quiz qui exigeait le manuel imprimé donne désormais la réponse, et une carte que le jeu n’avait jamais eue est apparue. Dans Centurion, j’ai ajouté des règles que l’original n’avait pas : fusion de légions, éléphants de guerre et débarquements depuis la mer. Une fois que les règles du jeu sont du code ordinaire en C# ou en JavaScript, le changement suivant est une modification de code ordinaire, et non un patch du binaire. On peut de la même façon porter un jeu vers un moteur plus rapide ou une autre plateforme : Centurion tourne dans un navigateur, et Pirates se compile pour Linux et pour le navigateur. La méthode n’est pas non plus liée aux vieux jeux. Un décompilateur, un émulateur de processeur et la comparaison des journaux fonctionnent de la même manière pour un programme plus récent ; plus de code signifie plus de travail, pas une autre approche.

Sources

  • Mes propres documents issus des deux projets : notes d’analyse du code, briefs pour les agents, rapport de l’outil de comparaison (802 exécutions, liste des différences trouvées), description du portage de Centurion et ses spécifications. Ils ne sont pas publiés.
  • National Security Agency, Ghidra, la suite de rétro-ingénierie utilisée pour décompiler les deux jeux.
  • Unicorn Engine, l’émulateur de processeurs utilisé pour exécuter le code 68000 d’origine.
  • OpenRetro Game Database, la page de Pirates! sur Amiga, source de la capture d’écran du jeu d’origine.

J’utilise des contenus générés par IA dans le cadre de mon processus d’apprentissage quotidien.