Homemade Pixels

Des pixels frais qui sortent du four le blog de MrHelmut.

Archive pour la catégorie ‘Emulation’

Vous avez peut être suivi l’actualité, Mojang a annoncé un nouveau jeu, 0×10c. On en a notamment entendu parlé ce 1er avril, Notch ayant fait la blague de nommer le jeu Mars Effect, en satire de Mass Effect 3 et sa fin. Un space opera façon 4X par Mojang ? Pourquoi pas.

On ne sait quasiment rien de ce nouveau jeu, si ce n’est une chose qui m’a plutôt émoustillé. Le vaisseau spacial que l’on pilote aura un ordinateur de bord… programmable ! Notch est allé jusqu’à créer un CPU virtuel dont il vient de fournir les spécifications techniques.

Résultat des courses ? J’annonce le développement de “DCPU-16 Devkit”, un projet d’environement de développement pour ce faux CPU. La spec’ étant relativement simple (plus simple que Chip8), cela devrait être assez rapide et amusant à faire. Ce que j’ai dans mes cartons :

  • Un émulateur DCPU-16
  • Un assembleur de binaires
  • Le tout dans un IDE avec débuggueur
  • Par la suite, un compilateur C

Mise à jour : c’était à prévoir, internet est plus fort que moi… moins de 24h après l’annonce des specs, j’ai à peine le temps de finir une journée de boulot qu’une pléthore d’émulateurs fleurissent, dont certains déjà bien complets. Il faut que j’arrête d’annoncer des trucs à la va vite. Je me sens con ^^.

Haa, ca me rappel le bon vieux temps des jeux “intelligents” où il fallait “programmer” pour gagner, MindRover en tête (d’ailleurs si vous connaissez d’autres jeux de ce genre, faites péter).

En attendant une release, je vais tout prochainement vous proposer une suite à mon tutoriel sur la création d’un émulateur Chip8 pour implémenter les extensions Super Chip8. L’article est presque fini.

Si vous n’êtes pas membres wefrag, vos commentaires peuvent être recueillis par mail :

contact

*jingle* Aujourd’hui, dans notre émission culinaire, nous allons voir comment préparer un émulateur via une recette de ma grand-mère. Mais attention, pas n’importe quel émulateur, puisque nous allons nous pencher sur le cas du Chip8.

emu

Avant propos : cet article est à but didactique, les éléments présentés sont donc simplifiés ou vulgarisés volontairement. De même, le code que je vous propose n’est pas de toute beauté / très optimisé afin de fluidifier l’article. Parler d’émulation et d’infrastructure bas niveau n’étant pas une chose aisée, toutes les remarques sur la clarté de cet article sont donc bienvenues.

Dans mon billet sur la Gameboy, j’avais présenté Chip8 comme étant le choix le plus judicieux pour apprendre la théorie de l’émulation.

Mais déjà, qu’est-ce qu’un émulateur ? C’est un programme qui simule ou reproduit à l’identique le fonctionnement d’une machine. En gros, un émulateur simule un processeur qui va tourner sur un autre processeur.

Or, ce fameux Chip8 n’est pas une machine qui a existé. En fait, il s’agit d’un langage interprété qui a été créé dans les 70’s afin de faciliter le développement de jeux vidéo.  Mais alors, pourquoi je vous parle d’émulation d’une machine si c’est un langage ? La particularité du Chip8 est qu’il ressemble beaucoup à un langage de bas niveau, proche de la machine (type assembleur). A un tel point que, programmer un interpreteur de Chip8 est un exercice qui ressemble à s’y méprendre à programmer un émulateur. La structure et la problématique est strictement la même. D’ailleurs, pour vous faire la main plus facilement, je ne vais pas parler d’interpréteur, mais bel et bien de CPU Chip8. Il a été conçu avec un jeu d’instructions très réduit, ce qui en fait un mets de choix pour l’apprentissage.

(Aparté : les gens tatillons sur les termes vous dirons peut être que la frontière entre émulateur, interpréteur et machine virtuelle est assez maigre.)

Présentation des caractéristiques techniques du Chip8 (attention, ca fait rêver) :

  • Une résolution d’affichage hallucinante de 64×32 pixels
  • Des couleurs monochromes d’une seule nuance
  • Affichage de sprites de 8 pixels de large et jusqu’à 15 pixels de haut
  • Un son mono-canal et mono fréquence (*beep* de carte mère)

Chip8 est devenu si populaire dans l’apprentissage de l’émulation que des enthousiastes ont créé des versions améliorées : SuperChip8 et MegaChip8. Voici un petit tableau qui reprend leurs caractéristiques :

tableau

Conversions numériques et conventions

Cet article n’a pas pour objectif de vous expliquer comment compter en base 2 (binaire), 10 (décimal) ou 16 (hexadécimal). Pour cela, référez vous à internet, au pif ici (si vous avez mieux, partagez ;-). Je ne vous apprendrai pas non plus l’algèbre de bool et autres opérations bit à bit (bitwise). Et je vais encore moins vous apprendre à programmer.

Par convention, je noterai les chiffres comme suit :

  • 1234 est un nombre en base 10
  • 0b010101 est un nombre binaire
  • 0x1234 est un nombre en hexadécimal
  • Tout chiffre succédé d’un h est une adresse (ex.: 0xFFFFh)

Qu’est-ce qu’un programme et quel est son lien avec un CPU ?

Comme on le dit si bien, en informatique, tout est fait de 0 et de 1. Un programme est un fichier qui ne contient qu’une bête série de 0 et de 1 (on parle de fichier binaire). Si vous ouvrez un programme quelconque avec un éditeur de texte, vous ne verrez que des caractères bizarres, un caractère correspondant à 8 bits (c’est à dire une série de huit 0 ou 1).

Ces bits décrivent des instructions (le langage machine) qui seront données telles quelles au processeur. Ces instructions sont codées suivant le processeur pour lequel elles ont été faites (un processeur ayant un jeu d’instructions précis). C’est d’ailleurs pour ca qu’un programme x86 ne peut pas tourner sur un processeur ARM.

Dans le cas du Chip8, toutes les instructions sont codées sur 16 bits et pour faire plus simple, on les traitera en hexadécimal. Un programme aura alors ce genre de tête :

0xAA56 0x5888 0xECAA 0xD78B
0x0EDC 0x1505 0xF5CA 0x2F04
0x59B8 0xE063 0x45C1 0x535B
0xE77E 0xC817 0x7078 0x044A...

Le seul job du processeur est alors de prendre ces nombres un à un, de les traduire en instructions et de les exécuter.

Ainsi, pour créer un émulateur, il faut écrire un programme qui lit un fichier binaire, traduit les nombres lus en instructions, et simule ces instructions. Pour faire tout ca, il convient de connaître ces instructions et la structure du processeur à simuler.

Structure basique d’un processeur

De façon très simplifiée, un processeur se compose des éléments suivants :

  • Un jeu d’instructions
  • Une mémoire principale (RAM), qui contient le programme et sert à stocker le résultat des calculs
  • Un compteur, qui indique à quelle instruction est le CPU (communément appelé Program Counter, ou PC)
  • Un ensemble de registres qui sont des blocs de mémoire indépendants de la RAM et qui servent au CPU à exécuter ses instructions

