Accueil
Écrit le 13-09-2023
par Damien Brebion
Je me suis récemment intéressé à la création d’un Bot pour la génération III des jeux pokémons. Au départ, je m’attendais à trouver pas mal d’outils ou même de documentation mais après plusieurs recherches je me suis rendu compte qu’il y a très peu d’information à ce sujet. Aujourd’hui je vais partager avec vous mes recherches et sources.
Après ce tutoriel vous devrez être dans la capacité de comprendre le fonctionnement de base d’une ROM et comment lire ou écrire des données directement depuis la mémoire.
Manipuler la mémoire d’une application permet de facilement créer une application externe qui va modifier ces données pour pouvoir tricher. C’est ce que font une grande majorité des applications de triches pour toutes sortes de jeux. Les plus connus sont par exemple WeMod ou CheatEngine.
Si vous êtes intéressé par le bot, vous pouvez lire la documentation et voir comment l’installer sur Github.
Voici les outils et technologies nécessaires pour suivre ce tutoriel :
Une fois ces outils téléchargés, je vous conseil de suivre le tutoriel officiel de BizHawk en fonction de votre environnement. Si vous êtes en LUA vous pouvez commencer à écrire votre script. Si vous êtes en C# vous devrez importer BizHawk dans votre projet et référencés les DLLs. Suivez ce tutoriel.
Maintenant, vous êtes prêt pour commencer !
Les jeux pokémons ont une structure de données relativement simple. Il s’agit généralement de booléen ou d’entier unsigned (entiers uniquement positifs). La plus grosse difficulté réside dans la lecture et dans la transformation des bytes en valeur utilisable. Heureusement pour nous, l’étape de lecture est simplifiée car les adresses mémoires sont défini à l’avance et toujours aux mêmes endroits dans la ROM. Donc pour résumé, notre ROM est le programme qui contient des adresses en mémoire et à la destination de ces adresses se trouvent des données d’une taille variable en fonction du type de données.
Pour ce faire, nous aurions dû utiliser divers logiciels pour lire la mémoire et trouver l’adresse qui nous intéresse. Cependant, merci à Pret qui a décompilé entièrement les jeux des premières générations. Grâce à ça nous avons accès à des fichiers symbols
qui nous donne directement accès à la liste des adresses utilisées, leur taille ainsi qu’un nom bref de la donnée qui s’y trouve. Voilà le fichier pour pokémon émeraude.
Nous avons donc 030022c0 g 0000043c gMain
. 030022c0 est la valeur hexadécimale de l’adresse, g signifie global et 0000043c est la taille hexadécimale.
Grâce au projet de décompilation nous pouvons faire une recherche dans le code source de la décompilation du jeu pour le terme gMain
et nous trouvons une référence struct Main gMain
donc nous connaissons maintenant que gMain a la structure Main. Cliquons dessus et nous arrivons dans le fichier qui contient la référence vers gMain. Si on clique sur le type “Main” on va pouvoir voir la définition complètement de la structure.
Pour informations:
Il se peut que devant la ligne il y ait en commentaire l’offset pour atteindre la donnée. 0x010 signifie 10 offsets hexadécimaux. Dans le cas où il n’y aurait pas cette information on part du principe qu’on commence à 0 et on ajoute 1 offset par byte. Si l’offset est à 0 et que la donnée est de type u32 alors on ajoute 4 offset pour accéder à la prochaine donnée.
Dans certains jeux comme Rouge Feu, Vert Feuille ou Émeraude il y a une sécurité appelée DMA. Cette sécurité permet que lorsqu’un joueur exécute une action comme entrer ou sortir d’un batîment, l’adresse mémoire contenant les données est déplacée. C’est le cas pour 3 types de données:
Heureusement pour nous, on peut trouver l’adresse en temps réel grâce au pointeur gSaveBlock1Ptr ou gSaveBlock2Ptr. Plus d’info grâce à JPAN.
Cette partie est la plus complexe, contrairement au reste des données. La donnée d’un pokémon est cryptée et nécessite des manipulations mathématiques pour pouvoir les lires. Bulbapedia a écrit une documentation très complète sur le processus de décryptage des données.
Pour résumé, chaque pokémon est stocké en 100 octets. Sachant que les 20 derniers octets ne sont utilisés que pour les pokémons dans l’équipe. Les 80 octets restants sont utilisés pour stocker toutes les données du pokémon. La taille et le type de donnée est défini dans cette documentation.
Data est la partie qui est la plus complexe à lire car c’est celle-ci qui est cryptée. 48 bytes pour stocker 4 sections chacune de 12 bytes. Ces sections sont dans un ordre aléatoire qui dépend du pokémon et il y a 24 possibilités. Pour obtenir l’ordre qui correspond au pokémon il faut faire la valeur de la personnalité % (modulo) 24
. Quand c’est fait nous devons décrypté ces données en utilisant le dresseur ID xor la valeur de la pesonnalité
. Nous pouvons également calculé checksum
pour s’assurer que le pokémone est valide. En code nous avons donc :
//bytesPokemon est un tableau de bytes contenant les 100 bytes
var PID = bytesPokemon.Take(4).ToUInt32();
//Get original trainer
var OT = bytesPokemon.Skip(4).Take(4).ToUInt32();
var OTID = bytesPokemon.Skip(4).Take(2).ToUInt16();
var OTSID = bytesPokemon.Skip(6).Take(2).ToUInt16();
var decryptKey = PID ^ OT;
var order = PID % 0x18; //0x18 = 24
var subStructureType = _subStructureTypes[order];
var subStructuresData = new Dictionary<char, byte[]>();
int calculatedChecksum = 0;
for (int i = 0; i < 4; i++)
{
var subStructure = subStructureType[i];
var subStructureData = pokemonData.Skip(i * 12).Take(12).ToArray();
subStructuresData[subStructure] = DecryptSubStructure(subStructureData, decryptKey);
for (int k = 0; k < 6; k++)
{
byte[] bytesToAdd = subStructuresData[subStructure].Skip(k * 2).Take(2).ToArray();
calculatedChecksum += bytesToAdd.ToUInt16();
calculatedChecksum &= 0xFFFF;
}
}
var valid = calculatedChecksum == checksum;
Quand c’est fait, il ne reste plus qu’assembler toutes les données ensemble. Plus d’info dans le code source de mon projet.
Les données du dresseur peuvent être obtenu à plusieurs endroits en fonction de ce que l’on recherche.
Pour la position du joueur et sa direction on va plutôt utiliser gObjectEvents
. Pour les données comme le sexe ou le sprite on va utiliser gPlayerAvatar
.
public virtual PlayerData ParsePlayer(byte[] bytesGPlayer, byte[] bytesObjects)
{
var runningState = (PlayerRunningState)bytesGPlayer[2];
var transitionState = (TileTransitionState)bytesGPlayer[3];
var gender = bytesGPlayer[7] == 0;
var currentX = bytesObjects.Skip(0x10).Take(2).ToUInt16();
var currentY = bytesObjects.Skip(0x12).Take(2).ToUInt16();
var previousX = bytesObjects.Skip(0x14).Take(2).ToUInt16();
var previousY = bytesObjects.Skip(0x16).Take(2).ToUInt16();
var currentPosition = new Position(currentX, currentY);
var prevPosition = new Position(previousX, previousY);
var facingDirection = (PlayerFacingDirection)bytesObjects[0x18];
return new PlayerData(currentPosition, prevPosition, runningState, transitionState, gender, facingDirection);
}
Si par contre vous voulez récupérer le Trainer ID et le Secret ID alors il va falloir utiliser gSaveBlock2 qui contient toutes les données du joueur, dont son TID et SID. Pour récupérer l’adresse de début de ces données, il faut la récupérer via la méthode DMA (voir ci-dessus). Ensuite, il suffit de lire directement à l’offset voulu.
protected virtual uint GetSaveBlock2Address()
{
//With FR / LG / Emerald it will select this symbol because of DMA (Dynamic Memory Address) we need this pointer
var symbolPtr = Symbols.FirstOrDefault(x => x.Name == "gSaveBlock2Ptr");
uint addr;
if (symbolPtr == null)
{
var symbol = Symbols.FirstOrDefault(x => x.Name == "gSaveBlock2");
addr = (uint)symbol.Address;
}
else
{
addr = SymbolUtil.Read(APIContainer, symbolPtr).ToUInt32();
}
return addr;
}
//With Gen 3 you should follow the save block in memory using pointer
//https://bulbapedia.bulbagarden.net/wiki/Save_data_structure_(Generation_III)#Game_save_A.2C_Game_save_B
public int GetTID()
{
return SymbolUtil.Read(APIContainer, GetSaveBlock2Address(), 0x0A, 2).ToUInt16();
}
Les tâches permettent d’avoir accès à des informations comme ce que l’utilisateur est en train de faire ou de ce qu’il sélectionne entre plusieurs choix.
Il peut y avoir au total 16 tâches qui sont constituées de 40 bytes chacune pour un total de 640 bytes. Voici la structure :
struct Task
{
TaskFunc func;
bool8 isActive;
u8 prev;
u8 next;
u8 priority;
s16 data[NUM_TASK_DATA];
};
Concernant le code, voilà le résultat :
//gTasks is the symbol with address and size
var bytesTask = SymbolUtil.Read(APIContainer, gTasks);
for (int i = 0; i < 16; i++)
{
var offset = i * 40;
var isActive = bytesTask[offset + 4] == 1;
if (isActive)
{
var bytesName = bytesTask.Skip(offset).Take(4);
var addr = bytesName.ToUInt32() - 1;
var taskSymbol = Symbols.FirstOrDefault(x => x.Address == addr);
string taskName;
if (taskSymbol == null)
{
taskName = addr.ToString();
}
else
{
taskName = taskSymbol.Name;
}
var prev = (int)bytesTask[offset + 5];
var next = (int)bytesTask[offset + 6];
var priority = (int)bytesTask[offset + 7];
var data = bytesTask.Skip(offset + 8).Take(32).ToArray();
tasks.Add(new GTask(
taskName,
isActive,
prev,
next,
priority,
data
));
}
}
La création d’un bot pour les jeux GBA ou GB est très intéressant et constitue un excellent exercice de recherche et de reverse engineering. Elle offre l’opportunité d’acquérir une compréhension approfondie du fonctionnement des jeux et de la manière dont les développeurs optimisaient leurs ressources dans un contexte où l’espace mémoire était fortement limité.
Le processus devient rapidement complexe et demande une réflexion approfondie, surtout lorsqu’on explore les différentes langues d’une ROM. Cependant, plus on investit de temps dans le projet, plus la satisfaction est grande lorsqu’on parvient à obtenir des résultats.
Si vous êtes passionnés par la compréhension des jeux de cette époque, je vous encourage vivement à vous engager dans un projet de cette envergure.