Programmer un émulateur

Chapitre 3 : La base

BestCoder

Voici la première partie liée à de la programmation pure et dure.
Je fournirai le code pour presque toutes les actions à effectuer, mais il est inutile de préciser qu'il vaut mieux comprendre et écrire son propre code que d'effectuer des copier-coller.

Commentez

- Titre : Programmer un émulateur

- Auteur : BestCoder

- Parution : 18 mars 2014

- Licence : Le contenu de cet article est rédigé par BestCoder et est mis à disposition selon les termes de la Licence Creative Commons Attribution - Pas d'Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 non transposé. Les logos Developpez.com, en-tête, pied de page, css, et look & feel de l'article sont Copyright ® 2013 Developpez.com.

Sommaire

- Navigation

- I. Introduction

- II. L'implémentation de la machine

- II-A. La mémoire

- II-B. Les registres

- II-C. La pile ou stack

- II-D. Les compteurs

- III. Le graphique

- III-A. Création des pixels

- III-B. Modifier l'écran

- Navigation

Navigation

Tutoriel précédent : quelle console émuler ?

Sommaire

Tutoriel suivant : simulations des instructions

I - Introduction

Voici la première partie liée à de la programmation pure et dure.
Je fournirai le code pour presque toutes les actions à effectuer, mais il est inutile de préciser qu'il vaut mieux comprendre et écrire son propre code que d'effectuer des copier-coller.

II - L'implémentation de la machine

Nous allons commencer par récupérer une citation dans la description de la Chip 8, que nous nous contenterons de traduire en langage machine.
Cette partie concernera le CPU de la Chip 8. Le CPU est l'organe central de notre émulateur : c'est le chef d'orchestre.

II-A - La mémoire

Les adresses mémoire de la Chip 8 vont de $200 à $FFF, faisant ainsi 3 584 octets. La raison pour laquelle la mémoire commence à partir de $200 est que sur le VIP et Cosmac Telmac 1800, les premiers 512 octets sont réservés pour l'interpréteur. Sur ces machines, les 256 octets les plus élevés ($F00-$FFF sur une machine 4 Ko) ont été réservés pour le rafraîchissement de l'écran, et les 96 octets inférieurs ($EA0-$EFF) ont été réservés pour la pile d'appels, à usage interne, et les variables.