Ces différentes caractéristiques représentent ce qu’on appel les spécifications techniques d’un processeur. Faisons le parallèle avec les spécifications du Chip8 :

  • 34 instructions (10 de plus en SuperChip8 et encore 11 de plus en MegaChip8)
  • 4 ko de mémoire 8bit (16 mo en MegaChip8)
  • Le compteur PC, 12bit (16bit en MegaChip8)
  • 16 registres 8bit nommés V0, V1, V2… VF
  • 1 registre 16bit nommé I

Et voici le tableau des instructions, qu’il est préférable d’imprimer et de toujours avoir sous le coude (uniquement Chip8 de base) :

opcode

Une documentation plus complète et technique peut se trouver ici.

Pour résumer tout ce que l’on vient de voir, voici un schéma qui sera peut être plus clair :

archi

Un petit mot sur la mémoire

Nous avons vu que le CPU possède “4ko de mémoire 8bit”. Qu’est-ce que cela signifie ? Une mémoire est une succession de blocs d’une certaine taille, en l’occurrence 8bit. Pour créer 4ko de mémoire, il faut donc 4096 blocs de 8bit. Chaque bloc est numéroté (à partir de 0) afin que l’on puisse y accéder.

memory

Le numéro d’un bloc est ce qu’on appelle son adresse. On appelle aussi “espace adressable”, la quantité de blocs qu’un CPU peut gérer. Cette quantité est directement liée à PC et le nombre de bits sur lequel il est codé. Ici, nous avons un PC 12bit, qui peut compter de 0×0 à 0xFFF (0 à 4095), soit 4096 blocs tout pile.

C’est partie !

Nous avons maintenant tout ce qu’il nous faut pour commencer à programmer notre émulateur. Je vais pour ma part baser mon article sur une programmation en C# avec Visual Studio. Vous pouvez, si vous le souhaitez, télécharger le Windows Phone SDK, qui contient une version gratuite de Visual Studio et de ce dont nous avons besoin (même si on ne va pas faire une application Windows Phone). Si vous n’êtes pas branché C#, il ne devrait pas être difficile de traduire mes exemples dans votre langage préféré.

Commencez donc par créer un nouveau projet WPF (”Fichier/Nouveau/Projet…”, onglet “Windows”, puis “Application WPF”), puis ajoutez-y une nouvelle classe que l’on va nommer “Chip8″. Nous allons dans un premier temps nous attaquer à l’architecture du CPU, c’est à dire la mémoire et les registres (cf. doc technique). Pour cela, il suffit de suivre la doc à la lettre et de la transposer en code (si tout le texte n’apparait pas, passez sur une colonne).

    public class Chip8
    {
        // Mémoire principale
        // Chip8 possède "4 ko de mémoire 8bit", soit 4096 blocs de 8bit
        // On transpose ca en un tableau de 4096 "byte", le type byte étant un entier
        // non signé codé sur 8 bits
        private byte[] _memory = new byte[4096];

        // Program Counter
        // Chip8 possède "un compteur PC, 12bit"
        // Un type de 12bit n'existe pas, on va donc utiliser un type de borne supérieure,
        // en l'occurence ushort (entier non signé sur 16 bits)
        // La documentation dit qu'un programme démarre à l'adresse 0x200h
        private ushort _PC = 0x200;

        // Registres
        // Chip8 possède "16 registres 8bit nommés V0, V1, V2… VF"
        // Pour travailler avec ces 16 registres plus facilement, nous allons en faire
        // un tableau de 16 valeurs (indexé de 0x0 à 0xF)
        private byte[] _V = new byte[16];
        // Plus "1 registre 16bit nommé I"
        private ushort _I;
    }

Nous avons vu qu’un programme est un fichier binaire et que ce programme doit être chargé en mémoire. Pour cela, nous allons ajouter un menu à notre émulateur pour sélectionner un fichier, qui va être lu puis chargé en mémoire.

Ajoutons d’abord un menu à notre fenêtre en éditant le fichier MainWindow.xaml (concentrez vous sur ce qui est en gras) :

<Window x:Class="MegaChip8Emulator.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Menu Height="23" Name="menu1" VerticalAlignment="Top">
            <MenuItem Header="Fichier">
                <MenuItem Header="Charger une ROM" />
                <Separator />
                <MenuItem Header="Quitter" />
            </MenuItem>
        </Menu>
    </Grid>
</Window>

Nous allons ajouter un événement au menu “Charger une ROM”, pour cela placer votre curseur sur la ligne de texte de “Charger une ROM”, puis dans la fenêtre propriétés, allez à l’onglet “événements” (avec la petite icône d’un éclair), et double-cliquez sur l’événement “Click”. Cela devrait vous ouvrir le fichier de code correspondant et vous avoir généré une méthode toute prête pour notre événement. Ajoutons-y le code pour charger un fichier binaire.

private void MenuItem_Click(object sender, RoutedEventArgs e)
{
    // On initialise un dialog d'ouverture de fichiers
    Microsoft.Win32.OpenFileDialog ofd = new Microsoft.Win32.OpenFileDialog();
    ofd.DefaultExt = ".ch8";
    ofd.Filter = "Chip8 ROM files (.ch8)|*.ch8";
    ofd.Multiselect = false;

    // On ouvre le dialog
    Nullable<bool> result = ofd.ShowDialog();

    // Si un fichier a été sélectionné
    if (result == true)
    {
        // On récupère son chemin
        string filename = ofd.FileName;

        // Et on l'ouvre
        using (System.IO.FileStream fs = new System.IO.FileStream(filename,
                System.IO.FileMode.Open))
        {
            // On l'ouvre en mode binaire
            using (System.IO.BinaryReader br = new System.IO.BinaryReader(fs))
            {
                // Et on le mets dans un tableau de byte
                byte[] rom = new byte[fs.Length];
                for (int i = 0; i < fs.Length; i++)
                    rom[i] = br.ReadByte();
                // Puis on le donne à notre émulateur
                Chip8Emulator.Load(rom);
            }
        }
    }
}

