Homemade Pixels

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

Recette : Créer son premier émulateur - Partie 2

Dans nos précédentes aventures culinaires, je vous expliquais les bases de l’émulation en vous proposant de programmer votre propre émulateur Chip8. Aujourd’hui, on va continuer cet émulateur en lui ajoutant le support du Super Chip8, une extension HD ! (Si ce n’est 4K.)

schip8emu

Pour ce tutoriel, nous allons reprendre là où nous nous étions arrêté. Nous avions terminé notre émulateur Chip8, dont vous pouvez télécharger les sources par ici (sous Creative Commons by-nc-sa).

Je vous conseil également de lire cet article sur une colonne pour une meilleure lisibilité du code.

Comme nous l’avions vu, Chip8 a été étendu pour être plus performant avec deux extensions : Super Chip8 et Mega Chip8. Dans ce billet, je vous propose d’implémenter les instructions Super Chip8. Pour mémoire, voici le tableau récapitulatif des apports de chaque extension :

tableauSuper Chip8 a été conçu pour être rétrocompatible avec Chip8. Comprendre par là qu’un programme Chip8 fonctionnera tout à fait normalement sur Super Chip8. Pour cela, Super Chip8 conserve toutes les instructions de son ainé sans y toucher. Il ne fait que rajouter de nouvelles instructions. Il ajoute également un mode étendu, qui active la résolution de 128×64 et change le comportement de certaines instructions pour qu’elles fonctionnent dans ce mode. Voici les instructions dédiées à Super Chip8 :

schip8

Sans plus attendre, taillons dans le gras !

Mise à l’échelle de l’image

Dans mon premier billet, j’avais avancé cela :

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.

Ce qui reste vrai. Notre émulateur est un peu dégueulasse et ne rend pas hommage aux pixels bien carrés de Chip8. Mais, mais, mais… A ce moment, j’avais complètement omis le fait qu’il est possible de spécifier à WPF la manière dont il met à l’échelle les images. Par défaut, il utilise un filtre linéaire très rudimentaire. Ce que nous cherchons, c’est d’aller au plus simple et de ne pas appliquer de filtre (on parle de mise à l’échelle “au voisin le plus proche”). Pour cela, dans le constructeur de notre fenêtre WPF, nous allons rajouter le bout de code en gras :

            InitializeComponent();
            RenderOptions.SetBitmapScalingMode(frameBufferImage, BitmapScalingMode.NearestNeighbor);
            frameBufferImage.Source = writeableBitmap;

Et tada ! Nous avons nos bon gros pixels. Pour ceux qui souhaiteraient aller plus loin, l’article wikipédia sur la mise à l’échelle d’images est plutôt complet et présente tous les algorithmes utilisés dans les émulateurs (SuperEagle, 2xSal, HQ2X…). La plupart d’entre eux sont vraiment simples à mettre en place.

Le mode étendu

Deux instructions sont dédiées à la gestion du mode étendu. 00FF l’active alors que 00FE le désactive. Lorsqu’on l’active, il faut modifier le frame buffer pour une résolution de 128×64. Inversement, il faut le remettre à 64×32 quand on le désactive. Il va aussi nous falloir un booléen qui nous servira par ci par là.

        public bool ExtendedMode = false;

        // Désactive le mode étendu
        private void Inst_00FE()
        {
            ExtendedMode = false;
            _ScreenBuffer = new byte[8192]; // 64x32x4
        }

        // Active le mode étendu
        private void Inst_00FF()
        {
            ExtendedMode = true;
            _ScreenBuffer = new byte[32768]; // 128x64x4
        }

C’est un peu abrupte de recréer des tableaux ainsi, et potentiellement une source de gouffre à mémoire, mais dans le cadre de ce tutoriel, on ne va pas faire dans la dentelle. De plus, ces instructions sont très rarement appelées (une fois par jeu) et .NET gère bien les types valeurs, donc l’impact est vraiment nul. On aurait pu s’y prendre autrement (par ex. avoir un seul grand tableau ou un buffer par mode), mais faire ainsi nous simplifie la vie puisque l’on aura peu de modification à faire.

Défilement

