0% LU

PlayFan.

Florent M.
Bordeaux, FR
...

Fiche technique

Publication27 août 2026
Sujets abordésIA / Reverse engineering / Jeux vidéo
XEmail
Retour à l'index

J'ai porté Megabonk sur iPhone en reverse engineering, avec Claude

Ce n'est pas un projet, c'est une expérience de six jours qui m'a retourné le cerveau. 🕹️ Un jeu Unity compilé en IL2CPP, sans une ligne de code source, qui tourne à 120 FPS sur mon iPhone avec des contrôles tactiles. Je trouve ça fou qu'on puisse faire ça aujourd'hui, et je vous raconte comment.

Avant toute chose, posons le cadre, parce qu'il compte. Megabonk est un survivor-like sorti sur Steam en 2025, que j'adore et auquel je joue beaucoup trop. Ce que je raconte ici est une expérience strictement personnelle : le dépôt est privé, le build n'a jamais quitté mon téléphone, aucun asset du jeu n'a été ni ne sera redistribué, et il n'y aura jamais de TestFlight. En droit français, la décompilation n'est autorisée que pour l'interopérabilité (article L122-6-1 IV du Code de la propriété intellectuelle), et un portage n'en est pas une. Je le sais, c'est écrit en tête de mon README, et c'est une consigne « non négociable » dans les instructions que je donne à l'agent : ne jamais proposer de publier, de pousser sur un remote public, ni de partager quoi que ce soit issu de l'extraction.

Ce que je voulais savoir, c'est autre chose : jusqu'où peut-on aller aujourd'hui avec un agent IA sur une tâche colossale, répétitive et exigeante, du genre qu'un humain seul ne fait tout simplement pas. Et accessoirement, jouer à mon jeu préféré dans le train. La réponse m'a sidéré.

Le point de départ : il n'y a plus de code

Megabonk est un jeu Unity compilé en IL2CPP. Pour ceux qui ne connaissent pas : le C# du jeu a d'abord été transpilé en C++, puis compilé en code machine. Les noms de classes, les signatures de méthodes, les champs et leurs types survivent dans un fichier de métadonnées. Mais les corps de méthodes n'existent plus. Il n'y a rien à « décompiler » au sens habituel : la logique est dans 55 Mo de code machine x86-64, et il faut la relire en pseudo-assembleur pour la réécrire.

Ce n'est donc pas un portage. C'est une réimplémentation, guidée par une spécification qu'il faut d'abord reconstruire.

Jour 1 : la reconnaissance

Le 14 août, première étape : savoir à quoi on a affaire. En parsant directement le fichier de métadonnées, on obtient les chiffres : 14 421 types dans le binaire, 73 assemblies, dont 1 221 dans le code du jeu lui-même. Une fois retirés les types générés par le compilateur, les types imbriqués et les bibliothèques tierces embarquées, il reste environ 930 classes de jeu à réécrire.

Trois bonnes nouvelles d'entrée. Les métadonnées sont dans un format standard, non chiffrées et non obfusquées : les classes s'appellent BossPoison, ChestTypeToEncounter ou ProjectileBlackHole, pas a1 et b2. Aucun plugin audio natif, tout passe par le moteur. Et le namespace des objets contient 85 classes d'implémentations d'items et 22 capacités passives : plus d'une centaine de classes de contenu répétitif, parfaitement parallélisables.

L'extraction : cinq minutes au lieu d'un week-end

Deuxième étape, les assets. J'avais prévu un week-end. AssetRipper, lancé en mode headless et piloté en HTTP par quelques curl, a tout sorti en cinq minutes : 5 scènes, 414 prefabs, 750 meshes, 1 158 textures, 311 clips audio, 578 matériaux, 31 Mo d'animations, et surtout 1 093 ScriptableObjects d'équilibrage, en clair.

C'est le premier des deux faits qui gouvernent tout le reste. Les dégâts, les courbes d'XP, les tables de loot, les améliorations d'armes, tout est lisible directement. Pour reprendre ma note de l'époque : des mois de game design qu'il n'y a pas à deviner. L'amélioration d'une hache se lit en quatre lignes de YAML.