A ce niveau la, vous remarquez qu’il y a une erreur sur Chip8Emulator.Load(rom), ce qui est normal puisque nous n’avons pas déclaré cet objet qui est de classe Chip8 (qui n’a d’ailleurs pas encore de méthode Load). Attelons-nous y, toujours dans le même fichier, on déclare l’objet au niveau de la classe :

    public partial class MainWindow : Window
    {
        Chip8 Chip8Emulator = new Chip8();

Et enfin dans notre classe Chip8, on rajoute la méthode Load :

// Charge une ROM en mémoire
public void Load(byte[] rom)
{
    // On fait une bête copie en mémoire de la ROM
    // Mais attention, la doc dit que "les programmes Chip8
    // commencent à l'adresse 0x200h, car les 512 premiers blocs
    // sont réservés au CPU"
    for (int i = 0; i < rom.Length; i++)
        _memory[0x200 + i] = rom[i];
}

A ce niveau la, vous pouvez lancer ce squelette d’émulateur et tester le chargement de ROM (vous pouvez trouver l’intégralité des ROM dans un seul zip ici). Bien entendu, après avoir chargé une ROM, il ne se passe absolument rien (mais ca ne devrait pas planter, et c’est déjà ca).

Dispatcher et instructions

Maintenant que notre programme est chargé, il faut l’émuler. Le procédé est assez simple, il suffit de lire le bloc pointé par PC, de décoder l’instruction, et de l’exécuter. Pour ca, nous allons coder un “dispatcher”, avec un bon vieux switch “C-Style” des chaumières :

        // Les paramètres possibles d'une instruction
        ushort _NNN;
        byte _KK;
        byte _K;
        byte _X;
        byte _Y;        

        // Emule un cycle du CPU
        public void Emulate()
        {
            // On récupère l'instruction 16bit sur laquelle pointe PC
            // Pour ca on lit 2 byte 8bit que l'on stock sur 16 bits
            ushort opcode = (ushort)((_memory[_PC] << 8) + _memory[_PC + 1]);

            // Puis on incrémente PC d'autant de blocs que l'on vient de lire, soit 2
            _PC += 2;

            // On extrait les différents paramètres possibles d'une instruction à l'avance
            ushort _Code = (ushort)(opcode & 0xF000);
            _NNN = (ushort)(opcode & 0x0FFF);
            _KK = (byte)(opcode & 0x00FF);
            _K = (byte)(opcode & 0x000F);
            _X = (byte)((opcode & 0x0F00) >> 8);
            _Y = (byte)((opcode & 0x00F0) >> 4);

            // Maintenant on décode l'opcode et on le redirige vers la bonne instruction (dispatcher)
            switch (_Code)
            {
                case 0x0000:
                    switch (_KK)
                    {
                        case 0xE0: Inst_00E0(); break;
                        case 0xEE: Inst_00EE(); break;
                    }
                    break;
                case 0x1000: Inst_1NNN(); break;
                case 0x2000: Inst_2NNN(); break;
                case 0x3000: Inst_3XKK(); break;
                case 0x4000: Inst_4XKK(); break;
                case 0x5000: Inst_5XY0(); break;
                case 0x6000: Inst_6XKK(); break;
                case 0x7000: Inst_7XKK(); break;
                case 0x8000:
                    switch (_K)
                    {
                        case 0x0: Inst_8XY0(); break;
                        case 0x1: Inst_8XY1(); break;
                        case 0x2: Inst_8XY2(); break;
                        case 0x3: Inst_8XY3(); break;
                        case 0x4: Inst_8XY4(); break;
                        case 0x5: Inst_8XY5(); break;
                        case 0x6: Inst_8XY6(); break;
                        case 0x7: Inst_8XY7(); break;
                        case 0xE: Inst_8XYE(); break;
                    }
                break;
                case 0x9000: Inst_9XY0(); break;
                case 0xA000: Inst_ANNN(); break;
                case 0xB000: Inst_BNNN(); break;
                case 0xC000: Inst_CXKK(); break;
                case 0xD000: Inst_DXYK(); break;
                case 0xE000:
                    switch (_KK)
                    {
                        case 0x9E: Inst_EX9E(); break;
                        case 0xA1: Inst_EXA1(); break;
                    }
                break;
                case 0xF000:
                switch (_KK)
                    {
                        case 0x07: Inst_FX07(); break;
                        case 0x0A: Inst_FX0A(); break;
                        case 0x15: Inst_FX15(); break;
                        case 0x18: Inst_FX18(); break;
                        case 0x1E: Inst_FX1E(); break;
                        case 0x29: Inst_FX29(); break;
                        case 0x33: Inst_FX33(); break;
                        case 0x55: Inst_FX55(); break;
                        case 0x65: Inst_FX65(); break;
                    }
                break;
            }

Nous devons maintenant écrire toutes les instructions auxquelles nous venons de faire référence. Si vous avez des difficultés ou des bugs, vous trouverez le code source complet en fin d’article ;-). Je vais juste me concentrer sur les plus spécifiques et ne vais pas tout détailler car logique derrière certaines instructions est souvent proche d’autres. Pour aller plus vite et pouvoir compiler votre code, vous pouvez générer des méthodes vides, pour cela, avec Visual Studio, cliquez sur un des appels à méthode qui n’existe pas, et cliquez sur l’option “Générer le stub de cette méthode” qui apparait. Cela vous créera des méthodes vides qui passent la compilation.

Autre point, je ne vais pas revenir sur la signification de certaines instructions, volontairement, pour que vous vous référiez au tableau de documentation. Car lorsque vous codez un émulateur (ou n’importe quoi d’autre d’ailleurs), il n’y a personne pour vous tenir la main et c’est à vous de vous servir à bon escient d’une documentation.

Jump, sub-routine et pile d’appels

Pour commencer, nous allons voir la notion de jump et ses instructions associées. L’instruction 1NNN est un jump, c’est à dire un saut du Program Counter. Cela consiste simplement à faire pointer PC sur NNN :

        private void Inst_1NNN()
        {
            _PC = _NNN;
        }

2NNN est un jump un peu particulier, c’est un appel de sous-routine. Une sous-routine est un sous-programme (une fonction si vous préférez). La différence avec un bête jump, c’est que, comme dans un langage de programmation, toute routine, une fois terminée, revient la où le programme principal c’était arrêté avant son appel. Pour cela, il faut garder quelque part la valeur de PC telle qu’elle était avant l’appel à la routine. De plus, une routine peut appeler une autre routine, et ainsi donner lieux à une cascade d’appels dont il faut garder la trace. Pour cela, nous allons mettre en place une pile (ou liste LIFO) sur laquelle on va empiler les appels :

        // La pile d'appels
        private Stack<ushort> _CallStack = new Stack<ushort>();

        private void Inst_2NNN()
        {
            // On sauvegarde l'adresse de retour
            _CallStack.Push(_PC);
            // On jump à la sous-routine
            _PC = _NNN;
        }

Maintenant que l’on sait appeler une routine, il faut que l’on puisse terminer celle-ci et revenir au programme supérieur. C’est l’instruction 00EE qui s’en charge et qui consiste à dés-empiler la dernière valeur sur la pile :

        private void Inst_00EE()
        {
            _PC = _CallStack.Pop();
        }

Enfin, dernier jump dans son genre, le jump relatif BNNN, qui saute à l’adresse NNN + V0 :

        private void Inst_BNNN()
        {
            _PC = (ushort)(_NNN + _V[0]);
        }

Branchements conditionnels

Il y a un ensemble d’instructions dédiées aux branchements conditionnels. Par exemple, l’instruction 3XKK saute l’instruction suivante (c’est à dire, incrémente PC de 2) si VX = KK. Cela permet de faire des branchements conditionnels (if/then/else). Si la condition n’est pas remplie (si VX est différent de KK dans le cas de l’instruction 3XKK), l’instruction suivante est généralement un appel à une sous-routine (le contenu du “else”) alors que si on saute, le programme continu (le “then”). Toutes ces instructions ont une logique similaire, je ne vais donc pas les détailler mais seulement vous pondre le code :

        private void Inst_3XKK()
        {
            if (_V[_X] == _KK)
                _PC += 2;
        }

        private void Inst_4XKK()
        {
            if (_V[_X] != _KK)
                _PC += 2;
        }

        private void Inst_5XY0()
        {
            if (_V[_X] == _V[_Y])
                _PC += 2;
        }

        private void Inst_9XY0()
        {
            if (_V[_X] != _V[_Y])
                _PC += 2;
        }

Read / Write et affectations

Prochaines instructions : les lectures / écritures mémoire et les affectations. Ces instructions servent à lire/écrire des résultats de calculs ou à initialiser des valeurs. La documentation est assez claire à sujet et ces instructions sont triviales :

        private void Inst_6XKK()
        {
            _V[_X] = _KK;
        }

        private void Inst_8XY0()
        {
            _V[_X] = _V[_Y];
        }

        private void Inst_ANNN()
        {
            _I = _NNN;
        }

        private void Inst_FX55()
        {
            for (int i = 0; i <= _X; i++)
                _memory[_I + i] = _V[i];
        }

        private void Inst_FX65()
        {
            for (int i = 0; i <= _X; i++)
                _V[i] = _memory[_I + i];
        }

Opérations bitwise

Il y a quelques instructions qui permettent de faire les opérations bit à bit les plus usuelles (AND, OR, XOR) :

        private void Inst_8XY1()
        {
            _V[_X] |= _V[_Y];
        }

        private void Inst_8XY2()
        {
            _V[_X] &= _V[_Y];
        }

        private void Inst_8XY3()
        {
            _V[_X] ^= _V[_Y];
        }

Opérations mathématiques et bitwise à Flag

En informatique bas niveau, il y a une notion des plus importantes, les “flags”. Prenons un exemple, une bête addition : 120 + 200 = 320. Problème, nous travaillons avec des registres et une mémoire 8bit. Un bloc ne peut contenir que des valeurs allant de 0 à 255, donc pour stocker 320, on l’a dans le baba, et quand cela arrive, on parle d’overflow (dépassement de capacité). C’est la que la notion de flag intervient. Lorsque qu’on affecte 320 à un bloc 8bit, la valeur qui sera sauvegardée correspond au modulo 255 de la valeur (donc dans notre cas, 320 modulo 255, c’est à dire 65). Mais on a aussi généré une retenue, le “carry flag”, qui va garder la trace de l’overflow afin de pouvoir en tenir compte dans les prochains calculs. En Chip8, le flag est toujours stocké dans le registre VF.

La documentation des instructions expliquent bien quand un flag doit être levé. Le seul truc à avoir en tête quand on implémente ces instructions, est de travailler des variables temporaires de capacité supérieure, afin de tester si il y a eu overflow ou non. Pour ce qui est de l’implémentation, la voici :

        private void Inst_8XY4()
        {
            int tmp = _V[_X] + _V[_Y];
            if (tmp > 255)
                _V[0xF] = 1;
            else
                _V[0xF] = 0;
            _V[_X] = (byte)(tmp & 0xFF);
        }

        private void Inst_8XY5()
        {
            if (_V[_X] > _V[_Y])
                _V[0xF] = 1;
            else
                _V[0xF] = 0;
            _V[_X] -= _V[_Y];
        }

        private void Inst_8XY6()
        {
            _V[0xF] = (byte)(_V[_X] & 0x1);
            _V[_X] >>= 1;
        }

        private void Inst_8XY7()
        {
            if (_V[_Y] > _V[_X])
                _V[0xF] = 1;
            else
                _V[0xF] = 0;
            _V[_X] = (byte)(_V[_Y] - _V[_X]);
        }

        private void Inst_8XYE()
        {
            _V[0xF] = (byte)((_V[_X] & 0x80) >> 7);
            _V[_X] <<= 1;
        }

Timers

Chip8 possède deux timers 8bit : un d’usage général (appelé DT) et un pour le son (appelé ST, que je détaillerai plus tard). Leur principe est simple, à chaque instruction exécutée, ces timers sont décrémentés de 1. Si ils sont déjà à 0, ils le restent. Cela permet de compter le nombre d’instructions exécutées et, par extrapolation avec la fréquence du CPU, le temps écoulé. On déclare donc nos timers avec le reste des registres :

        // Timers 8bit
        private byte _DT = 0;
        public byte _ST = 0;

Et à la fin de notre méthode “Emulate” (après le gros switch), on rajoute la gestion de la décrémentation :

            if (_DT > 0)
                _DT--;
            if (_ST > 0)
                _ST--;

Viennent ensuite trois instructions qui permettent de travailler avec ces timers :

        private void Inst_FX07()
        {
            _V[_X] = _DT;
        }

        private void Inst_FX15()
        {
            _DT = _V[_X];
        }

        private void Inst_FX18()
        {
            _ST = _V[_X];
        }

Divers : représentation BCD, nombres aléatoires et autres opérations

Vous l’avez peut être constater, travailler avec de la mémoire 8bit non-signée pose un problème : on ne peut pas travailler avec des nombres à virgules ou de trop grands nombres. Pour palier à ce problème, il existe le codage Binary-Coded Decimal (BCD) qui permet de stocker un nombre à virgule sur plusieurs octets. Chip8 possède une instruction qui stock la valeur de VX dans un format BCD. Je vous épargne son explication, voici juste son implémentation :

        private void Inst_FX33()
        {
            _memory[_I] = (byte)(_V[_X] / 100);
            _memory[_I + 1] = (byte)((_V[_X] % 100) / 10);
            _memory[_I + 2] = (byte)((_V[_X] % 100) % 10);
        }

L’instruction CXKK quand à elle, permet de générer un nombre aléatoire. En plus de cela, elle fait un ET logique avec KK sur cette valeur. Pas vraiment utile, mais ca rajoute une complexité pour des algorithmes de génération de nombres aléatoires qui, à l’époque, n’était pas très performants / aléatoires. Pour la peine, on va quand même en faire de même. Pour le nombre aléatoire, on s’embête pas et on utilise directement ce que .NET nous offre :

        private Random _RandGenerator = new Random();

        private void Inst_CXKK()
        {
            _V[_X] = (byte)(_KK & (byte)_RandGenerator.Next(0, 256));
        }

Et sinon, on a deux instructions triviales qui sont assez quelconques mais qui ne rentraient dans les précédentes catégories :

        private void Inst_7XKK()
        {
            _V[_X] += _KK;
        }

        private void Inst_FX1E()
        {
            _I += _V[_X];
        }

Gestion de l’entrée clavier

Chip8 peut gérer jusqu’à 16 touches (numérotées de 0×0 à 0xF), qui sont généralement celles du pavé numérique. La pavé numérique du clavier Chip8 avait généralement cette forme :

1 2 3 C
4 5 6 D
7 8 9 E
A 0 B F

Afin de savoir quelles touches sont appuyées ou non, nous allons travailler avec un tableau de booléens, un par touche possible, soit 16. Si le booléen est à “vrai”, on considère la touche comme étant appuyée. Ainsi, notre émulateur sera générique et on pourra y mapper les touches que l’on veut par la suite sans avoir à retoucher à ce code. Voici donc l’implémentation et les trois instructions qui vont avec :

        public bool[] Keyboard = new bool[16];

        private void Inst_EX9E()
        {
            if (Keyboard[_V[_X]] == true)
                _PC += 2;
        }

        private void Inst_EXA1()
        {
            if (Keyboard[_V[_X]] == false)
                _PC += 2;
        }

        private void Inst_FX0A()
        {
            while (true)
                for (byte i = 0; i < 16; i++)
                    if (Keyboard[i] == true)
                    {
                        _V[_X] = i;
                        return;
                    }
        }

Les graphismes

Dernier morceau mais pas des moindres, la sortie vidéo. Pour créer notre sortie vidéo, nous allons travailler avec un “frame buffer”, c’est à dire une mémoire tampon qui va contenir le rendu d’une image entière, prête à être affichée. Nous allons coder les couleurs de notre frame buffer en BGRA (Blue, Green, Red, Alpha) avec un byte par composante, soit 4 bytes par pixel. Nous avons une résolution d’affichage de 64×32, soit 2048 pixels, soit 8192 bytes nécessaires. Il nous faut donc un tableau de 8192 bytes pour stocker une image (au niveau de la classe Chip8) :

        private byte[] _ScreenBuffer = new byte[8192];

La première instruction graphique est la plus triviale, 0E00, et consiste à effacer tout ce qu’il y a à l’écran (soit mettre tous les pixels à la couleur noir) :

        private void Inst_00E0()
        {
            // noir = {B = 0, G = 0, R = 0, A = 255}
            for (int i = 0; i < _ScreenBuffer.Length; i += 4)
            {
                _ScreenBuffer[i] = 0;
                _ScreenBuffer[i + 1] = 0;
                _ScreenBuffer[i + 2] = 0;
                _ScreenBuffer[i + 3] = 255;
            }
        }

L’instruction DXYK est surement la plus compliquée de toute. C’est elle qui affiche les graphismes à l’écran. Elle lit K lignes de pixels en mémoire et les affichage aux coordonnées (X,Y). Il faut également savoir, qu’en informatique, l’origine d’un repère graphique n’est pas en bas à gauche, mais en haut à gauche. L’axe Y est également inversé :

repereChip8 a une façon un peu particulière d’afficher des pixels. Ces derniers sont dessinés suivant le principe du XOR :

  • Dessiner du blanc sur un pixel noir, donne du blanc.
  • Dessiner du noir sur un pixel blanc, donne du blanc.
  • Dessiner du noir sur un pixel noir, donne du noir.
  • Dessiner du blanc sur un pixel blanc, donne du noir.

Cette méthode un peu bizarre a son utilité. Dans le dernier cas, on peut détecter si deux sprites entrent en collision. Si tel est le cas, on met le flag (VF) à 1. Ainsi, Chip8 combine affichage et détection de collisions en une seule fois.

        private void Inst_DXYK()
        {
            // On reset le flag
            _V[0xF] = 0;
            // Pour chaque ligne de pixels à écrire
            for (int i = 0; i < _K; i++)
            {
                byte ligne = _memory[_I + i];
                // Pour chaque pixel dans la ligne
                for (int n = 0; n < 8; n++)
                {
                    // On extrait le pixel à écrire
                    byte pixel = (byte)((byte)(ligne << n) >> 7);
                    // On calcul l'adresse du pixel dans le frame buffer
                    int pixelAddress = _V[_X] + n + (_V[_Y] + i) * 64;
                    pixelAddress *= 4;
                    pixelAddress %= _ScreenBuffer.Length;
                    // On fait la somme des composantes, pour savoir si il y a une couleur
                    int couleur = _ScreenBuffer[pixelAddress] + _ScreenBuffer[pixelAddress + 1] +_ScreenBuffer[pixelAddress + 2];
                    // On l'écrit en XOR
                    lock (_ScreenBuffer)
                    {
                        if ((pixel != 0 && couleur == 0) || (pixel == 0 && couleur != 0))
                        {
                            _ScreenBuffer[pixelAddress] = 255;
                            _ScreenBuffer[pixelAddress + 1] = 255;
                            _ScreenBuffer[pixelAddress + 2] = 255;
                            _ScreenBuffer[pixelAddress + 3] = 255;
                        }
                        else
                        {
                            _ScreenBuffer[pixelAddress] = 0;
                            _ScreenBuffer[pixelAddress + 1] = 0;
                            _ScreenBuffer[pixelAddress + 2] = 0;
                            _ScreenBuffer[pixelAddress + 3] = 255;
                        }
                    }

                    // Et on détecte la collision
                    if (pixel != 0 && couleur != 0)
                        _V[0xF] = 1;
                }
            }
        }

Voilà, le gros du boulot est fait et il ne nous reste plus qu’un point à voir : la police d’écriture. Chip8 permet d’écrire du texte. Ce texte est en fait représenté par des sprites et sont affichés avec l’instruction que l’on tout juste vient d’écrire. Les sprites, quand à eux, sont stockés dans la partie protégée de la mémoire Chip8 (dans la plage d’adresse 0×0h à 0×1FFh). Nous allons devoir initialiser cette mémoire avec les sprites des caractères dedans. Pour cela, nous allons mettre tout ca dans un constructeur de la classe Chip8. Je vous épargne la compréhension de ces sprites, il est préférable de copier / coller le contenu suivant (pour les explications et un tableau plus compréhensible, c’est par ici dans la doc) :

        public Chip8()
        {
            _memory[0] = 240; _memory[1] = 144; _memory[2] = 144; _memory[3] = 144; _memory[4] = 240;
            _memory[5] = 32; _memory[6] = 96; _memory[7] = 32; _memory[8] = 32; _memory[9] = 112;
            _memory[10] = 240; _memory[11] = 16; _memory[12] = 240; _memory[13] = 128; _memory[14] = 240;
            _memory[15] = 240; _memory[16] = 16; _memory[17] = 240; _memory[18] = 16; _memory[19] = 240;
            _memory[20] = 144; _memory[21] = 144; _memory[22] = 240; _memory[23] = 16; _memory[24] = 16;
            _memory[25] = 240; _memory[26] = 128; _memory[27] = 240; _memory[28] = 16; _memory[29] = 240;
            _memory[30] = 240; _memory[31] = 128; _memory[32] = 240; _memory[33] = 144; _memory[34] = 240;
            _memory[35] = 240; _memory[36] = 16; _memory[37] = 32; _memory[38] = 64; _memory[39] = 64;
            _memory[40] = 240; _memory[41] = 144; _memory[42] = 240; _memory[43] = 144; _memory[44] = 240;
            _memory[45] = 240; _memory[46] = 144; _memory[47] = 240; _memory[48] = 16; _memory[49] = 240;
            _memory[50] = 240; _memory[51] = 144; _memory[52] = 240; _memory[53] = 144; _memory[54] = 144;
            _memory[55] = 224; _memory[56] = 144; _memory[57] = 224; _memory[58] = 144; _memory[59] = 224;
            _memory[60] = 240; _memory[61] = 128; _memory[62] = 128; _memory[63] = 128; _memory[64] = 240;
            _memory[65] = 224; _memory[66] = 144; _memory[67] = 144; _memory[68] = 144; _memory[69] = 224;
            _memory[70] = 240; _memory[71] = 128; _memory[72] = 240; _memory[73] = 128; _memory[74] = 240;
            _memory[75] = 240; _memory[76] = 128; _memory[77] = 240; _memory[78] = 128; _memory[79] = 128;
        }

Et pour clore le spectacle, il n’y a plus que l’ultime instruction, qui permet de pointer sur la première ligne du sprite du caractère souhaité :

        private void Inst_FX29()
        {
            _I = (ushort)(_V[_X] * 5);
        }

Et voilà ! Vous pouvez presque sabrer le champagne, car votre émulateur est “terminé” ! Enfin du moins toute la partie logique propre à l’émulation elle-même. Il ne reste plus qu’à interfacer le frame buffer avec l’interface WPF, de même pour le clavier et le son. La seule “difficulté” restante est de faire tourner notre émulateur avec un peu de multi-thread.

Faire tourner notre émulateur dans WPF

Avant d’attaquer cette partie, il est nécessaire de revenir à la définition d’un CPU. Un CPU est cadencé à une certaine fréquence. Par exemple, une fréquence de 1Mhz signifie que le CPU fait 1.000.000 de cycles par seconde. Une instruction dure généralement plusieurs cycles suivant sa complexité (une addition en prend peu, alors qu’une grosse lecture en mémoire va en prendre beaucoup). Le problème avec Chip8 est qu’il n’est pas un CPU et que sa documentation ne spécifie donc pas la durée de chaque instruction et encore moins sa fréquence d’horloge. Pour créer un émulateur, il est nécessaire de savoir la vitesse du CPU et le temps que prend chaque instruction, afin que les programmes tournent à la même vitesse que sur le matériel d’origine.

L’absence de doc à ce sujet pour Chip8 fait que chaque émulateur fait un peu à sa sauce et fixe une vitesse arbitraire. Dans notre cas, on va considérer que toutes les instructions durent 1 cycle, et que le CPU est cadencé à 60hz (60 instructions par seconde).

Pour intégrer notre émulateur à l’interface WPF, nous allons faire du multi-threading ultra basique. Nous aurons un thread pour faire tourner l’émulateur à 60hz, et un autre thread qui affiche le frame buffer à hauteur de 60 images par seconde (60hz aussi). Pour afficher le frame buffer, on va utiliser un WriteableBitmap, une méthode assez triviale et pas très performante pour afficher des images avec WPF, mais qui suffit très largement dans notre cas. Retournons donc à notre fichier MainWindow.xaml.cs et ajoutons quelques objets comme membres de la classe MainWindow :

        // Le timer / thread pour le rendu
        DispatcherTimer RenderTimer = new DispatcherTimer();
        // Le bitmap pour afficher le screen buffer
        WriteableBitmap writeableBitmap = new WriteableBitmap(64, 32, 60, 60, PixelFormats.Bgra32, null);
        // Le timer pour le timing CPU
        DispatcherTimer Chip8Timer = new DispatcherTimer();

A ce niveau la, il est possible que Visual Studio ne trouve pas DispatcherTimer. Il suffit de rajouter “using  System.Windows.Threading;” au début du fichier pour lui dire que l’on souhaite utiliser la lib de threading.

Dans l’interface, MainWindow.xaml, nous allons rajouter l’image qui servira d’hôte au buffer :

    ...
        </Menu>
        <Image Margin="0,23,0,0" Name="frameBufferImage" Stretch="Uniform" />
    </Grid>
    ...

Et dans le construction de MainWindow, on fait le lien entre l’image et le WriteableBitmap :

        public MainWindow()
        {
            InitializeComponent();
            frameBufferImage.Source = writeableBitmap;
        }

Maintenant, toujours dans MainWindow.xaml.cs, nous allons rajouter du code à la méthode de chargement d’une ROM pour démarrer notre émulateur :

                        // Et on le mets dans un tableau de byte
                        byte[] rom = new byte[fs.Length];
                        for (int i = 0; i < fs.Length; i++)
                            rom[i] = br.ReadByte();

                        // On stop l'émulation le temps du chargement
                        Chip8Timer.Stop();
                        // Puis on le donne à notre émulateur
                        Chip8Emulator.Load(rom);

                        RenderTimer.Interval = TimeSpan.FromSeconds(1 / 60);
                        RenderTimer.Tick += new EventHandler(Render);
                        RenderTimer.Start();

                        Chip8Timer.Interval = TimeSpan.FromSeconds(1 / 60);
                        Chip8Timer.Tick += new EventHandler(CPUCycle);
                        Chip8Timer.Start();

Nous aurons donc 2 threads, qui vont appeler 60 fois par seconde les méthodes Render et CPUCycle. CPUCycle est la plus triviale :

        private void CPUCycle(object sender, EventArgs e)
        {
            Chip8Emulator.Emulate();
        }

Render est un peu plus tricky, puisque la manip consiste à copier la mémoire du frame buffer dans la mémoire du WriteableBitmap. Heureusement, la classe Marshal possède des méthodes “helper” pour copier des tableaux de bits (et nécessite un “using System.Runtime.InteropServices;”) :

        private void Render(object sender, EventArgs e)
        {
            byte[] data = Chip8Emulator.GetScreenBuffer();
            if (data != null)
            {
                writeableBitmap.Lock();
                Marshal.Copy(data, 0, writeableBitmap.BackBuffer, data.Length);
                writeableBitmap.AddDirtyRect(
                    new Int32Rect(0, 0, 64, 32));
                writeableBitmap.Unlock();
            }
        }

Et la méthode GetScreenBuffer que nous devons rajouter à la classe Chip8 :

        public byte[] GetScreenBuffer()
        {
            lock (_ScreenBuffer)
            {
                return _ScreenBuffer;
            }
        }

A ce niveau la, notre émulateur devrait être parfaitement fonctionnel :-). Essayez le en chargeant des jeux ou des démos. Il ne nous reste plus que deux points à aborder : interfacer le clavier, et le son.

