Evoland.gb : Développer un jeu GameBoy en 2019 (BONUS)

Et voici enfin le dernier article sur mon retour d'expérience du développement d'Evoland.gb. Dans les articles précédents, j'avais à peu près fait le tour de l'histoire de ce projet, de ses débuts à la sortie de la première version publique. Cet article sera donc un « bonus » dans lequel je vais vous parler des incidents de parcours.

Je vais vous expliquer en détail les deux plus gros bugs que j'ai rencontrés, ceux qui m'ont été les plus difficiles à cerner et sur lesquels j'ai bien galéré. Cet article sera donc un peu plus technique que les précédents. Si toutefois le côté technique ne vous intéresse pas, jetez tout de même un coup d'œil à la fin de l'article, j'y ai compilé des captures vidéo des bugs les plus « spectaculaires » que j'ai rencontrés. :)

Un peu... décalé ?

Capture d'écran du bug (personnage coupé en deux)

Le premier problème majeur auquel j'ai dû faire face lors du développement d'Evoland.gb est apparu lorsque j'ai commencé à utiliser des sprites. Sur la GameBoy, les sprites peuvent mesurer soit 8×8 pixels, soit 8×16 pixels. Étant donné que le personnage mesure 16×16 pixels, j'ai dû utiliser deux sprites pour le représenter (on parle de metasprite). Jusque-là, pas de problème particulier, en théorie tout du moins : il suffit d'afficher la moitié gauche du joueur sur un premier sprite, la moitié droite sur un autre, et de les placer au bon endroit à l'écran... et c'est là que j'ai rencontré un problème qui m'a pris un certain temps à résoudre.

Voici le code C qui me sert à positionner les deux sprites du joueur (il s'agit d'une version simplifiée pour ne contenir que le code qui nous intéresse) :

// Fonction fournie par GBDK pour déplacer les sprites,
// je vous reproduis sa signature ici pour plus de clarté
void move_sprite(sprite_id, x, y);

void main() {
    UINT8 x = 80;  // 0x50
    UINT8 y = 72;  // 0x48
    // ...
    move_sprite(0, x, y);
    move_sprite(1, x+8, y);  // x+8 = 0x58
}