Quatre voies vers du C#, trois échouent

Restait la logique. Mon plan annonçait du pseudo-C# lisible dans ILSpy. Ce n'est pas ce qu'on obtient. J'ai essayé les quatre voies, dans l'ordre :

  • Les DLL reconstruites par AssetRipper : corps de méthodes vides, { }.
  • Le même outil en mode « Level 3 » : identique, les niveaux ne concernent pas les corps.
  • Cpp2IL en mode récupération de CIL : corps throw null. La reconstruction est expérimentale et ne produit rien.
  • Cpp2IL en sortie ISIL : ✅ 100 % des méthodes, sous forme de pseudo-assembleur.

La conclusion tient en une phrase : la reconstruction de code compilable depuis de l'assembleur x86-64 ne fonctionne pas en 2026. Ce qui fonctionne, c'est l'ISIL, un pseudo-assembleur indépendant du jeu d'instructions, avec trois propriétés qui le rendent exploitable : les appels sont résolus par nom (Call Time.get_deltaTime), les types aussi, et le flux de contrôle est explicite. Ce qui manque, ce sont les champs ([rcx+44]) et les constantes ([0x18262EDE0]).

D'où l'annotateur maison, un script Python écrit avec Claude, qui croise trois sources : le dump ISIL, un second dump qui donne l'offset de chaque champ, et le binaire lui-même lu comme un fichier PE pour aller chercher la valeur des constantes à leur adresse. Résultat : 40 133 annotations sur 1 111 classes, et 57 % du volume retiré (prologues, épilogues, bruit du runtime). Une méthode de dix lignes d'assembleur annoté se retranscrit alors en deux Mathf.Lerp, avec les constantes de tuning d'origine, 7 et 12, extraites du binaire. Ce n'est pas du copier-coller, c'est de la lecture et de la traduction, mais c'est faisable.

Et la méthode de travail par classe est devenue une règle : croiser trois sources, jamais une seule. Le stub AssetRipper pour la structure exacte, l'ISIL annoté pour la logique, le ScriptableObject ou le prefab pour les valeurs réelles.

La découverte qui a tout changé

Le volume réel, mesuré sur l'ISIL : 965 classes portant du code, 7 384 méthodes, 487 157 instructions. Mon premier plan était classique : ne porter que les classes dont toutes les dépendances sont déjà portées, en partant de la périphérie.

Sauf que le graphe de dépendances déduit des stubs comptait 1 905 arêtes. Le graphe d'appels réel, reconstruit depuis l'ISIL, en compte 5 016. Et en cherchant les composantes fortement connexes, la réponse est tombée : 418 classes, soit 317 693 instructions et 65 % du code, forment un seul cycle mutuellement dépendant. GameManager dépend de 21 classes qui dépendent de GameManager. Aucune de ces classes n'est jamais portable seule.

J'ai simulé le retrait des quatorze classes les plus centrales, celles qu'on isolerait derrière des interfaces : la grappe passe de 418 à 321. Le couplage est diffus, pas concentré. C'est un code de jeu écrit vite, où chaque classe appelle librement les singletons des autres, ce qui est banal et parfaitement fonctionnel, mais interdit toute décomposition.

Renversement complet de stratégie : importer les 993 stubs vides dans Unity dès le premier jour, installer les packages, et faire compiler le projet intégralement, inerte mais entier. Puis remplacer un stub à la fois, dans n'importe quel ordre. Le projet ne cesse jamais de compiler. Le corollaire désagréable : rien n'est testable avant qu'une masse critique du cœur soit portée. Un PlayerMovement juste, entouré de stubs, ne bouge pas.

Mesurer, ne pas regarder l'image

Le 15 août au matin, la boucle minimale tournait : le joueur bouge, l'arme cible et tire, l'ennemi encaisse et meurt, une gemme tombe, l'XP monte, le niveau monte. Seize secondes de jeu observées, zéro exception.