Super Chip8 introduit trois instructions pour faire du défilement vertical et horizontal. Ces instructions ont deux particularités : elles ne wrap pas l’affichage (c’est-à-dire que le screen buffer n’est pas cyclique et s’éfface si déplacé) et le nombre de pixels déplacés est divisé par deux si le mode étendu n’est pas activé.

        // Défile de K pixels vers le bas, ou de K/2 en mode non étendu
        private void Inst_00CK()
        {
            int pixelToMove = 0; // Nombre de pixel à déplacer
            // On divise par 2 si le mode étendu est désactivé
            if (ExtendedMode)
                pixelToMove = _K * 512; // 512 bytes par lignes
            else
                pixelToMove = (int)Math.Ceiling(_K / 2.0f) * 256;

            // Position des pixels à déplacer
            int startPos = _ScreenBuffer.Length - pixelToMove - 1;

            for (int i = _ScreenBuffer.Length - 1; i >= 0; i--)
            {
                // Si on dépasse, on efface
                if (startPos >= 0)
                    _ScreenBuffer[i] = _ScreenBuffer[startPos];
                else
                    _ScreenBuffer[i] = 0;
                startPos--;
            }
        }

        // Défile de 4 pixels à droite, 2 si non étendu
        private void Inst_00FB()
        {
            // Nombre de byte à décaler à droite
            int offset = (ExtendedMode ? 16 : 8);

            for (int y = 0; y < (ExtendedMode ? 64 : 32); y++)
            {
                for (int x = (ExtendedMode ? 495 : 247); x >= 0; x--)
                {
                    // On décale à droite
                    int current = y * (ExtendedMode ? 512 : 256) + x;
                    _ScreenBuffer[current + offset] = _ScreenBuffer[current];
                    // On efface les 4 pixels à gauche
                    if (x < offset)
                        _ScreenBuffer[current] = 0;
                }
            }
        }

        // Défile de 4 pixels à gauche, 2 si non étendu
        private void Inst_00FC()
        {
            // Nombre de byte à décaler à droite
            int offset = (ExtendedMode ? 16 : 8);

            for (int y = 0; y < (ExtendedMode ? 64 : 32); y++)
            {
                for (int x = offset; x < (ExtendedMode ? 512 : 256); x++)
                {
                    // On décale à gauche
                    int current = y * (ExtendedMode ? 512 : 256) + x;
                    _ScreenBuffer[current - offset] = _ScreenBuffer[current];
                    // On efface à droite
                    if (x > (ExtendedMode ? 495 : 247))
                        _ScreenBuffer[current] = 0;
                }
            }
        }

Registres RPL

Deux nouvelles instructions ont une déscription un peu étrange. FX75 et FX85 évoque des registres RPL que Chip8 n’a pas. Quésaco ? RPL, ou ROM-based Procedural Language était le petit nom de l’environnement des calculatrices HP dans les années 80. Les deux instructions sus-citées permettent d’aller taper dans les registres du système d’exploitation. Leur utilité est double : avoir huit registres de plus où stocker des choses, et permettre à des programmes Super Chip8 de communiquer avec d’autres  (qu’ils soient Chip8 ou non). Ce dernier usage ne nous intéressent pas vraiment étant donné que notre émulateur ne tourne pas au sein d’un système. Par contre, avoir huit registres de plus, c’est chic. Pour le reste, ces instructions fonctionnent de façon quasi-similaire aux instructions FX55 et FX65 de Chip8 :

        // Registres RPL
        byte[] _RPL = new byte[8];

        // Stock V0 à VX dans les registres RPL, avec une limite à V7
        private void Inst_FX75()
        {
            int x = Math.Max(7, (int)_X);
            for (int i = 0; i <= x; i++)
                _RPL[i] = _V[i];
        }

        // Charge les registres RPL dans V0 à VX, avec une limite à V7
        private void Inst_FX85()
        {
            int x = Math.Max(7, (int)_X);
            for (int i = 0; i <= x; i++)
                _V[i] = _RPL[i];
        }

Table de caractères 10bit

Pour rappel, Chip8 utilise une table de sprites (stockée dans la partie privée de la mémoire) pour permettre l’affichage de caractères (de 0 à 9). Dans Chip8, ces sprites sont codés sur 5 octets, soit des sprites de 8×5 (dont 4×5 utiles en fait). Pour pouvoir afficher des caractères plus grands en mode étendu, Super Chip8 introduit une seconde table,  avec des caractères codés sur 10 octets (pour des sprites de 8×10 donc). Pour pointer sur cette table, il y a une nouvelle instruction :

        // Pointe I sur le digit de la table de caractères 10bit
        private void Inst_FX30()
        {
            _I = (ushort)(_V[_X] * 10 + 60);
        }