Bien que cette citation soit assez longue, ce qui nous intéresse est : « Les adresses mémoire vont de $200 à $FFF, faisant ainsi 3 584 octets » et « les premiers 512 octets sont réservés ». Je rappelle que $200 = 512.
On peut déduire de ces deux informations que la Chip 8 a une mémoire de 3 584 + 512 = 4 096 octets (un octet = huit bits, ne l'oubliez jamais). Le reste n'est que culture générale.
Et comme nous allons simuler le fonctionnement de notre machine, le rafraîchissement sera géré par une autre méthode. Il existe des fonctions dédiées pour toutes les bibliothèques graphiques (update, repaint, SDL_Flip, etc). Les premiers 512 octets ne serviront donc à rien (pour le moment).

Dans mon cas, la variable mémoire prendra la forme d'un tableau de 4 096 octets.

La mémoire est utilisée pour charger les jeux (roms) et pour la gestion des périphériques de la machine.
J'en profite pour vous dire qu'il faut bien prendre en compte la taille spécifiée pour chaque variable. En plus, elles sont toutes non signées. En cas de non-respect de ces indications, votre programme boguera à coup sûr.

Je parle en connaissance de cause. :honte:

Pour ma part, j'utilise SDL, donc des Uint.

Déclaration de la mémoire :

Uint8 memoire[4096]; // la mémoire est en octets (8 bits), soit un tableau de 4096 Uint8.

Maintenant, pour pointer sur une adresse donnée, il faut une autre variable qui sera initialisée à $200 = 512 comme nous le dit la description.
Nous la nommerons pc comme « program counter». La variable doit être de 16 bits au minimum car nous devons être en mesure de parcourir tout le tableau mémoire qui va de 0 à 4095.

II-B - Les registres

La Chip 8 comporte 16 registres de 8 bits dont les noms vont de V0 à VF (F = 15, en hexadécimal). Le registre VF est utilisé pour toutes les retenues lors des calculs.
En plus de ces 16 registres, nous avons le registre d'adresse, nommé I, qui est de 16 bits et qui est utilisé avec plusieurs opcodes qui impliquent des opérations de mémoire.

Ici, il n'y a rien de compliqué, nous nous contenterons donc juste de déclarer les variables. Les registres permettent à la Chip 8 − et à tout processeur en général − de manipuler les données. Ils servent en gros d'intermédiaires entre la mémoire et l'unité de calcul, ou l'UAL (Unité Arithmétique et Logique) pour les intimes. Le processeur gagne en vitesse d'exécution en manipulant les registres au lieu de modifier directement la mémoire.

II-C - La pile ou stack

La pile sert uniquement à stocker des adresses de retour lorsque les sous-programmes sont appelés. Les implémentations modernes doivent normalement avoir au moins 16 niveaux.

Lorsque le programme chargé dans la mémoire s'exécute, il se peut qu'il fasse des sauts d'une adresse mémoire à une autre.
Pour revenir de ces sauts, il faut sauvegarder l'adresse où il se trouvait avant ce saut (pc) : c'est le rôle de la pile, appelée stack en anglais. Elle autorise seize niveaux, il nous faudra donc un tableau de seize variables pour stocker les seize dernières valeurs de pc ; on le nommera saut.
Et comme pour la mémoire, on aura besoin d'une autre variable afin de parcourir ce tableau. Cette fois-ci, le type Uint8 fera l'affaire puisqu'on ne parcourt que seize valeurs. Je l'ai nommée nbrsaut.

II-D - Les compteurs

La Chip 8 est composée de deux compteurs. Ils décomptent tous les deux à 60 hertz, jusqu'à ce qu'ils atteignent 0.

Minuterie système : cette minuterie est destinée à la synchronisation des événements de jeux. Sa valeur peut être réglée et lue.
Minuterie sonore : cette minuterie est utilisée pour les effets sonores. Lorsque sa valeur est différente de zéro, un signal sonore est émis. Sa valeur peut être réglée et lue.

La Chip 8 a besoin de deux variables pour se charger de la synchronisation et du son. Nous les appellerons respectivement compteurJeu et compteurSon.
Puisqu'elles doivent décompter à 60 hertz, il faut trouver une méthode pour les décrémenter toutes les 1 / 60 = 0,016 = 16 millisecondes. Les timers restent une bonne solution pour effectuer ce genre d'opération. En SDL, on implémente cette action avec SDL_Delay.

Toutes les caractéristiques de la Chip 8 seront stockées dans une structure qui représentera le CPU.

cpu.h

#ifndef CPU_H
#define CPU_H

#define TAILLEMEMOIRE 4096
#define ADRESSEDEBUT 512

typedef struct
{
Uint8 memoire[TAILLEMEMOIRE];
Uint8 V[16]; //le registre
Uint16 I; //stocke une adresse mémoire ou dessinateur
Uint16 saut[16]; //pour gérer les sauts dans « mémoire », 16 au maximum
Uint8 nbrsaut; //stocke le nombre de sauts effectués pour ne pas dépasser 16
Uint8 compteurJeu; //compteur pour la synchronisation
Uint8 compteurSon; //compteur pour le son
Uint16 pc; //pour parcourir le tableau « mémoire »
} CPU;

CPU cpu;  //déclaration de notre CPU

void initialiserCpu() ;
void decompter() ;

#endif

cpu.c

#include "cpu.h"

void initialiserCpu()
{
//On initialise le tout

Uint16 i=0;

for(i=0;i<TAILLEMEMOIRE;i++) //faisable avec memset, mais je n'aime pas cette fonction ^_^
{
cpu.memoire[i]=0;
}

for(i=0;i<16;i++)
{
cpu.V[i]=0;
cpu.saut[i]=0;
}

cpu.pc=ADRESSEDEBUT;
cpu.nbrsaut=0;
cpu.compteurJeu=0;
cpu.compteurSon=0;
cpu.I=0;

}

void decompter()
{
if(cpu.compteurJeu>0)
cpu.compteurJeu--;

if(cpu.compteurSon>0)
cpu.compteurSon--;
}

Maintenant, attaquons le graphique, cela nous permettra de voir rapidement les différents résultats. L'ordre d'implémentation des caractéristiques importe peu, vous pourriez commencer par le graphique ou même l'exécution des instructions si vous le vouliez (par contre, je ne vous le conseille pas).
Cet ordre nous permettra de faire des tests le plus tôt possible.

III - Le graphique

Jetons un coup d'œil à la description de la Chip 8 :

La résolution de l'écran est de 64 × 32 pixels, et la couleur est monochrome.

Pour simuler notre écran, nous allons créer un panneau divisé en 64 × 32 pixels.

III-A - Création des pixels

Un pixel est un petit carré (ou rectangle) caractérisé par son abscisse, son ordonnée et sa couleur (ici, elle sera noire ou blanche car l'écran est monochrome). Dans notre cas, j'ai choisi des pixels carrés de côté 8. Vous pouvez fixer une dimension qui vous convient.

Voici le code C/SDL qui permet de définir notre pixel.

pixel.h

#ifndef PIXEL_H
#define PIXEL_H
#include <SDL/SDL.h>

typedef struct
{
SDL_Rect position; //regroupe l'abscisse et l'ordonnée
Uint8 couleur;   //comme son nom l'indique, c'est la couleur
} PIXEL;

#endif

Après la création de notre pixel, nous allons maintenant créer l'écran en tant que tel, qui sera constitué de 64 x 32 pixels.
Nous allons donc d'abord déclarer un tableau de 64 x 32 pixels et l'écran qui les contiendra. Cet écran aura des dimensions proportionnelles au nombre de pixels et à leur largeur.

pixel.h

#ifndef PIXEL_H
#define PIXEL_H
#include <SDL/SDL.h>

#define NOIR  0
#define BLANC 1
#define l 64      //nombre de pixels suivant la largeur
#define L 32       //nombre de pixels suivant la longueur
#define DIMPIXEL 8  //pixel carré de côté 8
#define WIDTH   l*DIMPIXEL      //largeur de l'écran
#define HEIGHT  L*DIMPIXEL       //longueur de l'écran

typedef struct
{
SDL_Rect position; //regroupe l'abscisse et l'ordonnée
Uint8 couleur;   //comme son nom l'indique, c'est la couleur
} PIXEL;

SDL_Surface *ecran,*carre[2];
PIXEL pixel[l][L];

#endif

Le tableau carre nous permettra de définir nos deux types de pixels : Noir et Blanc. Il sera utilisé pour dessiner à l'écran (avec la SDL, c'est le tableau que l'on va blitter sur l'écran à différentes positions).

Maintenant que nous avons déclaré notre tableau de pixels, le premier petit problème pointe le bout de son nez.

Comment calculer les coordonnées de notre pixel à partir de l'indice du tableau ?

La technique est assez utilisée et connue mais un petit rappel est toujours le bienvenu.
Jetons un coup d'œil sur notre futur panneau avec tous ses pixels.

Chaque carré représente un pixel. Le pixel en (0,0) a pour coordonnées (0,0). De même, le pixel en (2,0) a pour coordonnées (2*8,0) soit (16,0). Enfin, le pixel en (0,1) a pour coordonnées (0,1*8) soit (0,8).
D'une manière générale, pour trouver l'abscisse et l'ordonnée d'un pixel, il suffit de multiplier ses indices respectifs (X,Y) par la largeur et la longueur d'un pixel. Nous les avons fixés tous les deux à 8 (les pixels sont carrés).
Voici donc comment j'ai procédé pour le calcul :

pixel.h

#ifndef PIXEL_H
#define PIXEL_H
#include <SDL/SDL.h>

#define NOIR  0
#define BLANC 1
#define l 64      //nombre de pixels suivant la largeur
#define L 32       //nombre de pixels suivant la longueur
#define DIMPIXEL 8  //pixel carré de côté 8
#define WIDTH   l*DIMPIXEL      //largeur de l'écran
#define HEIGHT  L*DIMPIXEL       //longueur de l'écran

typedef struct
{
SDL_Rect position; //regroupe l'abscisse et l'ordonnée
Uint8 couleur;   //comme son nom l'indique, c'est la couleur
} PIXEL;

SDL_Surface *ecran,*carre[2];
PIXEL pixel[l][L];

void initialiserPixel() ;
#endif

pixel.c

#include "pixel.h"

void initialiserPixel()
{
Uint8 x=0,y=0;

for(x=0;x<l;x++)
{
for(y=0;y<L;y++)
{
pixel[x][y].position.x=x*DIMPIXEL;
pixel[x][y].position.y=y*DIMPIXEL;
pixel[x][y].couleur=NOIR; //on met par défaut les pixels en noir
}
}

}

Pour la couleur des pixels, j'ai adopté le même codage que la Chip 8, à savoir :

- 0 pour le noir ou éteint ;

- 1 pour le blanc ou allumé.

Envie de faire quelques tests ?

Rajoutons des fonctions pour initialiser les variables ecran et carre et pour dessiner sur notre écran.

pixel.h

#ifndef PIXEL_H
#define PIXEL_H
#include <SDL/SDL.h>

#define NOIR  0
#define BLANC 1
#define l 64
#define L 32
#define DIMPIXEL 8
#define WIDTH   l*DIMPIXEL
#define HEIGHT  L*DIMPIXEL

typedef struct
{
SDL_Rect position; //regroupe l'abscisse et l'ordonnée
Uint8 couleur;   //comme son nom l'indique, c'est la couleur
} PIXEL;

SDL_Surface *ecran,*carre[2];
PIXEL pixel[l][L];
SDL_Event event; //pour gérer la pause

void initialiserEcran() ;
void initialiserPixel() ;
void dessinerPixel(PIXEL pixel) ;
void effacerEcran() ;
void updateEcran() ;

#endif

pixel.c

#include "pixel.h"

void initialiserPixel()
{
Uint8 x=0,y=0;

for(x=0;x<l;x++)
{
for(y=0;y<L;y++)
{
pixel[x][y].position.x=x*DIMPIXEL;
pixel[x][y].position.y=y*DIMPIXEL;
pixel[x][y].couleur=NOIR;
}
}

}

void initialiserEcran()
{
ecran=NULL;
carre[0]=NULL;
carre[1]=NULL;

ecran=SDL_SetVideoMode(WIDTH,HEIGHT,32,SDL_HWSURFACE);
SDL_WM_SetCaption("BC-Chip8 By BestCoder",NULL);

if(ecran==NULL)
{
fprintf(stderr,"Erreur lors du chargement du mode vidéo %s",SDL_GetError());
exit(EXIT_FAILURE);
}

carre[0]=SDL_CreateRGBSurface(SDL_HWSURFACE,DIMPIXEL,DIMPIXEL,32,0,0,0,0); //le pixel noir
if(carre[0]==NULL)
{
fprintf(stderr,"Erreur lors du chargement de la surface %s",SDL_GetError());
exit(EXIT_FAILURE);
}

SDL_FillRect(carre[0],NULL,SDL_MapRGB(carre[0]->format,0x00,0x00,0x00)); //le pixel noir

carre[1]=SDL_CreateRGBSurface(SDL_HWSURFACE,DIMPIXEL,DIMPIXEL,32,0,0,0,0); //le pixel blanc
if(carre[1]==NULL)
{
fprintf(stderr,"Erreur lors du chargement de la surface %s",SDL_GetError());
exit(EXIT_FAILURE);
}

SDL_FillRect(carre[1],NULL,SDL_MapRGB(carre[1]->format,0xFF,0xFF,0xFF));  //le pixel blanc

}

void dessinerPixel(PIXEL pixel)
{
/* pixel.couleur peut prendre deux valeurs : 0, auquel cas on dessine le pixel en noir, ou 1, on dessine alors le pixel en blanc */

SDL_BlitSurface(carre[pixel.couleur],NULL,ecran,&pixel.position);
}

void effacerEcran()
{
//Pour effacer l'écran, on remet tous les pixels en noir

Uint8 x=0,y=0;
for(x=0;x<l;x++)
{
for(y=0;y<L;y++)
{
pixel[x][y].couleur=NOIR;
}
}

//on repeint l'écran en noir
SDL_FillRect(ecran,NULL,NOIR);
}
void updateEcran()
{
//On dessine tous les pixels à l'écran
Uint8 x=0,y=0;

for(x=0;x<l;x++)
{
for(y=0;y<L;y++)
{
dessinerPixel(pixel[x][y]);
}
}

SDL_Flip(ecran); //on affiche les modifications
}

main.c

#include <SDL/SDL.h>
#include "cpu.h"

void initialiserSDL();
void quitterSDL();
void pause();

int main(int argc, char *argv[])
{
initialiserSDL();
initialiserEcran();
initialiserPixel();

updateEcran();

pause();

return EXIT_SUCCESS;
}

void initialiserSDL()
{
atexit(quitterSDL);

if(SDL_Init(SDL_INIT_VIDEO)==-1)
{
fprintf(stderr,"Erreur lors de l'initialisation de la SDL %s",SDL_GetError());
exit(EXIT_FAILURE);
}

}

void quitterSDL()
{

SDL_FreeSurface(carre[0]);
SDL_FreeSurface(carre[1]);
SDL_Quit();
}

void pause()
{

Uint8 continuer=1;

do
{
SDL_WaitEvent(&event);

switch(event.type)
{
case SDL_QUIT:
continuer=0;
break;
case SDL_KEYDOWN:
continuer=0;
break;
default: break;
}
}while(continuer==1);

}

Et voilà le résultat de tout ce travail.

III-B - Modifier l'écran

Derrière tout ce travail se cache un grand secret. Je vous donne ce bout de code qui va vous éclaircir les idées. Remplacez la fonction initialiserPixel() par celle-ci :

void initialiserPixel()
{

Uint8 x=0,y=0;

for(x=0;x<l;x++)
{
for(y=0;y<L;y++)
{
pixel[x][y].position.x=x*DIMPIXEL;
pixel[x][y].position.y=y*DIMPIXEL;

if(x%(y+1)==0)
pixel[x][y].couleur=NOIR;
else
pixel[x][y].couleur=BLANC;
}
}
}

Et voilà le travail.

Vous pouvez même changer la condition pour voir ce que donne le résultat.
C'est comme ça que le jeu se dessinera à l'écran. Il n'y aura pas de fichier image à charger ni quoi que ce soit ! Tout se fera en positionnant les pixels noirs et blancs comme il faut et en effectuant les différentes instructions requises. Nous les aborderons dans la partie suivante.
En revanche, n'oubliez pas de rétablir l'ancienne fonction initialiserPixel().

Nous avons fini de remplacer le matériel utilisé, il suffit maintenant de simuler les différents calculs que peut effectuer la Chip 8 et notre émulateur sera fini et opérationnel.

Navigation

Tutoriel précédent : quelle console émuler ?

Sommaire

Tutoriel suivant : simulations des instructions