Interface clavier

Pour rappel, Chip8 utilise généralement 16 touches du pavé numérique avec ce layout :

1 2 3 C
4 5 6 D
7 8 9 E
A 0 B F

Que nous allons mapper à ces touches du clavier :

7 8 9 /
4 5 6 *
1 2 3 -
+ 0 . Enter

Pour cela, nous allons ajouter un événement “Key Up” et un “Key Down” à MainWindow. Et voici le corps des méthodes en question :

        private void Window_KeyDown(object sender, KeyEventArgs e)
        {
            if (e.Key == Key.NumPad0)
                Chip8Emulator.Keyboard[0x0] = true;
            if (e.Key == Key.NumPad7)
                Chip8Emulator.Keyboard[0x1] = true;
            if (e.Key == Key.NumPad8)
                Chip8Emulator.Keyboard[0x2] = true;
            if (e.Key == Key.NumPad9)
                Chip8Emulator.Keyboard[0x3] = true;
            if (e.Key == Key.NumPad4)
                Chip8Emulator.Keyboard[0x4] = true;
            if (e.Key == Key.NumPad5)
                Chip8Emulator.Keyboard[0x5] = true;
            if (e.Key == Key.NumPad6)
                Chip8Emulator.Keyboard[0x6] = true;
            if (e.Key == Key.NumPad1)
                Chip8Emulator.Keyboard[0x7] = true;
            if (e.Key == Key.NumPad2)
                Chip8Emulator.Keyboard[0x8] = true;
            if (e.Key == Key.NumPad3)
                Chip8Emulator.Keyboard[0x9] = true;
            if (e.Key == Key.OemPlus)
                Chip8Emulator.Keyboard[0xA] = true;
            if (e.Key == Key.OemPeriod)
                Chip8Emulator.Keyboard[0xB] = true;
            if (e.Key == Key.Divide)
                Chip8Emulator.Keyboard[0xC] = true;
            if (e.Key == Key.Multiply)
                Chip8Emulator.Keyboard[0xD] = true;
            if (e.Key == Key.OemMinus)
                Chip8Emulator.Keyboard[0xE] = true;
            if (e.Key == Key.Enter)
                Chip8Emulator.Keyboard[0xF] = true;
        }

        private void Window_KeyUp(object sender, KeyEventArgs e)
        {
            if (e.Key == Key.NumPad0)
                Chip8Emulator.Keyboard[0x0] = false;
            if (e.Key == Key.NumPad7)
                Chip8Emulator.Keyboard[0x1] = false;
            if (e.Key == Key.NumPad8)
                Chip8Emulator.Keyboard[0x2] = false;
            if (e.Key == Key.NumPad9)
                Chip8Emulator.Keyboard[0x3] = false;
            if (e.Key == Key.NumPad4)
                Chip8Emulator.Keyboard[0x4] = false;
            if (e.Key == Key.NumPad5)
                Chip8Emulator.Keyboard[0x5] = false;
            if (e.Key == Key.NumPad6)
                Chip8Emulator.Keyboard[0x6] = false;
            if (e.Key == Key.NumPad1)
                Chip8Emulator.Keyboard[0x7] = false;
            if (e.Key == Key.NumPad2)
                Chip8Emulator.Keyboard[0x8] = false;
            if (e.Key == Key.NumPad3)
                Chip8Emulator.Keyboard[0x9] = false;
            if (e.Key == Key.OemPlus)
                Chip8Emulator.Keyboard[0xA] = false;
            if (e.Key == Key.OemPeriod)
                Chip8Emulator.Keyboard[0xB] = false;
            if (e.Key == Key.Divide)
                Chip8Emulator.Keyboard[0xC] = false;
            if (e.Key == Key.Multiply)
                Chip8Emulator.Keyboard[0xD] = false;
            if (e.Key == Key.OemMinus)
                Chip8Emulator.Keyboard[0xE] = false;
            if (e.Key == Key.Enter)
                Chip8Emulator.Keyboard[0xF] = false;
        }