Mais « observées » comment ? Pas à l'écran. Par des sondes Unity en mode batch : des scripts d'éditeur qui entrent en mode Play sans interface, laissent tourner, journalisent une vingtaine de mesures (position du joueur, horloge, ennemis avec mesh et matériau, projectiles, pools, XP) et ne capturent une image qu'en dernier recours. Trois conclusions fausses ont été évitées ainsi : « aucun ennemi » (il y en avait onze, hors champ), « les ennemis n'ont pas de mesh » (objet enterré neuf mètres sous le sol), « l'arme ne tire pas » (l'attaque était créée, c'est la cible qui manquait).

Et toutes les pannes de cette phase ont été silencieuses. Le code du jeu fait X.Instance.Machin() sans vérification, fidèlement à l'original où la scène garantit le composant. Manager absent, l'exception tombe chez l'appelant, et le message ne désigne jamais le coupable. Le pire cas : AlwaysManager, le seul endroit du binaire qui appelle les neuf initialisations statiques du jeu. Sans lui, la table d'XP restait nulle, l'exception interrompait le calcul avant la remise à zéro, et le même gain se réinjectait à chaque image. Résultat mesuré : 156 427 points d'XP pour sept gemmes, joueur niveau 0, et 33 808 exceptions dont aucune ne nommait AlwaysManager.

Les sondes mentent aussi

Ça, c'est le fil rouge du projet, et ce que je retiens le plus. Après « mesurer, ne pas regarder l'image », la phase suivante a ajouté « vérifier la référence, ne pas la croire » (deux entrées de mon propre catalogue de helpers étaient fausses, dont un arrondi lu comme une troncature qui recalibrait tout le jeu sur 59 Hz au lieu de 60). Et la phase d'après : la sonde ment aussi, et de façon crédible.

J'ai fini par cataloguer cinq pièges de sonde. Une qui listait les objets inactifs, donc le remplissage des prefabs. Une qui mesurait l'écran de mort 7 ms après la mort alors que le fondu dure 3 secondes. Une qui n'avait pas été relancée sur une scène, laissant passer 2 876 exceptions pendant un lot entier. Une regex ^[A-Za-z]+Exception au lieu de ^[A-Za-z]*Exception, qui laissait passer les lignes Exception: nues : trente exceptions comptées comme zéro, et un lot déclaré propre.

Et le cinquième, mon préféré : elle explore d'une façon qu'aucun humain n'emploie. La sonde du flottement testait des caps orthogonaux (avant, droite, arrière, gauche). Or le code fautif teste ses deux axes séparément : un cap orthogonal laisse toujours un axe à zéro, et la condition ne peut jamais s'armer. Un joueur au clavier tient Z+D. La scène était la bonne, les objets actifs, on était bien en Play, et le code fautif n'était jamais atteint.

Le résultat

Le 16 août, le jeu tournait sur mon iPhone 17 Pro à 120 FPS stables. Menu, sélection de carte, partie générée, interface complète, boss final, portail, écran de fin. Les défis, la boutique avec ses vrais prix, les personnages, les chapeaux, les sauvegardes vérifiées par un vrai redémarrage. 660 classes passaient IL2CPP en ARM64 sans une erreur au premier build.

À la fin : 167 commits en cinq jours, 1 000 classes réécrites sur 1 033, soit 452 174 instructions traduites, environ 153 000 lignes de C#. Trente classes non portées par décision (Steam, Discord, les classements), trois partielles. J'ai ajouté une couche tactile complète (stick flottant, zone de regard, six boutons, retour haptique de combat) qui n'existe pas dans l'original et qui est le seul endroit du projet où tout est un choix de conception, et j'ai réécrit à la main onze shaders que l'extraction avait vidés : terrain triplanaire, herbe, eau, ciel, minimap, écran de mort, dissolution.

Le périmètre a été arrêté noir sur blanc : base fidèle, aucun contenu ajouté. Pas de nouveaux objets ni d'ennemis, même si les tomes et les ennemis sont 100 % pilotés par les données et que ce serait facile. Les quatre emplacements vides trouvés dans les enums sont des trous du jeu d'origine, pas du travail en attente. Et un bug d'origine a été conservé volontairement : le boss final est créé avec une combinaison de drapeaux qui ne correspond à aucune des trois branches censées le traiter. C'est bien ce que fait le binaire. Porté tel quel, à ne pas « corriger » sans mesurer.

