J'écris un émulateur en D

J'ai une philosophie dans la vie : celle de réinventer la roue non seulement pour comprendre son fonctionnement, mais aussi pour mieux apprécier son utilité et le génie qui est derrière. J'ai appliqué cette façon de penser auparavant en créant (ou en essayant de créer) : un client HTTP, un ORM (ah ah la naïveté), un serveur HTTP (si, si), quelques jeux simples, et d'autres petits projets dont je n'arrive pas à me rappeler. Quand on ne saisit pas la complexité d'un programme, d'une machine ou autre, on a tendance à comporter comme un gamin ingrat dès que celle-ci rencontre le moindre problème, on se met à se plaindre au moindre bug ou ralentissement qui surgit, même si c'est notre faute dans 60% des cas.

Parlons un peu de notre émulateur. En réalité, le terme correct pour décrire cette.. chose que j'ai écrite est "interprétateur", mais je suppose que "émulateur" est aussi valable. Je l'ai appelé ChippeD car il est écrit en D et qu'il émule les ROMs de la Chip-8.

Cette photo est uniquement à but démonstratif, mon meilleur score est de 14x points.

La quoi ?

Si, comme moi, vous avez grandi au Maroc dans les années 90, il est fort probable que vous ayez joué au "téléjeu" : un handheld monochrome qui comportait en théorie jusqu'à 9999 jeux. J'y ai moi-même joué bien que mes parents ne supportent pas l'idée des consoles ni des jeux vidéo en général. J'avais acheté le mien en cachette en dépensant les 40 dirhams que j'ai passé des mois à économiser, c'était ma toute première console. Ah la nostalgie.

Le mien avait 999 jeux, mais j'ai vu certains qui en ont 9999, soit over nine thousand.

Chip-8 est en quelque sorte une version plus sophistiquée de cette console, mais pas vraiment : C'est une machine virtuelle qui est censée faire tourner des programmes peu importe l'environnement dans lequel elle est exécutée. Ainsi, nous avons droit à des émulateurs qui tournent sur moult machines : ordinateurs, consoles, calculatrices, smartphones, lave-vaisselles, et j'en passe.

Parmi ses caractéristiques, on note : un écran monochrome d'une résolution de 64x32 pixels, 4 ko de RAM, 16 registres de 8 bits chacun, un clavier de 16 boutons, deux timers, etc.

J'ai choisi cette plate-forme parce que c'est la plus facile à émuler. Au départ j'avais essayé de me lancer directement sur la gameboy classique, mais je me suis rapidement heurté à un obstacle majeur : le CPU Z80. Notre prof d'électronique numérique nous en avait parlé auparavant, du coup je m'étais dit qu'émuler une gameboy m'aiderait à mieux assimiler l'élément d'architecture des CPU qu'on allait étudier cette année, mais il s'est avéré que c'était plus facile à dire qu'à faire. Suite à quelques recherches sur le net, j'ai conclu qu'il était impératif que je commence par quelque chose de plus facile à appréhender.

Le langage D

Le langage D est un langage peu connu chez la communauté francophone. Je l'ai choisi parce que je suis un hipster qui se plaît à utiliser des technologies rares que personne d'autre n'utilise, comme Google+ ou le Blackberry.

*voilà ce qui va t'arriver si tu n'arrêtes pas de parler ainsi des gens qui hébergent ton blog*

Un pote m'a dit une fois que si je dis à quelqu'un que je code en D, il croirait certainement que je me moque de lui. Mais sérieusement, je trouve que c'est un langage qui a beaucoup de potentiel. J'ai longtemps cherché un langage qui puisse être compilé nativement, ayant un typage statique, une syntaxe peu "verbose" ressemblant à celle du langage C, et qui soit relativement facile à utiliser. J'ai découvert D accidentellement durant un tournoi de Google (Codejam) et je me suis mis à l'utiliser dès lors.

Code source et commentaires