Voici ce que fait le bout de code ci-dessus :

  • on commence par stocker les coordonnées du premier sprite dans les variables x et y (les valeurs sont calculées pour que le groupe composé des deux sprites du joueur soit centré à l'écran),
  • puis on déplace le premier sprite aux coordonnées (x, y),
  • et le second aux coordonnées (x+8, y) pour qu'il apparaisse à côté du premier.
Illustration du metasprite représentant le joueur

Normalement, tout devrait bien se passer... mais lorsque l'on exécute ce programme, on se retrouve avec les sprites décalés sur l'axe vertical... Mais qu'est-ce qui a bien pu se passer ? C'est ce que l'on va voir tout de suite !

Au début, j'ai bien sûr pensé que j'avais commis une erreur quelque part, mais après avoir refait tous les calculs à la main plusieurs fois, j'ai commencé à penser que ce n'était peut-être pas de ma faute et qu'il se passait vraiment un truc pas net. À partir de là, je me suis dit qu'il fallait voir ce qui se passe réellement sur le processeur de la console. Mais avant de me lancer dans le débogage dans BGB, je me suis dit que ce serait pertinent d'analyser le code généré par le compilateur afin de comprendre ce qu'il faisait. J'ai donc rajouté des options à la compilation pour qu'il génère les fichiers .lst qui contiennent entre autres le code assembleur généré par le compilateur. Je me suis donc mis à analyser des bouts du programme pour comprendre ce qui se passait, et au bout d'un certain temps, je me suis rendu compte qu'un truc tournait pas rond dans certains cas lors des appels de fonctions.

Explications (version courte)

Pour faire simple, le code C ci-dessus est parfaitement juste. Le problème se situe au niveau du compilateur : il s'est avéré que ce dernier était quelque peu... bogué. Sans rentrer dans les détails, le code machine généré pour le premier appel à la fonction move_sprite() est correcte, mais le code généré pour le second appel est faux. Au lieu d'appeler la fonction avec les paramètres (1, x+8, y), elle se retrouve appelée avec (1, x+8, x) (avec une fuite mémoire en prime...). C'est pour cela que le second sprite se retrouve décalé puisque sa position devient (88, 80) au lieu de (88, 72).

Explications (version longue)

Comme je l'ai dit précédemment, ça m'a pris un certain temps de comprendre ce qui se passait. Je n'avais par exemple aucune idée de la convention d'appel utilisée pour appeler les fonctions, et je n'avais pas refait d'assembleur depuis des années (et c'était pour une tout autre architecture...).

C'est en comparant le code généré pour le premier appel de la fonction move_sprite() avec celui du second appel que j'ai compris où se situait le problème. C'est à ce moment que j'ai été absolument sûr que le problème venait du compilateur lui-même et pas d'ailleurs.

Pour vous expliquer plus en détail l'erreur du compilateur, je vais devoir vous monter quelques lignes d'assembleur. Ne fuyez pas, je vais essayer d'expliquer ça aussi simplement que possible.

Note

Quelques informations utiles avant de plonger dans le code assembleur :

Avant de plonger dans le code assembleur, il faut que je vous explique deux ou trois trucs sur les registres du processeur de la console et sur la manière dont sont appelées les fonctions.

Les registres du processeur de la GameBoy

Je ne vais pas vous faire un cours magistral sur l'ensemble des registres du CPU de la GameBoy et leur utilité, car ce n'est pas vraiment le sujet de l'article. Je vais juste vous donner les informations nécessaires pour comprendre la suite.

Dans notre cas, les registres suivants sont utilisés :

  • le registre 16 bits HL, qui se décompose en deux sous-registres 8 bits H et L (pour être parfaitement clair, si on modifie H ou L, le registre HL est impacté, et inversement),
  • le registre 16 bits AF, qui englobe le sous-registre 8 bits A et les flags du processeur,
  • le registre 16 bits SP (Stack Pointer), qui contient l'adresse mémoire du haut de la stack.
Registres du CPU de la GameBoy

À propos de la stack

La stack est une zone de la mémoire où l'on peut empiler des valeurs (à l'aide de l'instruction PUSH) et les dépiler (à l'aide de l'instruction POP). Il s'agit d'une structure de type LIFO (Last In First Out), ce qui signifie que le dernier élément empilé sera le premier à être dépilé (comme avec une pile d'assiettes).

L'adresse du haut de la pile est stockée dans un registre du processeur appelé SP (Stack Pointer). Ce registre est automatiquement mis à jour lorsque certaines instructions telles que PUSH, POP, CALL ou RET sont appelées, mais il est également possible de le modifier à la main.

Sur le CPU de la GameBoy, il est uniquement possible d'empiler des registres 16 bits : on est donc obligé d'empiler 2 octets à la fois.

Enfin, la stack grandit « à l'envers » : plus on empile des trucs dessus, plus l'adresse mémoire stockée dans le Stack Pointer est petite.

Schéma montrant une opération PUSH sur la stack

Opération PUSH sur la stack

Schéma montrant une opération POP sur la stack

Opération POP sur la stack

Appels de fonctions

Lorsque l'on appelle une fonction et qu'on lui passe des arguments, la convention d'appel est la suivante :

  • les arguments que l'on passe à la fonction sont empilés sur la stack (via l'instruction PUSH),
  • puis la fonction est appelée (instruction CALL).

L'appel de la fonction provoque l'empilement de l'adresse de retour (adresse de la prochaine instruction, où il faudra retourner à la fin de la fonction), puis le Program Counter (PC) va sauter à l'adresse où se trouve le code de la fonction.

La fonction va ensuite récupérer ses paramètres sur la stack. Elle ne les dépile pas, elle calcule leurs adresses par rapport à la valeur courante du Stack Pointer (SP), en tenant compte du fait que l'adresse de retours a été empilée par-dessus.

À la fin de la fonction (lorsque l'on rencontre l'instruction RET), le programme va dépiler l'adresse de retour depuis la stack puis sauter à cette adresse.

Enfin, il faut libérer les arguments que l'on avait empilés sur la stack (soit avec l'instruction POP pour les dépiler, soit en décalant directement le Stack Pointer).

Premier appel à move_sprite()

Commençons donc par analyser ce qui se passe lors du premier appel à la fonction move_sprite(), qui pour rappel, se passe correctement.

Voici le code assembleur généré par le compilateur:

ld    hl, #0x4850  ; y x
push  hl
ld    a, #0x00     ; sprite_id
push  af
inc   sp
call  _move_sprite
lda   sp, 3(sp)