Comment Claude a été utilisé

Trois choses ont fait la différence, et aucune n'est « demander à l'IA d'écrire le jeu ».

Des agents en parallèle. Les commits le disent tels quels : « Une arme, cinq ennemis : quatre agents en parallèle », « 74 classes portées en cinq lots parallèles », « 116 classes en dix lots ». Deux salves de cinq agents, chacun sur un lot de classes indépendantes, avec les mêmes trois sources et les mêmes règles.

Des prompts de session versionnés. Chaque nouvelle session démarre par un fichier à copier-coller, avec un bloc « PREMIÈRE ACTION, IMPÉRATIVE » : les sondes à lancer, les résultats attendus, les exceptions connues à ne pas « corriger », les trois matériaux que le mode Play salit et qu'il faut restaurer. Et une honnêteté que j'ai appris à exiger dans les deux sens. Dans le prompt de la session tactile : « Je n'ai jamais lancé cette sonde ; son résultat m'est inconnu. Commence par là. » Dans celui du projet jumeau : « Installe sur mon iPhone quand c'est prêt, ne te contente pas de compiler » et « Dis-moi explicitement ce que tu n'as pas pu vérifier. »

Un carnet de guerre. Le fichier d'instructions du projet fait 1 907 lignes. Ce n'est pas une doc. Chaque section commence par un piège, souvent en majuscules et précédé d'un 🔴, raconte l'erreur, son coût mesuré, et le balayage automatisable qui l'aurait trouvée. « UN MonoBehaviour DANS UN FICHIER QUI NE PORTE PAS SON NOM N'EXISTE PAS POUR UNITY. » « UNE CLASSE COCHÉE "PORTÉE" PEUT AVOIR CINQ CORPS INVENTÉS. » « UN "AVANT" QUI N'EST PAS MESURÉ N'EST PAS UN AVANT. » Et une convention rare : code et commentaires en français, les commentaires expliquent pourquoi, pas quoi.

Les anecdotes que je garde

Le boss s'appelait « Baco ». L'annotateur rendait une instruction test exactement comme un cmp. Sur un enum de flags, le contresens est invisible à la relecture : (axes & Horizontal) != 0 devient axes == Horizontal, ce qui inverse le sens de chaque axe. En cascade, la largeur maximale d'un texte valait 61,7 au lieu de 1 000, et le boss s'affichait « Baco » au lieu de « Baconator le Terrible », cinq glyphes sur vingt et un. Le vrai coupable n'était pas l'assembleur : c'était un commentaire de onze lignes, en tête de la classe, que j'avais validé, et qui affirmait que « toutes les instances » utilisaient la même valeur. Vingt-huit ne le faisaient pas, dont les six champs de nom de boss. La leçon, textuellement : un quantificateur (« toutes », « aucune ») dans une remarque de code est une mesure, pas une intuition. Écrit sans mesure, il transforme un doute en certitude, et la remarque protégeait le bug qu'elle décrivait. Le balayage systématique du même motif a ensuite trouvé un second cas plus cher, où la logique d'une capacité passive était exactement inversée, avec une branche entière morte.

Le personnage flottait au-dessus de la carte. C'étaient deux bugs sans rapport, trouvés à huit heures d'intervalle, et corriger le premier n'a rien changé à l'écran, ce qui m'a fait croire que le diagnostic était faux. Le premier : un saut conditionnel non signé (jbe) lu comme signé (jg), avec la constante en premier opérande, qui coupait la gravité quand le joueur court en diagonale, le cas le plus courant du jeu. Le second : le modèle suivait le centre de la capsule au lieu des pieds, exactement colliderHeight / 2 = 1,925 m plus haut. Deux champs voisins, tous deux plausibles, seul l'offset tranchait. Et ce qui a débloqué la situation, c'est une phrase de moi : « purement visuel, il se déplace, ramasse et encaisse normalement ». Elle élimine la physique en une ligne et désigne le rendu. Aucune sonde ne la produisait. Il a fallu la demander.

