Suivre le format binaire d'un MMO propriétaire à travers ses patches
.bin au format positionnel : pas de balises, pas de noms de champs, juste une suite d'octets lue dans un ordre fixe par un lecteur écrit à la main. Le problème, c'est que ce client est obfusqué par renommage automatique des symboles : à chaque re-génération, les noms de classes et de méthodes changent, une classe qui s'appelait p la semaine dernière s'appelle epH cette semaine, et rien ne garantit qu'elle porte encore le même rôle.Le jeu se met à jour régulièrement, et à chaque patch, une poignée de structures binaires dérive : un champ ajouté, un autre supprimé, l'ordre changé. Si le lecteur externe ne suit pas, il continue à décoder, mais il décale tout, silencieusement, à partir du premier octet mal interprété.
J'ai construit un outil pour arrêter de refaire ce travail à la main à chaque patch : détecter la dérive, retrouver le bon lecteur dans le client décompilé sans jamais s'appuyer sur un nom obfusqué (puisqu'il ne survit pas au patch suivant), et conserver les noms de champs qu'un humain a choisis au fil des versions.
Le piège du nom volatil
La première tentation, face à un dump décompilé, c'est de chercher une classe par son nom. Ça marche une fois, puis le patch suivant renomme tout et il faut recommencer à zéro. La seule chose stable entre deux versions, c'est la structure : combien de champs, dans quel ordre, de quel type. Donc j'ai pris le parti inverse : ne jamais écrire un nom obfusqué en dur dans le code, et retrouver dynamiquement les trois pièces dont j'ai besoin à chaque fois.
- 1
Enum de registre
D'abord l'enum de registre, celle qui associe un identifiant numérique de fichier à une constante de type. Je ne connais pas son nom, mais je sais qu'elle est instanciée une fois par constante déclarée, avec un appel du genre= new X(id). La classe construite ainsi le plus grand nombre de fois dans tout le dump, c'est elle. Une fois trouvée, une regex sur son propre code source donne la table id vers constante. - 2
Légende du buffer
Ensuite la "légende" du buffer, c'est-à-dire la correspondance entre une méthode de lecture (bHA(), par exemple) et le type qu'elle renvoie. Je la lis par réflexion textuelle sur la classe buffer elle-même : toute méthode publique sans argument qui renvoie un type primitif connu entre dans la légende. Pas besoin de savoir quebHAveut dire "lire un entier", le code source obfusqué me le dit directement. - 3
Lecteurs candidats
Enfin, le ou les lecteurs pour un id donné : les classes qui référencent la constante de l'enum et qui possèdent une méthodevoid a(Buffer p). Il y en a généralement deux, une variante serveur et une variante client, presque identiques mais pas tout à fait. C'est là que ça devient intéressant.
Transcrire une méthode sans écrire un parseur Java
Une fois le corps de la méthode a() en main, il faut le retranscrire en une suite d'opérations de schéma ordonnée : lire un entier, lire une chaîne, boucler N fois sur une sous-structure, lire un champ optionnel selon un flag. J'ai délibérément choisi de ne pas écrire un vrai parseur Java. Le code est déjà compilé puis décompilé, donc syntaxiquement homogène et prévisible dans sa forme (pas de sucre syntaxique exotique). Un matching d'accolades pour délimiter les blocs, combiné à des regex ciblées, suffit à repérer les trois motifs qui comptent :
- Boucle
- Une boucle
forprécédée d'un entier qui sert de compteur (je remplace la paire par un seul noeud "boucle" qui plie le compteur dans la structure). - Flag optionnel
- Un
ifdont la condition ou le local juste avant est un booléen ou un octet (je le replie en un flag d'un octet). - Sous-structure
- Un appel
.a(param)sur un objet fraîchement construit, qui signale une sous-structure à inliner récursivement.
Le piège
C'est fragile dans l'absolu, un vrai parseur AST serait plus robuste face à du code exotique. Mais le code généré est stable dans sa forme d'une version à l'autre (seuls les noms bougent), donc le compromis tient : l'outil retranscrit correctement l'immense majorité des lecteurs, et pour les rares échecs, il le dit clairement au lieu de produire un schéma silencieusement faux.
{"path": "12.3", "kind": "read", "type": "int", "obf": "epH", "name": "familyId"}Ça, c'est un noeud de la couche de connaissance dont je parle plus bas : un chemin dans l'arbre de schéma, un type, le nom obfusqué courant (juste pour référence visuelle), et le nom sémantique que j'ai assigné.
L'arbitre : la vérification byte-exacte
Reste le problème du choix entre les deux candidats, serveur et client. Rien dans le code ne dit lequel des deux correspond au format réellement livré dans les fichiers installés. La solution que j'ai retenue est brutale et imparable : on essaie chaque candidat contre les vraies données binaires installées, et on retient celui qui décode toutes les entrées jusqu'au dernier octet. Concrètement, chaque entrée déclare sa taille ; si le nombre d'octets consommés colle exactement à la taille annoncée pour toutes les entrées, le candidat est le bon. Sinon, il échoue tout de suite, souvent sur la toute première entrée.
Sur un cas réel rencontré après un patch, un candidat a décodé 175901 entrées sur 175901, l'autre zéro. Pas d'ambiguïté possible : la taille exacte est un oracle sans appel. C'est ce test qui transforme une supposition structurelle en certitude vérifiable, sans jamais avoir besoin de comprendre ce que fait réellement le code.
make bump enchaîne tout ça après un patch :
- 1
Identifier
Identifier les ids dont le lecteur a cessé de coller. - 2
Retranscrire
Retranscrire les deux candidats depuis le dump frais. - 3
Tester
Les tester contre les bins installés. - 4
Épingler
Épingler le gagnant dans un fichier d'override.
Les ids non résolus restent listés, pas cachés sous le tapis.
Ancrer le savoir humain par position, pas par nom
Le dernier morceau, c'est la couche de connaissance : les noms sémantiques que j'assigne à chaque champ ("familyId", pas "epH"). Le nom obfusqué ne survit à rien, donc je l'utilise seulement comme référence d'affichage. Ce qui ancre vraiment un nom, c'est sa position structurelle dans l'arbre de schéma (le chemin, genre "12.3") combinée à son type. Quand une nouvelle version arrive et que la structure est retranscrite depuis zéro, avec des noms obfusqués totalement différents, je recale les anciens noms sur les nouveaux champs en comparant (position, type). Si le champ n'a pas bougé de place et garde le même type, il retrouve automatiquement son nom humain. S'il a changé de position ou disparu, l'outil le signale comme "à revoir" au lieu de deviner.
Chaque version du jeu devient un instantané figé de cette connaissance : je peux comparer deux versions et voir précisément quels champs ont été ajoutés, lesquels ont disparu, et pour ceux-là, quel nom ils portaient avant de partir. C'est un historique de schéma, pas juste un état courant.
Ce que j'en retiens
- Types définis
- 161 types possibles dans l'enum de registre, dont 129 réellement livrés dans les fichiers installés, et tous décodent sans erreur.
- Schémas transcrits
- Environ 341 Ko de JSON, généré à partir d'une combinaison de 112 lecteurs de référence portés à la main et de transcriptions automatiques pour les structures plus récentes.
- Dépendances
- L'interface qui permet de suivre l'état de chaque fichier et de nommer les champs tourne en local, sans dépendance externe, juste la bibliothèque standard.
À retenir
La leçon centrale, c'est qu'ancrer une connaissance sur un nom, dans un système où le nom est jetable, revient à construire sur du sable. Ancrer sur une propriété qui ne bouge pas (ici la position dans un arbre positionnel, combinée au type), c'est ce qui permet à un travail humain de nommage de traverser des dizaines de patches sans qu'on ait à tout refaire à chaque fois.