Voici la première partie liée à de la programmation pure et dure. Ouvrons grand nos yeux ! La partie de plaisir peut enfin commencer. Plusieurs codes seront fournis dans le tutoriel, mais bien entendu, il vaut mieux comprendre et écrire son propre code que d’effectuer des copier-coller.
Let’s gooo !
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.
La mémoire
Récupérons la description de la mémoire de la Chip 8.
Les adresses mémoire de la Chip 8 vont de
0x200
à0xFFF
(l’hexadécimal revient), faisant ainsi 3 584 octets. La raison pour laquelle la mémoire commence à partir de0x200
est que sur le VIP et Cosmac Telmac 1800, les 512 premiers octets sont réservés pour l’interpréteur. Sur ces machines, les 256 octets les plus élevés (0xF00-0xFFF
sur une machine 4K) ont été réservés pour le rafraîchissement de l’écran, et les 96 octets inférieurs (0xEA0 - 0xEFF
) ont été réservés pour la pile d’appels, à usage interne, et les variables.
En fait, la mémoire est utilisée pour charger les roms et pour gérer les périphériques de la machine.
Bien que cette citation soit assez longue, les deux choses qui nous intéressent sont que « les adresses mémoire vont de 0x200 à 0xFFF
, faisant ainsi 3 584 octets » et que « les 512 premiers octets sont réservés » (rappelons-nous que 0x200 = 512
).
On peut déduire de ces deux informations que la Chip 8 a une mémoire de 3 584 + 512 = 4 096 octets. Il existe des fonctions dédiées au rafraichissement de l’écran dans toutes les bibliothèques graphiques (update
, repaint
, SDL_RenderPresent
, etc.) et les 512 premiers octets ne nous serviront donc à rien (pour le moment). Cependant, nous allons les garder.
Donc, nous allons représenter la mémoire de la Chip 8 sous la forme d’un tableau memory
de 4 096 octets.
Il faut bien prendre en compte la taille spécifiée pour chaque variable et elles doivent de plus être non signées. En cas de non-respect de ces indications, notre programme aura à coup sûr des bogues.
Nous codons nos exemples avec la SDL. Elle dispose du type Uint8
pour représenter les entiers non signés et codés sur 8 octets.
Il nous faut de plus une autre variable, qui indique quelle case de la mémoire lire. Elle doit être initialisée à 0x200
comme le dit la description. La taille de cette variable nous importe peu. Elle doit juste être suffisante pour pouvoir représenter 4095 (les cases de notre tableau vont de 0 à 4095), c’est-à-dire être d’au moins 16 bits. Nous la nommerons pc
pour « program counter ».
Les registres
La Chip 8 comporte 16 registres de 8 bits dont les noms vont de
V0
àVF
(F = 15
en hexadécimal). Le registreVF
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.
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 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.
Là encore, ce n’est pas très compliqué, il nous suffit de déclarer les variables. Pour représenter ces registres, nous allons faire un tableau pour les 16 registres et une variable pour le registre I
.
La pile
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 nous d’une case du tableau memory
à une autre). Pour revenir de ces sauts, il faut sauvegarder l’adresse où il se trouvait avant ce saut (donc l’ancienne valeur de pc
). C’est le rôle de la pile, appelée stack en anglais.
La pile autorise seize niveaux, cela signifie qu’il nous faudra au maximum sauvegarder 16 valeurs de pc
. Nous allons donc utiliser un tableau de seize valeurs pour représenter la pile. Nous allons l’appeler jump
puisqu’il permet de gérer les sauts de mémoires. Notons qu’il nous faudra de plus une variable pour nous aider à parcourir le tableau (savoir à quelle case nous sommes à tout moment).
Cette variable doit pouvoir prendre 16 valeurs (une variable codée sur un octet suffit donc à la représenter). Nous allons l’appeler jump_nb
.
Les compteurs
La Chip 8 est composée 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 compteurs pour se charger de la synchronisation et du son. Nous allons les représenter à l’aide de deux variables que nous appellerons respectivement sys_counter
et sound_counter
.
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. Par exemple, le système de timer de la SDL peut être une bonne idée pour gérer cela.
Structure finale
Toutes les caractéristiques de la Chip 8 seront stockées dans une structure qui représentera le CPU.
#define MEMORY_SIZE 4096
#define START_ADDRESS 512
struct s_cpu
{
Uint8 memory[MEMORY_SIZE];
Uint8 V[16];
Uint16 I;
Uint16 jump[16];
Uint8 jump_nb;
Uint8 sys_counter;
Uint8 sound_counter;
Uint16 pc;
};
Il nous faut de plus une fonction pour se charger de l’initialisation du CPU. Elle prend en paramètre un CPU
et l’initialise.
void initialize_cpu(struct s_cpu *cpu)
{
memset(cpu, 0, sizeof(*cpu));
cpu->pc = START_ADDRESS;
}
Ici, toutes nos variables sont initialisés à zéro, sauf le program counter qui, rappelons-le, doit être initialisé à 0x200
que nous avons ici représenté sous la forme de la constante START_ADDRESS
.
On peut de plus déjà écrire la fonction qui décrémente nos compteurs et qui sera appelée toutes les 16 millisecondes. N’oublions pas, nous décomptons jusqu’à ce que les compteurs soient nuls.
void count(struct s_cpu *cpu)
{
if(cpu->compteurJeu > 0)
cpu->compteurJeu--;
if(cpu->compteurSon > 0)
cpu->compteurSon--;
}
Ça y est, nous avons implémenté la structure de notre CPU.
Chargement d’une rom
Notre CPU étant codé, nous pouvons d’ores et déjà écrire une fonction pour charger une rom. Les roms contenant les instructions à exécuter, il faut juste lire le contenu du fichier binaire et l’écrire dans la mémoire du CPU. Dans notre cas, les jeux sont chargés à partir de l’adresse 512
, nombre que nous avons dans la constante START_ADDRESS
.
int load_rom(struct s_cpu *cpu, const char path[])
{
FILE *rom = fopen(path, "rb");
if(!rom)
{
perror("Error fopen:");
return -1;
}
fread(&cpu->memory[START_ADDRESS], sizeof(Uint8) * (MEMORY_SIZE - START_ADDRESS), 1, rom);
fclose(rom);
return 0;
}
Pour le moment, charger une rom ne nous sert à rien, nous n’avons pas de quoi les exécuter, mais au moins cette fonction est déjà écrite.
Le graphique
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, nous pourrions commencer par le graphique ou même par l’exécution des instructions si nous le voulions. Cependant, ce n’est pas conseillé. Commencer par le graphique nous permettra de faire des tests le plus tôt possible.
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.
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). Bien sûr, nous n’allons pas représenter un pixel de la Chip 8 par un pixel de notre ordinateur. Pour rester dans de bonnes proportions, nous allons plutôt par exemple choisir de représenter un pixel par un bloc de 8 pixels.
Nous pouvons même (et c’est mieux) ne pas choisir une taille fixe pour nos blocs et décider que la largeur d’un bloc sera la largeur de la fenêtre divisée par le nombre de pixels par largeur et sa hauteur la hauteur de la fenêtre divisée par le nombre de pixels par hauteur. Ceci nous permettra de gérer le redimensionnement de la fenêtre facilement.
Nous pouvons maintenant définir notre écran. L’écran c’est une fenêtre et une matrice de pixels.
#define BLACK SDL_FALSE
#define WHITE SDL_TRUE
#define PIXEL_BY_WIDTH 64
#define PIXEL_BY_HEIGHT 32
#define PIXEL_DIM 8
#define WIDTH PIXEL_BY_WIDTH*PIXEL_DIM
#define HEIGHT PIXEL_BY_HEIGHT*PIXEL_DIM
typedef SDL_bool s_pixel;
struct s_screen
{
SDL_Window *w;
SDL_Renderer *r;
s_pixel pixels[PIXEL_BY_WIDTH][PIXEL_BY_HEIGHT];
Uint32 pixel_height;
Uint32 pixel_width;
};
Notre écran est composé non seulement de la fenêtre et du tableau de pixel, mais aussi de la hauteur et de la largeur d’un pixel à tout moment. Ainsi, à chaque fois que la fenêtre sera redimensionnée, nous mettrons à jour ces valeurs. Les dimensions initiales sont stockées dans des constantes de préprocesseur.
Créons maintenant des fonctions d’initialisation de l’écran.
void clear_screen(struct s_screen *screen)
{
memset(screen->pixels, BLACK, sizeof(screen->pixels));
}
int initialize_screen(struct s_screen *screen)
{
screen->w = SDL_CreateWindow("Emulateur", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
WIDTH, HEIGHT, SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
if(NULL == screen->w)
{
fprintf(stderr, "Error SDL_CreateWindow: %s.\n", SDL_GetError());
return -1;
}
screen->r = SDL_CreateRenderer(screen->w, -1, SDL_RENDERER_ACCELERATED);
if(NULL == screen->r)
{
fprintf(stderr, "Error SDL_CreateRenderer: %s.\n", SDL_GetError());
SDL_DestroyWindow(screen->w);
return -2;
}
clear_screen(screen);
screen->pixel_height = PIXEL_DIM;
screen->pixel_width = PIXEL_DIM;
return 0;
}
Notons que nous avons appelé clear_screen
la fonction qui initialiser les pixels (en les mettant tous en noirs). Nous en aurons peut-être besoin par la suite.
Et créons également une fonction pour détruire un écran.
void destroy_screen(struct s_screen *screen)
{
SDL_DestroyRenderer(screen->r);
SDL_DestroyWindow(screen->w);
}
Affichage de l’écran
Maintenant que nous avons déclaré notre tableau de pixels, voyons comment l’afficher. Pour cela, il nous faut calculer les coordonnées de chaque pixel.
Chaque carré représente un pixel. Le pixel en (0,0) a pour coordonnées (0,0), le pixel en (2,0) a pour coordonnées (2 × 8,0) soit (16,0), …, et finalement 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 hauteur d’un pixel (qui sont dans notre structure s_screen
).
Ceci nous permet d’écrire une fonction pour mettre à jour notre affichage. Nous allons être malin : plutôt que de dessiner chaque pixel, nous allons commencer par nettoyer la fenêtre en noir, ce qui nous permettra de ne dessiner que les pixels blancs.
void update_screen(struct s_screen *screen)
{
SDL_SetRenderDrawColor(screen->r, 0, 0, 0, 255);
SDL_RenderClear(screen->r);
SDL_SetRenderDrawColor(screen->r, 255, 255, 255, 255);
for(size_t i = 0; i < PIXEL_BY_WIDTH; i++)
{
for(size_t j = 0; j < PIXEL_BY_HEIGHT; j++)
{
if(screen->pixels[i][j] == WHITE)
{
SDL_Rect pixel_rect = {
screen->pixel_width * i, screen->pixel_height * j,
screen->pixel_width, screen->pixel_height
};
SDL_RenderFillRect(screen->r, &pixel_rect);
}
}
}
SDL_RenderPresent(screen->r);
}
Premiers tests
Grâce à cela, nous pouvons faire nos premiers tests. Nous allons créer une structure s_emulator
pour représenter notre émulateur. Cette structure contient, pour le moment, un CPU et un écran. Nous y ajoutons un champ in
pour gérer les évènements avec une structure s_input
et ces bouts de code, comme vu dans ce tutoriel.
struct s_input
{
SDL_bool key[SDL_NUM_SCANCODES];
SDL_bool quit;
int x, y, xrel, yrel;
int xwheel, ywheel;
SDL_bool mouse[6];
SDL_bool resize;
};
void update_event(struct s_input *input)
{
SDL_Event event;
while(SDL_PollEvent(&event))
{
if(event.type == SDL_QUIT)
input->quit = SDL_TRUE;
else if(event.type == SDL_KEYDOWN)
input->key[event.key.keysym.scancode] = SDL_TRUE;
else if(event.type == SDL_KEYUP)
input->key[event.key.keysym.scancode] = SDL_FALSE;
else if(event.type == SDL_MOUSEMOTION)
{
input->x = event.motion.x;
input->y = event.motion.y;
input->xrel = event.motion.xrel;
input->yrel = event.motion.yrel;
}
else if(event.type == SDL_MOUSEWHEEL)
{
input->xwheel = event.wheel.x;
input->ywheel = event.wheel.y;
}
else if(event.type == SDL_MOUSEBUTTONDOWN)
input->mouse[event.button.button] = SDL_TRUE;
else if(event.type == SDL_MOUSEBUTTONUP)
input->mouse[event.button.button] = SDL_FALSE;
else if(event.type == SDL_WINDOWEVENT)
if(event.window.event == SDL_WINDOWEVENT_RESIZED)
input->resize = SDL_TRUE;
}
}
On peut maintenant faire un programme de test de l’écran.
struct s_emulator
{
struct s_cpu cpu;
struct s_screen screen;
struct s_input in;
}
int initialize_SDL(void)
{
if(SDL_Init(SDL_INIT_VIDEO) < 0)
{
fprintf(stderr, "Error SDL_Init: %s.\n", SDL_GetError());
return -1;
}
return 0;
}
void destroy_SDL(void)
{
SDL_Quit();
}
int initialize_emulator(struct s_emulator *emulator)
{
int status = -1;
initialize_cpu(&emulator->cpu);
memset(&emulator->in, 0 sizeof(s_input));
if(0 == initialize_SDL())
{
int status = initialize_screen(&emulator->screen);
if(0 > status)
destroy_SDL();
}
return status;
}
void destroy_emulator(struct s_emulator *emulator)
{
destroy_screen(&emulator->screen);
destroy_SDL();
}
void emulate(struct s_emulator *emulator)
{
while(!emulator->in.quit)
{
update_event(&emulator->in);
update_screen(&emulator->screen);
SDL_Delay(20);
}
}
int main(int argc, char *argv[])
{
struct s_emulator = {0};
int status = -1;
if(!initialize_emulator(&emulator))
{
status = 0;
emulate(&emulator);
destroy_emulator();
}
return status;
}
Quoi ? Tout ce travail pour ça ! Un simple remplissage de la fenêtre aurait donné le même résultat.
Modifier l’écran
Derrière tout ce travail se cache un grand secret. Nous pouvons modifier les pixels pour afficher ce que nous voulons. Essayons par exemple d’appeler cette fonction set_pixels
juste avant de mettre à jour l’écran (dans ce code, on utilise le fait que s_pixel
soit un booléen).
void set_pixels(struct s_screen *screen)
{
for(size_t i = 0; i < PIXEL_BY_WIDTH; i++)
for(size_t j = 0; j < PIXEL_BY_HEIGHT; j++)
screen->pixels[i][j] = (i % (j + 1)) != 0;
}
Profitons-en pour commencer à gérer le redimensionnement de la fenêtre. En fait, testons le code avec cette fonction emulate
.
void resize_screen(struct s_screen *screen)
{
int w, h;
SDL_GetWindowSize(screen->w, &w, &h);
screen->pixel_height = h / PIXEL_BY_HEIGHT;
screen->pixel_width = w / PIXEL_BY_WIDTH;
}
void emulate(struct s_emulator *emulator)
{
set_pixels(&emulator->screen);
while(!emulator->in.quit)
{
update_event(&emulator->in);
if(emulator->in.resize)
resize_screen(&emulator->screen);
update_screen(&emulator->screen);
SDL_Delay(20);
}
}
Ici, on obtient ce résultat.
Nous pouvons redimensionner notre fenêtre comme nous le voulons. À chaque fois qu’un redimensionnement a lieu, on change la valeur de la largeur et de la hauteur des blocs ce qui fait que l’affichage se passera toujours bien.
Nous pouvons même changer la condition (i % (j + 1)) != 0
pour voir le résultat obtenu. Nous pourrions même allumer et éteindre des pixels au hasard à chaque tour de boucle !
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 avons maintenant fini de remplacer le matériel utilisé. Il ne nous reste plus qu’à simuler les différents calculs que peut effectuer la Chip 8 et notre émulateur sera fini et opérationnel.