Touche finale, le son

La partir son d’un émulateur est parfois la chose la plus chiante à faire. Il faut faire des accès son de bas niveau, générer des ondes, etc.  Fort heureusement pour nous, le son dans Chip8 est tout ce qu’il y a de plus rudimentaire : un simple et unique *beep* de carte mère. Rien de plus simple donc, on rajoutant simplement ce code à la méthode CPUCycle :

            if (Chip8Emulator._ST != 0)
                SystemSounds.Beep.Play();

Avec un petit “using System.Media;” et le tour est joué. Par contre, si votre carte mère n’est pas reliée à un haut-parleur, vous ne pourrez pas profiter de cette fabuleuse sortie son.

Le mot de la fin

Voilà, vous avez un émulateur Chip8, fonctionnel sur tous les plans et qui devrait être compatible avec la plupart des jeux / démos.

Vous trouverez le code source de l’émulateur par ici, sous licence Creative Commons by-nc-sa.

Ce petit émulateur a quelques défauts évidemment, comme la sortie vidéo qui scale plutôt mal. WPF faisant un filtre linéaire sur les textures, l’image est floue si on agrandi la fenêtre. Une solution est de se tourner vers une API graphique plus paramétrable, type DirectX/XNA, OpenGL, etc. Ou d’écrire plus de pixels manuellement lorsque l’on copie le frame buffer (mais c’est cradoc). Ou encore d’utiliser un filtre, comme SuperEagle, HQ2X… que la plupart des émulateurs populaires utilisent.