Et la table en question, que j’ai choisi de mettre à l’adresse 0×050h, soit directement après la table initiale (et toujours dans la mémoire privée) :

             // Table de caractères 10bit
            _memory[60] = 0x3C; _memory[61] = 0x7E; _memory[62] = 0xC3; _memory[63] = 0xC3; _memory[64] = 0xC3;
            _memory[65] = 0xC3; _memory[66] = 0xC3; _memory[67] = 0xC3; _memory[68] = 0x7E; _memory[69] = 0x3C;
            _memory[70] = 0x18; _memory[71] = 0x38; _memory[72] = 0x58; _memory[73] = 0x18; _memory[74] = 0x18;
            _memory[75] = 0x18; _memory[76] = 0x18; _memory[77] = 0x18; _memory[78] = 0x18; _memory[79] = 0x3C;
            _memory[80] = 0x3E; _memory[81] = 0x7F; _memory[82] = 0xC3; _memory[83] = 0x06; _memory[84] = 0x0C;
            _memory[85] = 0x18; _memory[86] = 0x30; _memory[87] = 0x60; _memory[88] = 0xFF; _memory[89] = 0xFF;
            _memory[90] = 0x3C; _memory[91] = 0x7E; _memory[92] = 0xC3; _memory[93] = 0x03; _memory[94] = 0x0E;
            _memory[95] = 0x0E; _memory[96] = 0x03; _memory[97] = 0xC3; _memory[98] = 0x7E; _memory[99] = 0x3C;
            _memory[100] = 0x06; _memory[101] = 0x0E; _memory[102] = 0x1E; _memory[103] = 0x36; _memory[104] = 0x66;
            _memory[105] = 0xC6; _memory[106] = 0xFF; _memory[107] = 0xFF; _memory[108] = 0x06; _memory[109] = 0x06;
            _memory[110] = 0xFF; _memory[111] = 0xFF; _memory[112] = 0xC0; _memory[113] = 0xC0; _memory[114] = 0xFC;
            _memory[115] = 0xFE; _memory[116] = 0x03; _memory[117] = 0xC3; _memory[118] = 0x7E; _memory[119] = 0x3C;
            _memory[120] = 0x3E; _memory[121] = 0x7C; _memory[122] = 0xC0; _memory[123] = 0xC0; _memory[124] = 0xFC;
            _memory[125] = 0xFE; _memory[126] = 0xC3; _memory[127] = 0xC3; _memory[128] = 0x7E; _memory[129] = 0x3C;
            _memory[130] = 0xFF; _memory[131] = 0xFF; _memory[132] = 0x03; _memory[133] = 0x06; _memory[134] = 0x0C;
            _memory[135] = 0x18; _memory[136] = 0x30; _memory[137] = 0x60; _memory[138] = 0x60; _memory[139] = 0x60;
            _memory[140] = 0x3C; _memory[141] = 0x7E; _memory[142] = 0xC3; _memory[143] = 0xC3; _memory[144] = 0x7E;
            _memory[145] = 0x7E; _memory[146] = 0xC3; _memory[147] = 0xC3; _memory[148] = 0x7E; _memory[149] = 0x3C;
            _memory[150] = 0x3C; _memory[151] = 0x7E; _memory[152] = 0xC3; _memory[153] = 0xC3; _memory[154] = 0x7F;
            _memory[155] = 0x3F; _memory[156] = 0x03; _memory[157] = 0x03; _memory[158] = 0x3E; _memory[159] = 0x7C;

Sprites 16×16 et 8×16