« L'IA ennemie utilise le NavMesh. » Faux. C'était une déduction tirée de la seule présence du package, propagée dans quatre documents avant d'être démentie en portant le déplacement des ennemis. Vérification sur quatre sources : zéro agent de navigation dans tout le jeu. Les ennemis foncent en ligne droite et escaladent les obstacles au raycast : un mur est touché, la vitesse verticale passe à +8, et l'ennemi grimpe. C'est la horde qui escalade les rochers, visuellement caractéristique du jeu, et bien moins cher qu'un NavMesh. Leçon : la présence d'un package ne dit rien de son usage.

Les textes « définitivement perdus ». Trois documents l'affirmaient, sur une observation exacte (les tables extraites étaient vides) et une conclusion fausse. Les bundles d'origine étaient intacts et se lisaient en cinq lignes de Python : 19 157 textes, 867 tables, 17 langues rendus au jeu. D'où l'entrée du carnet : « Irrécupérable » veut souvent dire « mauvaise source consultée ».

Gamma contre linéaire. Le projet Unity était en espace Gamma alors que l'original est en Linear. Ce n'était pas une décision, c'était le défaut d'Unity que personne n'avait changé. Mais basculer seul est une régression spectaculaire : tout le terrain du menu est passé du vert au gris-brun, arbres et touffes restant verts, parce que neuf textures de données (bruits, rampes de fondu, occlusion) étaient marquées sRGB. Le drapeau est inerte en gamma et décisif en linéaire : la bascule ne casse rien, elle révèle. Et le kicker : ma métrique de « platitude » disait l'inverse de l'œil. L'écart type baissait alors que l'image gagnait en profondeur. Devant un désaccord entre la métrique et l'image, regarder l'image, puis trouver la métrique qui décrit ce qu'on voit au lieu de défendre celle qu'on avait.

Deux achats prévus, deux achats évités. Rewired (~45 $) pour l'input : abandonné, 4 400 des 7 500 instructions concernées étaient du remapping clavier sans objet sur tactile. MK Toon (~40 $), le shader commercial derrière 67 % des matériaux, présenté au jour 1 comme « la très bonne nouvelle » qui désamorçait le poste le plus sous-estimé : remplacé le 18 août par un cel-shading écrit à la main. Et l'aveu qui suit, en majuscules dans le carnet : l'achat n'aurait pas apporté ce qu'on croyait. Le rendu de secours était déjà très proche, ces matériaux étant des textures plates sur du low-poly. La fiche annonçait des « gobelins couleur chair » pour vendre la transformation ; c'était faux, ils étaient déjà verts.

Le cel-shading a été livré trois fois. Un commit « Un cel-shading écrit à la main remplace MK Toon : 379 matériaux ». Puis « Le cel-shading n'était pas dans le build : un git restore trop large l'avait annulé ». Le geste réflexe de nettoyage après les sondes, un joker sur tout le dossier des matériaux, avait rendu les 379 rebranchements à leur état précédent, et j'ai regardé un build rigoureusement identique au précédent. Rien n'aurait pu le signaler : la compilation, les sondes et l'audit avaient tous tourné avant le commit. Puis un troisième commit : « Le cel-shading n'était pas la transformation annoncée : mesure du vrai avant/après ». Le dernier du dépôt.

Le filet de compilation est mort deux fois. Un outil censé compiler les classes portées hors Unity, pour éviter d'ouvrir l'éditeur. D'abord limité (déposer les stubs hors Unity donne 3 866 erreurs, TextMeshProUGUI et Transform en tête), puis carrément aveugle : au-delà de 290 erreurs de déclaration, Roslyn s'arrête avant la liaison des corps, et une ligne int x = "ceci n'est pas un entier"; produisait zéro message. Mesuré par sonde, indépendamment par deux agents et par moi. Le seul juge du typage est Unity.

Et Megabonki, le projet bonus