Suivant la réception de cet article, je vous en proposerai d’autres, notamment pour faire évoluer l’ému vers SuperChip8 et MegaChip8.

Mise à jour : la seconde partie est disponible ! En route pour SuperChip8.

Si vous n’êtes pas membres wefrag, vos commentaires peuvent être recueillis par mail :

contact

So, you want to code an emulator ?

Jeudi 15 décembre 2011

Cet été, j’ai découvert qu’il existait un émulateur Wii très performant, Dolphin. Cela faisait un moment que je n’avais pas guetter la scène émulation. Aux dernières nouvelles que j’avais prises, il existait un émulateur Gamecube, des plus bancal (c’était déjà le projet Dolphin). Autant vous dire que j’ai pris une claque en lançant cet émulateur Wii sur ma machine (un peu comme à l’époque où Chankast avait débarqué de nul part). Cerise sur le gâteau, il est open source. Alors quand j’ai mis la tête dans le code, je me suis dit : MER IL SON FOU.

J’adore comprendre les choses, ce qui se passe en “behind the scene”, ce que mon processeur fait si je lui dis de faire A + B. J’ai codé 1001 choses dans ce but, mais je ne sais pas pourquoi, les émulateurs ont toujours eu quelque chose de mystique pour moi. De mon approche naïve, faire un émulateur consiste à écrire un programme qui simule en 1:1 les composants d’une machine. Mais ce que j’ai vu dans le source de Dolphin n’avait rien à voir. Il ne reproduit pas les composants, mais recompile des opcodes PowerPC vers x86 (facon JIT). Bref, des dingues, mais diable que c’est performant. La plupart des bons émulateurs font des choses de ce genre.