Détaillons-le ligne par ligne :

  1. On met la valeur 0x4850 dans le registre HL, ce qui correspond à la valeur de y dans H et la valeur de x dans L).

  2. On empile le contenu de HL. On a donc les paramètres x et y sur le haut de la pile.

    État de la pile à cette étape
  3. On met la valeur 0 dans le registre A (il s'agit du numéro du premier sprite).

  4. On empile le contenu du registre AF. On a donc rajouté sur la pile le paramètre sprite_id, et le contenu des flags (qui ne nous intéresse pas).

    État de la pile à cette étape
  5. On incrémente le Stack Pointer. Comme la pile grandit vers le bas, cela revient à supprimer le dernier élément de la pile (le contenu des flags). À ce stade, on a donc sprite_id, x et y sur le haut de la pile.

    État de la pile à cette étape
  6. On appelle la fonction move_sprite(), qui pourra récupérer ses 3 paramètres sur la pile.

    Paramètres qui seront récupérés par la fonction
  7. Enfin, une fois de retours de la fonction, on élimine les 3 paramètres que l'on avait placés sur le haut de la pile en décalant l'adresse du Stack Pointer de 3 octets.

Second appel à move_sprite()

Et maintenant, regardons le code généré pour le second appel à la fonction move_sprite() afin de voir quelle erreur a été commise par le compilateur :

ld    hl, #0x4850  ; y x
push  hl
ld    hl, #0x5801  ; x+8 sprite_id
push  hl
call  _move_sprite
lda   sp, 3(sp)

Cette fois encore, détaillons ce programme ligne par ligne :

  1. On met la valeur 0x4850 dans HL, exactement comme tout à l'heure (aucune idée de pourquoi le compilateur décide d'empiler y et x au lieu d'empiler directement y et x+8).

  2. On empile le registre HL. On a donc x et y sur le haut de la pile.

    État de la pile à cette étape
  3. On met la valeur 0x5801 dans HL, ce qui correspond aux valeurs de x+8 et de l'id (sprite_id) du second sprite.

  4. On empile le registre HL. On se retrouve donc avec sprite_id, x+8, x et y sur le haut de la pile.

    État de la pile à cette étape
  5. On appelle la fonction move_sprite(), qui va récupérer les 3 derniers éléments empilés étant donné qu'elle n'attend que 3 paramètres... Et là, c'est le drame : elle va récupérer sprite_id, x+8 et x...

    Paramètres qui seront récupérés par la fonction
  6. Et pour finir, on libère les 3 derniers éléments de la pile... Sauf qu'on en avait empilé 4 à l'origine. On a donc une fuite mémoire !

    Fuite mémoire dans la pile

C'est ainsi qu'on se retrouve avec le second sprite décalé par rapport au premier et qu'on perd une journée à déboguer des erreurs que l'on n'a pas commises. :)

Solution

Lorsque le compilateur se retrouve impliqué dans un bug, il n'y a pas 36 solutions :

  • soit on fait avec et on cherche une solution de contournement, c'est-à-dire modifier le code pour ne pas tomber sur un cas où le compilateur bug... avec le risque d'avoir à nouveau un problème similaire ailleurs...
  • soit on change de compilateur.

J'ai personnellement choisi la seconde option. J'ai laissé tomber le compilateur intégré à GBDK, qui était une vieille version de SDCC pas mise à jour depuis 2002. Je ne suis cependant pas allé chercher bien loin, car je l'ai remplacé par une version plus récente de SDCC sur laquelle le bug avait été corrigé depuis longtemps.

Ce changement a toutefois demandé quelques adaptations sur le projet. Les bibliothèques de GBDK ne fonctionnaient plus avec la nouvelle version du compilateur... j'ai commencé à les réadapter avant de tomber sur le projet gbdk-n qui avait déjà fait le travail. Au final j'ai eu seulement quelques modifications à apporter à mon code et surtout aux scripts de compilation.

Petit souci mathématique

Problème de collision et de chargement de la map

Après avoir changé de compilateur, j'ai poursuivi le développement du jeu, mais j'ai commencé à rencontrer des problèmes étranges : la carte ne se chargeait pas correctement suivant la direction dans laquelle on se déplaçait, les collisions se produisaient n'importe comment,... Les résultats des calculs, bien que reproductibles étaient devenu totalement incohérents,... J'ai passé un moment à chercher ce qui se passait, et j'ai même réécrit une partie des algorithmes du jeu en Python pour m'assurer qu'ils étaient justes... À ce stade j'ai pensé à un nouveau bug de compilateur, ce qui aurait été embêtant car je n'avais plus de solution de rechange (à part développer le jeu directement en assembleur...). Mais bien que le compilateur soit impliqué dans le problème, il ne s'agissait pas d'un bug cette fois-ci, c'était beaucoup moins grave... ouf, sauvé !

Le problème

Afin de comprendre le problème, j'ai cherché à l'isoler : j'ai donc écrit des petits programmes qui m'ont permis de tester diverses choses en dehors du jeu. Voici l'un d'entre eux, et plus précisément celui qui m'a permis de mettre le doigt sur le souci :

#include <stdio.h>
#include <gb/gb.h>

void main(void) {
    INT8 x = 10;
    INT8 dx = -1;
    printf("INT8 x = 10;\n");
    printf("INT8 dx = -1;\n\n");
    printf("x + dx = %i\n", x + dx);
}
10 + (-1) = 265

Capture d'écran du programme lancé dans un émulateur

Ce programme est tout bête : il affiche le résultat de l'opération 10 + (-1). En théorie, on devrait obtenir 9... Mais le résultat affiché fut tout autre : 265... WAT ZE PHOQUE ?!

Le problème se produisait en fait à chaque fois que je manipulais des variables contenants des nombres négatifs. Lorsque j'avais besoin de nombres négatifs, j'utilisais des variables de type INT8, qui peuvent contenir des nombres entiers compris entre -128 et 127... Tout du moins en théorie...

Il se trouve que ce type, INT8 est basé sur le type C char, qui lui aussi peut contenir des nombres entiers compris entre -128 et 127... en tout cas, c'était le cas jusqu'en 2016, où un développeur de SDCC a décidé qu'à partir de maintenant, le type char serait non signé par défaut, c'est-à-dire qu'il peut à présent contenir des nombres compris entre 0 et 255... Oops.

Lorsque l'on essaye de stocker -1 dans un entier 8 bits non signé (ce qu'était devenu le type char, et donc le type INT8 comme il en est dérivé), on se retrouve en fait avec la valeur 255 à la place... d'où le résultat du calcul qui semblait aberrant.