En parallèle, du 13 au 17 août, j'ai fait l'inverse : Megabonki, un survivor-like écrit from scratch en Swift et Metal, sans moteur ni dépendance, pour iPhone, iPad et Mac. Rien n'est extrait du jeu original ; seules quelques formules ont été reprises « du modèle », comme la formule de stats (base + plats) × (1 + additifs) × (multiplicatifs), les critiques cumulables au-delà de 100 % (×2, ×4, ×6,25, ×9…) et le principe du terrain en trois surfaces : un sol horizontal _, un sol incliné /, et ce qui les sépare est un mur vertical |. Il n'existe pas d'autre cas.

Le parti pris : aucun fichier d'asset. Toute la géométrie est générée en code, et les 34 bruitages en 64 variantes de hauteur plus les 4 couches musicales sont synthétisés au lancement en 70 ms. 74 fichiers Swift, 21 000 lignes, 270 lignes de Metal. Trois choix d'architecture tiennent le reste : un draw call par forme plutôt que par objet (un personnage est un assemblage de boîtes, un arbre un cylindre et deux sphères, une dizaine de draw calls quel que soit le nombre d'ennemis), une grille spatiale reconstruite à chaque frame par tri par comptage (en O(n²), 400 ennemis font 160 000 paires et le jeu s'effondre), et les morts retirés seulement en fin de frame, sinon un indice distribué frappe quelqu'un d'autre. Aucune UV n'est stockée : la texture est projetée depuis la position monde sur le plan de la normale dominante, et la profondeur est inversée.

Il a ses propres cicatrices. Un banc rulescheck compile la logique pure hors moteur graphique pour vérifier qu'un critique cumulé vaut bien ×6,25 en une seconde plutôt qu'en jouant six minutes. L'option -mute écrivait dans le volume de musique, dont le setter persiste : chaque lancement de test laissait l'appareil muet définitivement, et le symptôme « je n'ai plus de son » ne pointait vers rien. Le menu de pause était conditionné à l'existence des réglages audio, donc mettre en pause figeait le jeu sans afficher le moindre menu. Le mesh du terrain a été réécrit après six correctifs empilés qui n'avaient jamais fermé tous les cas, et la version finale tient en une règle : comparer des niveaux entiers, parce que les entiers ne mentent pas. Ma méthode de diagnostic préférée : colorer le ciel en rouge vif. Une zone suspecte qui vire au rouge est un trou dans le mesh ; une zone qui garde sa teinte est une surface mal éclairée. Deux hypothèses éliminées en une capture.

Et la direction artistique a changé en cours de route : la première identité était « parchemin, bois et fer », jusqu'au 14 août où j'ai admis qu'elle tirait le jeu vers le livre d'heures quand la référence assumée est le survivor-like rétro. La police retenue vient d'un jeu de stratégie des années 90 qui n'écrivait qu'en capitales : elle possède É À Ç Ô mais pas é è ç ô. D'où une règle typographique entière, titres en capitales, texte courant en chasse fixe système, construite autour d'accents manquants.

Ce que ça dit de 2026

Il y a deux ans, ce genre de chose représentait des mois de travail pour une équipe motivée. Là, c'est un développeur web, six jours, et un agent qui accepte de lire 487 000 instructions de pseudo-assembleur sans se plaindre, et surtout qui accepte qu'on lui impose de mesurer avant d'affirmer.

Ce qui m'a le plus marqué, ce n'est pas la vitesse. C'est que les erreurs les plus coûteuses étaient les miennes, ou celles de la méthode : un commentaire trop sûr de lui, une déduction prise pour une observation et recopiée dans quatre documents, un « je n'ai pas regardé » qui devient un « ça marche », un git restore réflexe. L'agent, lui, a été aussi bon que les règles qu'on lui a données, et il a été meilleur chaque fois qu'une règle a été écrite après un piège.

Ça ne sera jamais publié, et c'est très bien comme ça. Mais je n'oublierai pas la première fois où le boss final est apparu sur mon téléphone. Même s'il s'appelait « Baco ».