Bien entendu l’approche naïve est viable pour de petits hardware. Je me suis donc armé de mes connaissances en archi hardware et assembleur pour créer mon propre émulateur. Ainsi, je me suis attaqué à un système simple : la Gameboy. Il y a plein de systèmes sympa pour débuter, les Atari, la Nes, etc, mais la GB a l’avantage d’avoir un hadware très largement documenté via ce qu’on appel la “pandocs”, dont un miroir est trouvable par ici, mais aussi de la doc sur les instructions CPU.

Le résultat, c’est SilverGameboy, un émulateur codé en C# (avec une sortie Silverlight ou XNA suivant la version), tournant sur PC/Xbox360/WindowsPhone. Je compte ouvrir les sources dès qu’il aura atteint un degré de finition satisfaisant.

SilverGameboy

Ce qu’il lui manque encore pour être “fini”, en dehors d’habituels bugfix :

  • Le support du rom banking (pour l’instant uniquement MBC1, avec des bugs, donc 90% des jeux encore injouables)
  • Le son (assez problématique sur Xbox360 et WP7, car pas d’accès bas niveau à la couche sonore)
  • Tout ce qui touche à la Gameboy Color (et le Super Gameboy, mais ca je crois que je ne vais pas le couvrir)
  • Et j’aimerais bien faire un wrapper réseau du port série pour jouer en multi (devrait être assez trivial)