Dans les paragraphes qui suivent, je détaillerai les raisons qui m'ont poussé à faire tel ou tel choix, et j'expliquerai l'intérêt de certains bouts de code qui peuvent paraître étranges de premier abord.

Le code source peut être consulté sur mon Github. Si vous souhaitez le compiler vous-même comme un grand garçon, vous aurez besoin de deux outils : DMD (compilateur) et dub (dependency manager). Après les avoir installés et ajoutés à votre PATH (l'installation devrait le faire pour vous), il suffira par la suite de se rendre au répertoire contenant le fichier "dub.json" et de lancer la commande "dub build" sans les guillemets. dub se chargera de télécharger et d'installer les dépendances, Derelict dans ce cas.

Ceci dit, le programme contient des bugs que je n'arrive toujours pas à corriger, mais d'après ce que j'ai lu, c'est commun à la plateforme Chip8. Pour n'en citer que quelques uns : des ralentissements, le clavier avec certains jeux, et un certain "animal race" qui comporte des bugs d'affichage (mais de toute façon c'est Haram de miser sur les courses d'animaux donc on s'en fout.)

Remarquez comment les slices de D permettent de charger le programme dans la mémoire en quelques lignes :

void loadGame(string game)
{
    auto rom = cast(ubyte[]) game.read();
    memory[pc .. pc + rom.length] = rom;
    scr.setTitle("Running : " ~ game.baseName);
}

Et voilà ! Petite subtilité à noter : vu que l'opérateur ".." est à gauche du signe =, cette slice copie le tableau "rom" à l'intérieur de "memory" au lieu de simplement pointer dessus.

Vous remarquerez aussi que j'ai appelé une méthode read() sur une variable de type string. En réalité, string n'a pas de méthode read(), celle-ci fait partie du module std.file que j'ai importé plus haut :

import std.file : read;

Mais vu que D a ce qu'on appelle UFCS, on peut tout aussi bien faire un read(game) qu'un game.read(), ou plus simplement game.read. Ali Çehreli explique l'avantage d'une telle syntaxe dans son ebook gratuit, téléchargeable en cliquant sur "Download as PDF" en haut à droite du site.

"auto" est un mot-clef qui déduit le type selon le contexte dans lequel il est utilisé, les adeptes du C++11 le reconnaîtront sûrement.

En ce qui concerne les opcodes : J'ai décidé d'écrire une méthode par opcode malgré le nombre faible d'opcodes dont Chip8 dispose (35) car comme ça chaque méthode aura pour but d'effectuer une tâche bien délimitée. J'ai jugé que ça rendra le code plus lisible, et par conséquent plus facile à naviguer et à déboguer qu'un gros bloc de switch/case. Chaque méthode loggue ce qu'elle fait sous la forme d'une syntaxe proche de l'assembleur, elle est inspirée de l'excellente référence technique de Thomas P. Greene. Elle effectue ensuite l'action requise puis incrémente le program counter de deux (si nécessaire). Pour lier chaque méthode à l'opcode adéquat, j'ai créé un tableau associatif avec les masques comme clés et des delegates comme valeurs :

void delegate(OpCode op)[ushort] callbacks;

OpCode est une structure définie un peu plus haut. Elle prend un opcode dans son constructeur et le dissèque pour en extraire toutes les informations que peuvent utiliser les callbacks, comme ça je n'aurai pas à réécrire ce code à l'intérieur de chaque méthode :

struct OpCode
{
    ushort value;
    ushort mask;
    ubyte x;
    ubyte y;
    ubyte z;
    ubyte kk;
    ushort nnn;

    this(ushort op)
    {
        value = op;
        mask = op & 0xf000;
        x = (op & 0x0f00) >> 8;
        y = (op & 0x00f0) >> 4;
        z = op & 0x000f;
        kk = op & 0x00ff;
        nnn = op & 0x0fff;
    }
}

Petite remarque sur ce point : la structure risque de calculer certaines valeurs qui ne seront pas utilisées dans la méthode à laquelle elle sera passée, ce sont donc des calculs redondants. J'ai envisagé la possibilité de ne faire le calcul d'une propriété que lorsque celle-ci est demandée, chose qui pourrait être résolue en écrivant des getters (saupoudrées de sucre syntaxique à base de @property) pour chaque propriété, mais je me suis vite rendu compte qu'ils seraient recalculés à chaque appel au getter. Du coup j'ai pensé à faire queque chose du genre :

struct OpCode
{
    ushort opcode;
    byte _x = -1;
    byte _y = -1;
    byte _z = -1;
 
    this(ushort opcode)
    {
        this.opcode = opcode;
    }

    @property ubyte x() pure nothrow
    {
        return _x == -1 ? (opcode & 0x0f00) >> 8 : _x;
    }

    @property ubyte y() pure nothrow
    {
        return _y == -1 ? (opcode & 0x00f0) >> 4 : _y;
    }

    @property ubyte z() pure nothrow
    {
        return _z == -1 ? opcode & 0x000f : _z;
    }

}
//j'ai fait exprès de sauter une ligne pour énerver les atteints d'OCD d'entre vous

Mais là j'ai constaté que je m'étais mis à écrire plein de code pour un gain futile en performances, et ce avant même de savoir s'il y en avait un réel besoin, chose qui relève de la micro-optimisation ainsi que de l'optimisation prématurée (the root of all evil).

(Au passage, les pure nothrow sont là parce que j'avais aussi songé à utiliser memoize, mais j'ai bloqué sur la syntaxe.)

Deuxième petite remarque sur le constructeur de la classe CPU :

this()
{
    pc = 0x200;
    memory[0 .. display.fonts.length] = display.fonts;

    foreach(x; Iota!(0, 16))
        mixin("callbacks[0x%x000] = &_%xxxx;".format(x, x));
}

En D, mixin est un statement qui évalue la chaîne passée au moment de la compilation. On peut donc l'utiliser pour faire de la métaprogrammation (un des points forts du langage), comme écrire un programme qui génère son propre code le moment de la compilation, chose que j'ai faite là-dessus. J'ai toutefois triché sur ce dernier point. Vu que le langage n'a pas de static foreach pour le moment, j'ai fouillé un peu et je suis tombé sur cette solution qui utilise une template, un static if, et de la magie noire pour accomplir ce dont j'avais besoin : générer le tableau de delegates au moment de la compilation. Le code ci-dessus est donc l'équivalent de :

callbacks[0x0000] = &_0xxx;
callbacks[0x1000] = &_1xxx;
...
callbacks[0xf000] = &_fxxx;

L'affichage

Pour gérer l'affichage et les entrées du clavier, j'ai opté pour DerelictSDL, mais il n'y a pas de raison particulière à ce choix. Afin de mieux gérer le "zoom", j'ai créé une simple structure pour représenter les pixels et leurs statuts :

struct Pixel
{
    SDL_Rect pos;
    ushort zoom;
    bool lit;

    this(ushort zoom, ushort x, ushort y)
    {
        this.zoom = zoom;
        pos.x = x * zoom;
        pos.y = y * zoom;
        pos.w = zoom;
        pos.h = zoom;
    }
}

Ceux-ci font partie d'un tableau multidimensionnel de 64x32 que j'affiche avec une boucle imbriquée :

foreach(x; 0 .. grid.length)
    foreach(y; 0 .. grid[0].length)
        if(grid[x][y].lit)
            SDL_RenderDrawRect(renderer, &grid[x][y].pos);

C'est plus compliqué que ça ne devrait l'être. Les codes que j'ai vus jusque là n'utilisent qu'un tableau d'une seule dimension et arrivent à faire ce que j'ai fait là avec beaucoup moins de code. La méthode qui implémente l'opcode DXYN s'appelle drawSprite, mais je ne vais pas la retranscrire ici car elle est vraiment moche :^(

Finalement j'ai créé deux timers pour rafraîchir l'écran et pour décrémenter les timers du CPU. Etant donné que DerelictSDL est une interface à la librairie C, j'ai dû écrire du code qui utilise void * pour pouvoir passer les objets requis au callbacks de SDL_AddTimer :

extern(C) uint decrementTimers(uint interval, void *param) nothrow 
{
    auto cpu = cast(Cpu) param;
    cpu.decrementTimers();
    return interval;
}
//...
SDL_AddTimer(16u, cast(SDL_TimerCallback) &decrementTimers, cast(void *) cpu);

Certes pas très idiomatique, mais ça fait l'affaire. On ne peut malheureusement pas lui passer un delegate car sinon j'aurais passé la méthode Cpu::decrementTimers de façon directe. J'aurais aimé pouvoir faire de même pour runCycle() afin de contrôler le nombre d'opcodes à exécuter par seconde, mais elle ne peut pas être nothrow car elle fait des appels à des fonctions qui risquent de thrower des exceptions. Pour contrôler la vitesse de l'émulateur, j'ai donc créé un compteur qui s'incrémente à chaque itération de la boucle principale, puis j'ai utilisé le code suivant pour que le programme s'arrête quelques millisecondes une fois le nombre d'opcodes stocké dans "sleep_after" est exécuté.

if(++i % sleep_after == 0)
    SDL_Delay(delay);

Le nombre d'opcodes par cycle peut être changé durant l'exécution du programme en utilisant les boutons P (plus) et M (moins) pour respectivement augmenter ou diminuer la vitesse. Cette méthode n'est probablement pas la bonne, et c'est peut être elle qui cause les bugs du clavier avec certaines ROMs, mais la vitesse m'a l'air correcte sinon.

Hmm.. quoi d'autre ? Ah, le nettoyage. D est un langage dit "garbage collected", c'est à dire qu'il contient un ramasseur de miettes afin qu'on n'ait pas à gérer la mémoire nous même. C'est la raison pour laquelle beaucoup de développeurs C++ refusent d'adopter D. On a récemment ajouté l'annotation @nogc qui permettra d'écrire du code qui n'utilise pas le ramasseur de miettes, mais à quelques conditions : impossible par exemple d'allouer à l'intérieur d'une fonction nogc avec le mot clef new, impossible d'utiliser les slices, et d'autres. Le compilateur vous indiquera si vous essayez de faire ceci. A noter qu'on peut aussi contrôler le garbage collector à sa guise en passant par la structure GC du module core.memory.

En plus de ceci, les développeurs du langage envisagent d'améliorer le ramasseur de miettes car la version actuelle n'est pas très performante.

*et qu'est-ce que ça a avoir avec ce billet ?*

Bref, tout ça pour dire que puisque j'utilise une bibliothèque écrite en C, il faudra libérer les ressources manuellement. J'ai donc ajouté une méthode "cleanup" à la classe Screen :

void cleanup()
{
    SDL_DestroyRenderer(renderer);
    SDL_DestroyWindow(win);
}

Puis je l'ai appelé à l'intérieur d'un scope(exit) juste après avoir instancié la classe :

auto scr = new Screen(zoom);
scope(exit) scr.cleanup();

Comme ça, les fonctions de nettoyage seront appelées dès que le programme quitte la fonction main(), que ce soit de façon normale ou à cause d'une erreur. J'ignore si c'est l'approche correcte, mais ça m'a paru logique.

Conclusion

Félicitations ! Vous avez enfin atteint la fin du billet. Je n'ai malheureusement rien à vous offrir, à part ce cookie :

Spoiler alert : il contient des raisins secs.

Quant à l'émulateur, j'estime qu'il n'est pas encore terminé vu qu'il contient toujours quelques bugs. Cependant, si vous voulez l'essayer, vous pouvez télécharger cette version pré-compilée qui inclut aussi quelques ROMs téléchargées sur internet. Merci pour la lecture, et à très bientôt !

Commentaires

Posts les plus consultés de ce blog

Writing a fast(er) youtube downloader

My experience with Win by Inwi

Porting a Golang and Rust CLI tool to D