La solution

Une fois le problème trouvé, il est très facile de le résoudre. J'ai simplement remplacé la déclaration du type INT8 dans le fichier types.h de gbdk-n. Le type était à l'origine défini de cette façon :

typedef char                 INT8;

je l'ai donc corrigé en précisant qu'il devait être signé :

typedef signed char          INT8;

Et c'est tout, la correction est extrêmement simple, le plus dur, comme souvent, c'était de comprendre quel était le problème exactement. J'ai bien évidemment soumis cette correction sur le dépôt Github du projet gbdk-n, histoire que tout le monde puisse en profiter.

Galerie des horreurs

Durant le développement du jeu, j'ai rencontré pas mal de bugs, certains sont visuellement assez spectaculaires, c'est probablement la raison qui m'avait poussée à les immortaliser dans des captures vidéos. Ces bugs ne sont cependant pas forcément intéressants d'un point de vue technique c'est pourquoi je vous les partage ici sans entrer dans les détails.

Sortie de route

La carte du jeu est stockée dans la ROM à une place bien définie. Normalement on s'arrange pour ne pas aller lire les données de la carte en dehors de cette zone mais des fois il arrive qu'on ait oublié un détail ou commit une petite erreur et on se retrouve à prendre des données n'ayant rien à voir avec la carte et à tenter de les afficher. C'est ce genre de chose qui donne les « villes bug » du jeu Pokemon, ou des résultats comme ceux-ci :

Sortie de la map #1 Sortie de la map #2

Dans la seconde image, on est même sorti tellement loin de la map que l'on se retrouve à afficher des données de la RAM... dans laquelle on peut voir le contenu de la variable qui avait servi à générer le texte que l'on a ensuite affiché en bas de l'écran.

Téléportation ?

Pour ce bug-ci, je ne me souviens plus exactement des circonstances, je sais juste que je travaillais à ce moment-là sur les pools de sprites pour gérer les objets... Visiblement tout ne c'était pas déroulé comme prévu...

Téléportation ? #1 Téléportation ? #2

Ahhhhhhhhh !

J'ai rencontré ce bug lors de l'implémentation de l'épée. Rien de bien méchant toutes fois : il s'agit juste d'un sprite mal positionné et d'un petit mélange dans les frames de l'animation du joueur.

Ahhhhhhhh !

That's all folks

Développer un jeu GameBoy en 2019, c'est devenu assez accessible, surtout grâce aux outils modernes dont on dispose, et grâce au fait que l'on ne soit plus obligé de le faire en assembleur comme à l'époque. Il faut toutefois s'attendre à quelques surprises de temps à autre.

J'ai personnellement beaucoup appris en travaillant sur ce projet, et je vais profiter de ces connaissances pour me lancer sur de nouveaux projets et pour améliorer mes articles. Je vous donne donc rendez-vous d'ici quelques semaines pour la suite de ma série d'articles sur le développement GameBoy que j'avais un peu mis en pause ces derniers temps.