Les petits trucs que j’ai trouvé “marrants” en le faisant :

  • Le CPU possède ~512 instructions, que j’ai mis quelques heures à écrire et… plusieurs jours à déboguer. Heureusement pour ca il existe des ROMS de test qui testent rigoureusement les instructions une par une, le bon comportement des registres etc. Malheureusement les tests ne sont pas très explicites en dehors de dire “passed” ou “failed” alors que 200 instructions peuvent être erronées :-/. Je m’en suis sorti en “trichant”, j’ai pris les sources d’un autre émulateur que j’ai modifié pour avoir une trace d’exécution, que j’ai comparé avec une trace de mon propre émulateur pour comprendre ou ca clochait. Je me suis retrouvé à faire des “diff” sur des fichiers de traces de 500Mo. La où le bas blesse, c’est que les documentations sur le CPU de la GB sont contradictoires : d’une doc à l’autre, les “flags” et les timings ne sont les même. Je ne comprenais pas pourquoi d’un émulateur à l’autre, ils implémentaient des instructions si différemment. Comment faire pour être sur de coller au hardware ? Coder un test GB et demander à un copain qui a un SDK Gameboy de l’exécuter sur un vrai hardware…
  • Le rom banking, c’est barbare. Le rom banking, c’est une méthode des plus utilisées pour palier à des limitations hardware. La Gameboy ne peut adresser que 64ko à la fois (dont 32 dédiés à la ROM d’un jeu). Or, certains jeux font jusqu’à 2mo. Les cartouches sont alors découpées en plein de “bank” mémoire de 32ko, et au besoin on les échange pour aller taper dans la totalité. Ce que j’ai trouvé spé, c’est la facon dont on change de bank. Dans chaque cartouche de Gameboy, il y a une puce, qui gère les banks. Les cartouches sont de la mémoire en lecture seule (normal, sinon on pourrait modifier des jeux), or, il est possible d’”écrire” dessus. C’est la puce qui gère les banks qui va comprendre que en écrivant, vous voulez changer de bank (mais ne va pas vraiment écrire). Je ne sais pas comment le rom banking est géré sur d’autres hardware, mais le coup de la nécessité d’une puce dans chaque cartouche et le principe de faire une fausse écriture pour changer de bank, je trouve ca barbare. Mais il y a plus pourri, il existe plusieurs versions de cette puce (appelé Memory Bank Controller, aka MBC), et forcément, chaque version a un comportement différent. Il en existe une petite dizaine et certaines sont très mal documentées.
  • Emuler un vieux système revient à émuler une télé ^^. Dans le cas de la Gameboy, le hardware est étroitement lié au fonctionnement de l’écran LCD. Cet écran a des caractéristiques précises, il fonctionne comme un tube cathodique, en balayant les lignes de pixels. Chaque balayement a une durée précise, et suivant l’état de l’écran, le CPU a le droit de taper dans la mémoire graphique. Il faut donc coder un pseudo-écran LCD. J’ai regardé comment ca se passe ailleurs, notamment sur Atari, même combat, c’est assez lié au comportement d’une cathodique. Je pensais me retrouver face à des systèmes assez banals, avec un framebuffer et une indépendance de la sortie vidéo, mais non. C’était marrant.

Mais tout ca ne répond pas à la question initiale de ce billet. Quand on parle de développement d’émulateurs, c’est un peu comme le développement de jeux vidéo de façon générale. Il y a toujours pleins de kevins qui croient que avec 2 semaines de cours en BAC+1 on peut développer un MMORPG. Il faut commencer petit.

Bref, tout ca pour dire que je m’adresse uniquement aux personnes conscientes qu’on ne code pas un émulateur en ne voulant pas s’intéresser au bas niveau. Pour se lancer dans l’émulation, il est préférable d’avoir des bases d’architecture hardware, savoir un peu de comment on est arrivé de la logique booléenne à des processeurs. Loin d’être indispensable, mais préférable pour savoir où l’on va. Pour ces gens curieux, et qui veulent commencer doucement, il existe une “machine” à émuler. Il s’agit du Chip8. En fait, cette machine n’existe pas. C’est une machine virtuelle qui a été inventée dans le but de simplifier le développement de jeux dans les 70’s. Il y a une doc disponible sur son hardware virtuel, et la machine possède un jeu d’instructions très limité (une trentaine, contre les 512 de la GB par exemple), ce qui en fait un hardware de choix pour commencer à comprendre l’émulation. Il y a donc tout ce qu’il faut par ici pour bien débuter. Si la demande suit, je vous pondrai un tutoriel sur le développement d’un émulateur Chip8.

Mise à jour :

Suite à vos remarques sur la clareté de mon paragraphe sur le rom banking, voici quelques précisions sur la mémoire de la Gameboy.

Les images étant plus larges que 800px, il est préférable de lire cet article sur une seule colonne.

Une mémoire, ce sont des blocs de 1 octet (8 bits) qui sont tous numérotés. La Gameboy a un bus mémoire capable de voir 65536 blocs (autrement dit, 64ko).

blocks
Or, la Gameboy possède seulement 8ko de mémoire vive. A quoi servent le reste des 56ko ? Comme l’a souligné LeGreg dans les commentaires, le bus mémoire est de type “memory-mapped I/O”. Une partie est donc dédiée aux I/O (gamepad, port série, son, interruptions…), mais aussi à la RAM et à lire les cartouches de jeux (32ko de cet espace, divisé en deux parties de 16ko, est dédié à la lecture des cartouches). Le reste est grosso inutilisé (ou presque).

memorymap

Comme je l’ai expliqué, 99% des jeux Gameboy font plus de 32ko. Comment faire avec cette limitation hardware ? Il y a un mécanisme, qui est interne aux cartouches, et non à la GB : le Rom Banking. Le rom banking est géré par une puce à l’intérieur de chaques cartouches, le Memory Bank Controller (MBC). Son rôle est de montrer à la Gameboy uniquement 32ko de la ROM. La ROM est alors découpée en “bank” de 16ko (j’avais simplifié ce cas par des banks de 32ko dans mon article, ce qui est faux en fait). La bank 0 est toujours visible par la GB, afin d’avoir accès au programme principal en toutes circonstances. Par contre, la “bank n” est amovible, et c’est le MBC qui la pilote.

mbcMais comme la Gameboy n’est pas capable de gérer ça d’elle même, il faut pouvoir dire au MBC de montrer la bank que l’on souhaite. Le principe est alors d’écrire sur la cartouche (ROM), une opération illégale. Le MBC va alors intercepter cette écriture et l’empecher. A la place d’écrire, le MBC va comprendre par votre tentative illégale, que vous souhaitez voir une autre bank.

mbc21
Un bus mémoire de type memory-mapped I/O est quelque chose de tout à fait commun. Le rom banking également. Ce que j’avais trouvé bizarre, c’est que ceci n’est pas implémenté dans le hardware de la console elle même, mais dans les cartouches de jeux, via une puce dédiée (le MBC), et via le principe bizarre de “fausse écriture” (et du fait qu’il existe plein de versions de cette puce).
Je ne sais pas de quelle manière le rom banking est géré ailleurs, mais je m’attendais à quelque chose de plus propre en terme de design (comme un bête registre CPU). Après il se peut que ce soit comme ca partout et qu’il y ai une bonne raison à cela, mais je n’ai pas creusé la question.