Dans Chip8, l’affichage de sprites se fait via l’instruction DXYK, où K détermine le nombre de lignes que doit faire le sprite, avec K = 1 à F comme valeurs possibles (soit des sprites allant de 8×1 à 8×15). Super Chip8 autorise K = 0, qui va signifier un sprite de 8×16. Si le mode étendu est activé et que K = 0, les sprites peuvent faire 16×16 pixels. Dans tous les autres cas, le comportement de l’instruction est le même que pour Chip8 (même si le mode étendu est actif, K = 3 donnera des sprites de 8×3). En gras, ce qui a été ajouté/modifié.

        private void Inst_DXYK()
        {
            // On reset le flag
            _V[0xF] = 0;
            // Gestion de Super Chip8
            if (_K == 0)
                _K = 16;
            // Pour chaque ligne de pixels à écrire
            for (int i = 0; i < _K; i++)
            {
                byte ligne = _memory[_I + i];
                // Gestion de Super Chip8
                byte ligne2 = 0;
                int pixelsInLine = 8;
                // Cas particulier 16x16
                if (_K == 16 && ExtendedMode)
                {
                    ligne = _memory[_I + i * 2];
                    ligne2 = _memory[_I + i * 2 + 1];
                    pixelsInLine = 16;
                }
                // Pour chaque pixel dans la ligne
                for (int n = 0; n < pixelsInLine; n++)
                {
                    // On extrait le pixel à écrire
                    byte pixel = (byte)((byte)(ligne << n) >> 7);
                    if (n >= 8)
                    {
                        int n2 = n - 8;
                        pixel = (byte)((byte)(ligne2 << n2) >> 7);
                    }
                    // On calcul l'adresse du pixel dans le frame buffer
                    int pixelAddress = _V[_X] + n + (_V[_Y] + i) * (ExtendedMode ? 128 : 64);

                    // Le reste de la méthode est inchangé...

Mise à jour du dispatcher

Il faut bien évidemment mettre à jour notre dispatcher pour qu’il tienne compte de ces nouvelles instructions (en gras, du code en plus) :

            switch (_Code)
            {
                case 0x0000:
                    if (_Y == 0xC)
                        Inst_00CK();
                    else
                        switch (_KK)
                        {
                            case 0xE0: Inst_00E0(); break;
                            case 0xEE: Inst_00EE(); break;
                            case 0xFB: Inst_00FB(); break;
                            case 0xFC: Inst_00FC(); break;
                            case 0xFE: Inst_00FE(); break;
                            case 0xFF: Inst_00FF(); break;
                        }
                    break;

Puis un peu plus bas :

                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 0x30: Inst_FX30(); break;
                        case 0x33: Inst_FX33(); break;
                        case 0x55: Inst_FX55(); break;
                        case 0x65: Inst_FX65(); break;
                        case 0x75: Inst_FX75(); break;
                        case 0x85: Inst_FX85(); break;
                    }
                break;
            }

Mise à jour de WPF

Enfin, il nous faut un peu modifier l’affichage du screen buffer pour gérer le mode étendu (avec les changements, toujours en gras) :

        bool ExtendedMode = false;

        private void Render(object sender, EventArgs e)
        {
            byte[] data = Chip8Emulator.GetScreenBuffer();

            if (!ExtendedMode && Chip8Emulator.ExtendedMode)
            {
                writeableBitmap = new WriteableBitmap(128, 64, 60, 60, PixelFormats.Bgra32, null);
                frameBufferImage.Source = writeableBitmap;
                ExtendedMode = true;
            }
            else if (ExtendedMode && !Chip8Emulator.ExtendedMode)
            {
                writeableBitmap = new WriteableBitmap(64, 32, 60, 60, PixelFormats.Bgra32, null);
                frameBufferImage.Source = writeableBitmap;
                ExtendedMode = false;
            }

            if (data != null)
            {
                // write our pixels into the bitmap source
                writeableBitmap.Lock();
                Marshal.Copy(data, 0, writeableBitmap.BackBuffer, data.Length);
                writeableBitmap.AddDirtyRect(
                    new Int32Rect(0, 0, (ExtendedMode ? 128 : 64), (ExtendedMode ? 64 : 32)));
                writeableBitmap.Unlock();
            }
        }

Et voilà, nous avons terminé l’ajout des extensions Super Chip8 à notre émulateur. Pour le tester, je vous rappel que vous pouvez télécharger un pack de ROM publiques par ici.

Comme la dernière fois, le code source de ce billet est mis à disposition, sous licence Creative Commons by-nc-sa.

Au prochain épisode, nous attequerons les extensions Mega Chip8 avec notamment la gestion du son en PCM et des palettes de couleurs.

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

contact

6 commentaires pour “Recette : Créer son premier émulateur - Partie 2”

  1. Mangeurdenfants dit :

    Ça donne envie de s’y mettre… C’est pour quand le b-a ba des moteurs de Wolf3D et Doom ? :-)

  2. MrHelmut dit :

    Thx, ca va venir. Je ne suis pas très rapide à confectionner des tuto’. :-)

  3. tranxen dit :

    Ah j’adore ces recettes. Merci.

  4. Chopin dit :

    Salut MrHelmut !

    Ton tuto est super et m’a beaucoup aidé à créer mon émulateur Chip 8 et Super Chip 8 !!!

    As-tu l’attention de mettre en ligne un jour la suite ?
    Car j’en suis à la Mega Chip 8 et je suis complètement bloqué pour afficher les sprites au format ARVB

    Pourrais-tu m’aider ? Ici ou par MP ?

  5. MrHelmut dit :

    Hey, ça fait plaisir de lire ça !

    Malheureusement je n’ai plus l’opportunité d’écrire des gros tuto comme avant et je crains que la partie 3 ne voit pas le jour.
    J’aimerais t’aider mais c’est assez lointain et je me souviens avoir bien galéré sur l’ARVB aussi… J’avais contacté l’auteur de Mega Chip 8 pour qu’il me donne plus de détails sur sa documentation, mais le mec s’est avéré être assez présomptueux et m’a dit que tout était la… J’ai un peu laissé tomber et mon temps libre a fait le reste.

    Cela dit, je pense que tu as les clefs en main pour partir sur une architecture plus sympa est beaucoup mieux documentée, comme la Gameboy ! http://problemkaputt.de/pandocs.htm

  6. Chopin dit :

    En faite, entre temps j’ai finalement réussi à afficher correctement les sprites de la Mega Chip 8.
    Maintenant, il ne me reste plus que le son et les collisions à gérer et je pense que ça devrait le faire :)

    En tout cas sans ton tuto, je n’y serai surement pas arrivé.
    La Gameboy est dans mes projets mais je veux être bien caler avant de m’y lancer.

Laisser un commentaire

Vous devez être connecté avec votre compte Wefrag pour publier un commentaire.