Créer un émulateur avec l’API Libretro

Magazine
Marque
GNU/Linux Magazine
Numéro
270
Mois de parution
juillet 2024
Spécialité(s)


Résumé

L’émulation a le vent en poupe ces dernières années. Un intérêt sans doute renforcé par la bulle spéculative autour du retrogaming qui rend l’acquisition de vieilles machines ou de vieux jeux hors de prix. Nous allons voir dans cet article comment développer facilement un émulateur. Nous en profiterons même pour concevoir notre propre machine à émuler.


Body

Si vous avez déjà joué à des systèmes de retrogaming grand public comme Recalbox ou Retropie, vous avez déjà utilisé Libretro. Libretro [1] est une API pour développer des émulateurs, appelés « cores » (cœurs) dans la terminologie Libretro. Elle définit une interface de programmation pour les cores qui permet de factoriser certaines fonctionnalités présentes dans tous les émulateurs : gestion de l’affichage vidéo, du son, du clavier, des manettes, des sauvegardes, du chargement des supports (cartouche, disquette, cassette)… Libretro peut être vue comme une « spécification » d’interface, qui doit être implémentée dans un logiciel appelé « frontend ». Le projet Libretro développe son propre frontend appelé RetroArch [2]. RetroArch est disponible sur une quantité impressionnante de plateformes : GNU/Linux (ARM et x86), Android, macOS (ARM et x64), Windows (de Windows 95 à Windows 11), consoles Nintendo (de la GameCube à la Switch, en passant par la 3DS), consoles Sony (PS2, PS3, PS4, PSP, PSVITA), Xbox… La liste est interminable !

1. Installation de RetroArch

Commençons par l’installation de RetroArch. Deux méthodes sont présentées ici, la méthode facile (installation d’un paquet binaire), et la compilation des sources.

1.1 Installation d’un paquet binaire

RetroArch est disponible dans l’archive Debian. Il est donc possible de l’installer dans une distribution Debian ou dérivée avec un simple :

$ sudo apt-get install retroarch

Il ne s’agira toutefois pas de la dernière version. En effet, la version disponible pour Debian 12 est la version 1.14.0, alors que la dernière version disponible à la date d’écriture de cet article est la version 1.17.0. Ce sera néanmoins largement suffisant pour tester notre émulateur.

D’autres méthodes d’installation sont disponibles pour les autres systèmes d’exploitation sur le site de RetroArch [2].

1.2 Compilation à partir des sources

Le plus simple est d’installer les dépendances de compilation du paquet retroarch :

$ sudo apt-get build-dep retroarch

On récupère ensuite le code source :

$ mkdir ~/demo_libretro
$ cd ~/demo_libretro
$ git clone https://github.com/libretro/RetroArch.git

La compilation ensuite est triviale :

$ cd RetroArch
$ ./configure && make

Le résultat de la compilation est un binaire retroarch.

Certains éléments assez lourds de l’interface graphique de RetroArch sont stockés dans un dépôt GitHub séparé, retroarch-assets. Ces données sont recherchées par défaut dans le répertoire ~/.config/retroarch/assets. RetroArch reste utilisable sans ces éléments, mais les installer facilite la navigation dans les menus.

$ git clone --depth 1 https://github.com/libretro/retroarch-assets.git assets
$ mkdir ~/.config/retroarch
$ mv assets ~/.config/retroarch/

2. Création de l’embryon d’émulateur

Note : tout le code source de l’article est disponible sur le dépôt GitHub [6].

2.1 Mise en place du projet

La pierre angulaire de l’API Libretro est le fichier libretro.h, présent dans le dépôt GitHub libretro-common, que nous allons copier dans le répertoire de notre émulateur en devenir.

$ cd ~/demo_libretro
$ mkdir mon_emu
$ cd mon_emu
$ wget https://raw.githubusercontent.com/libretro/libretro-common/master/include/libretro.h

Nous allons ensuite créer un Makefile minimaliste permettant de compiler uniquement sous GNU/Linux. Libretro permet de créer des émulateurs portables sur à peu près toutes les plateformes possibles, mais cela nécessite un Makefile assez complexe pour couvrir tous les cas. Le lecteur pourra se reporter au Makefile d’un core Libretro existant, comme celui du core Theodore [3], par exemple.

Notre Makefile sera le suivant :

TARGET := mon_emu_libretro.so
CFLAGS := -fPIC -Wall -std=c99
INCDIRS := -I.
LDFLAGS := -lc -shared -version-script=link.T -no-undefined
 
SRC=$(wildcard *.c)
OBJECTS := $(SRC:.c=.o)
 
all: $(TARGET)
 
$(TARGET): $(OBJECTS)
    $(LD) $(LDFLAGS) $(OBJECTS) -o $@
 
%.o: %.c
    $(CC) $(CFLAGS) $(INCDIRS) -c -o $@ $<
 
clean:
    rm -f $(OBJECTS)
    rm -f $(TARGET)
 
.PHONY: all clean

Ce Makefile permet de créer une bibliothèque partagée, mon_emu_libretro.so, qui sera chargée par le frontend (RetroArch). L’option de compilation PIC (pour Position-Independent Code) permet de produire un code compilé qui ne dépend pas de l’emplacement en mémoire où il sera chargé, ce qui est nécessaire pour une bibliothèque partagée. L’option -shared de l’éditeur de liens demande à créer une bibliothèque partagée (shared library), qui sous GNU/Linux a l’extension *.so. L’option -version-script=link.T indique à l’éditeur de liens de prendre en compte des informations supplémentaires dans le fichier link.T. Ce fichier est ici utilisé pour limiter la visibilité des fonctions de la bibliothèque partagée qui va être créée aux seules fonctions dont le nom commence par « retro_ », ceci afin de ne pas polluer l’espace de noms du frontend. Ce sont en effet les seules fonctions que ce dernier aura besoin d’appeler directement.

Le contenu du fichier link.T est le suivant :

{
  global: retro_*;
  local: *;
};

2.2 Tour d’horizon de l’API

Le fichier libretro.h définit une grande quantité de constantes, de structures et de prototypes de fonctions, le tout très bien commenté. Créer un émulateur va consister à fournir une implémentation pour toutes ces fonctions (avec éventuellement une implémentation qui ne fait rien pour les fonctions qui ne sont pas supportées par le core).

Il est important de comprendre la séquence d’appels typique de ces fonctions par le frontend :

1. Phase d’initialisation du core :

  • retro_api_version() : cette fonction doit renvoyer la constante RETRO_API_VERSION (définie dans libretro.h) afin de valider la compatibilité de l'interface avec celle du frontend utilisé.
  • retro_get_system_info() : cette fonction permet de fournir au frontend des renseignements sur le core (nom, version, fichiers supportés…).
  • retro_set_environment() : cette fonction permet d’avoir accès à une fonction de configuration du frontend.
  • retro_init() : cette fonction permet l’initialisation globale de la librairie/core. C’est par exemple le bon endroit pour faire des allocations mémoires, qui seront libérées dans la fonction retro_deinit().

2. Chargement d’un support (disquette, K7, cartouche…) :

  • retro_load_game() : cette fonction permet de charger le contenu d’un support (appelé plus génériquement « jeu »).
  • retro_get_system_av_info() : cette fonction permet de configurer le frontend avec les informations de timing audio et vidéo et de géométrie d’écran.
  • Fonctions retro_set_*() (autres que la fonction retro_set_environment()) : ces fonctions permettent d’avoir accès à différentes fonctions du frontend (aussi appelées fonctions de rappel ou « callbacks ») que l’on pourra appeler à chaque pas d’émulation pour mettre à jour le buffer vidéo, le son, et obtenir des informations sur les entrées (clavier, manettes…).

3. Boucle d’émulation :

  • retro_run() : cette fonction est appelée par le frontend à la fréquence définie dans retro_get_system_av_info(). Le core implémentant cette fonction doit émuler la machine pendant exactement le temps nécessaire au rafraîchissement de l’écran.

4. Phase d’arrêt du core :

  • retro_unload_game() : cette fonction permet de décharger le « jeu ».
  • retro_deinit() : cette fonction doit en particulier libérer la mémoire avant l’arrêt du core.

D’autres fonctions peuvent être appelées ponctuellement en fonction des besoins :

  • Les fonctions retro_serialize_size(), retro_serialize() et retro_unserialize() sont utilisées pour sauvegarder l’état courant de l’émulateur (« save state »), ainsi que pour les fonctions de rembobinage (« rewind ») et de jeu en réseau (« netplay »).
  • Les fonctions retro_cheat_reset(), retro_cheat_set(), retro_get_memory_data() et retro_get_memory_size() sont utilisées pour trouver et appliquer en mémoire des « cheat codes » (codes de triche). Les deux premières sont utilisées quand l’émulateur gère directement ces codes, alors que les deux dernières permettent de déléguer la gestion (recherche et application des codes) au frontend.

Il n’est pas obligatoire pour un core Libretro de supporter toutes ces fonctionnalités. Ainsi, nous allons commencer par un code minimaliste et pourtant « fonctionnel » :

#include "libretro.h"
#include <string.h>
 
// Caractéristiques du système émulé
// Fréquence vidéo (60 Hz)
#define VIDEO_FPS 60
// Fréquence audio (22 kHz)
#define AUDIO_SAMPLE_RATE 22050
// Nombre de pixels X/Y de l'écran
#define XBITMAP 80
#define YBITMAP 50
 
// Callback d'accès à la configuration du frontend
static retro_environment_t environ_cb = NULL;
// Callback de mise à jour du buffer vidéo
static retro_video_refresh_t video_cb = NULL;
// Callbacks pour ajouter des échantillons au buffer audio
static retro_audio_sample_t audio_cb = NULL;
static retro_audio_sample_batch_t audio_batch_cb = NULL;
// Callback pour mettre à jour l'état des entrées (clavier, manette)
static retro_input_poll_t input_poll_cb = NULL;
// Callback pour lire l'état courant d'une touche/bouton
static retro_input_state_t input_state_cb = NULL;
 
unsigned retro_api_version(void) { return RETRO_API_VERSION; }
 
void retro_get_system_info(struct retro_system_info *info)
{
  memset(info, 0, sizeof(*info));
  // Nom de l'émulateur
  info->library_name = "mon_emu";
  // Version de l'émulateur
  info->library_version = "1.0";
  // Liste d'extensions de fichiers qui peuvent être chargés par l'émulateur
  info->valid_extensions = "bin";
  // Pas besoin du chemin complet du fichier chargé, Libretro va le charger pour nous
  info->need_fullpath = false;
  // Autorise Libretro à extraire le fichier d'une archive avant son chargement
  info->block_extract = false;
}
 
void retro_set_environment(retro_environment_t env)
{
  // L’émulateur peut démarrer sans charger un support
  bool no_rom = true;
  env(RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &no_rom);
  environ_cb = env;
}
 
void retro_init(void) {}
 
bool retro_load_game(const struct retro_game_info *game) { return true; }
 
bool retro_load_game_special(unsigned game_type,
const struct retro_game_info *info, size_t num_info) { return false; }
 
unsigned retro_get_region(void) { return RETRO_REGION_PAL; }
 
void retro_get_system_av_info(struct retro_system_av_info *info)
{
  memset(info, 0, sizeof(*info));
  info->timing.fps = VIDEO_FPS;
  info->timing.sample_rate = AUDIO_SAMPLE_RATE;
  info->geometry.base_width = XBITMAP;
  info->geometry.base_height = YBITMAP;
  info->geometry.max_width = XBITMAP;
  info->geometry.max_height = YBITMAP;
  info->geometry.aspect_ratio = (float) XBITMAP / (float) YBITMAP;
}
 
void retro_set_video_refresh(retro_video_refresh_t video_refresh)
{
  video_cb = video_refresh;
}
 
void retro_set_audio_sample(retro_audio_sample_t audio_sample)
{
  audio_cb = audio_sample;
}
 
void retro_set_audio_sample_batch(retro_audio_sample_batch_t audio_sample_batch)
{
  audio_batch_cb = audio_sample_batch;
}
 
void retro_set_input_poll(retro_input_poll_t input_poll)
{
  input_poll_cb = input_poll;
}
 
void retro_set_input_state(retro_input_state_t input_state)
{
  input_state_cb = input_state;
}
 
void retro_set_controller_port_device(unsigned port, unsigned device) {}
size_t retro_serialize_size(void) { return 0; }
bool retro_serialize(void *data, size_t size) { return false; }
bool retro_unserialize(const void *data, size_t size) { return false; }
void retro_cheat_reset(void) {}
void retro_cheat_set(unsigned index, bool enabled, const char *code) {}
void retro_run(void) {}
void retro_unload_game(void) {}
void retro_reset(void) {}
void retro_deinit(void) {}
void *retro_get_memory_data(unsigned id) { return NULL; }
size_t retro_get_memory_size(unsigned id) { return 0; }

Après compilation du core avec make, on lance l’émulateur via RetroArch avec la commande :

$ ../RetroArch/retroarch -L mon_emu_libretro.so

Il est aussi possible de rajouter l’option -v pour activer les logs de RetroArch, ce qui peut être utile en cas de problème.

Une petite fenêtre s’ouvre alors, mais reste désespérément noire, faute d’émulation de quoi que ce soit.

3. Conception de la machine à émuler

Avant de se lancer dans l’émulation, il faut choisir quelle machine nous allons vouloir émuler. Pour les besoins de cet article, nous n’allons pas chercher à émuler un vrai processeur, ce serait trop complexe (bien qu’il existe de vieux processeurs avec très peu d’instructions. Le PDP-8 par exemple ne disposait que de 8 instructions). Il existe des architectures avec une seule instruction (cf. [4]), mais cela ne facilite pas la conception des programmes. Nous allons donc nous amuser à développer notre propre mini-ordinateur 16 bits que l’on va ensuite émuler.

3.1 Registres et jeu d’instructions

La machine sera composée de plusieurs registres 16 bits :

  • IP (Instruction Pointer) : stocke l’adresse de l’instruction en cours.
  • CC (Code Condition) : stocke les « drapeaux » système, qui permettent de sauvegarder des informations de statut de la machine après une instruction de comparaison (CMP). Le bit 0 sera mis à 1 suite à une égalité.
  • R1 et R2 : registres généralistes (généralisé en Rx dans les tableaux d’instructions ci-dessous).

Pour se simplifier la vie, toutes les instructions seront codées sur deux mots de 16 bits, et dureront un cycle d’horloge. L’horloge sera cadencée à 1 MHz, donc la durée de chaque instruction sera de 1 µs.

Les instructions se rangent en plusieurs catégories :

1. Chargement d’une valeur dans un registre :

Instruction

Opcodes

Signification

LD Rx, 0xABCD

00 – 0x – AB - CD

Chargement (LoaD) dans Rx de la valeur hexadécimale ABCD

LD Rx, $0xABCD

01 – 0x – AB - CD

Chargement dans Rx du contenu de l’adresse mémoire ABCD (en hexadécimal)

2. Stockage en mémoire :

Instruction

Opcodes

Signification

ST Rx, $0xABCD

10 – 0x – AB – CD

Écriture (STore) en mémoire à l’adresse ABCD (en hexadécimal) de la valeur contenue dans le registre Rx

ST Rx, Ry

11 – 0x – 00 - 0y

Écriture en mémoire de la valeur contenue dans Rx à l’adresse contenue dans Ry

3. Opérations de comparaison :

Les opérations de comparaison positionnent le bit 0 du registre CC à 1 en cas d’égalité.

Instruction

Opcodes

Signification

CMP Rx, 0xABCD

20 – 0x – AB – CD

Comparaison de Rx avec la valeur hexadécimale ABCD

CMP Rx, Ry

21 – 0x – 00 – 0y

Comparaison de Rx avec Ry

4. Opérations de branchement :

Instruction

Opcodes

Signification

BRA 0xABCD

30 – 00 – AB – CD

Branchement inconditionnel à l’adresse hexadécimale ABCD

BEQ 0xABCD

31 – 00 – AB - CD

Branchement à l’adresse hexadécimale ABCD si le bit CC(0) = 1

5. Opérations mathématiques :

Instruction

Opcodes

Signification

ADD Rx, 0xABCD

40 – 0x – AB – CD

Rx = Rx + 0xABCD

6. Instructions diverses :

Instruction

Opcodes

Signification

NOP

FF – FF – FF – FF

Ne fait rien pendant 1 cycle d’horloge

Ce jeu d’instruction est très sommaire et pourra être complété au besoin.

3.2 Organisation de l’espace mémoire

La machine va disposer d’une mémoire de 64 ko, ce qui permet d’accéder à n’importe quel emplacement avec une simple adresse de 16 bits, sans devoir gérer de mécanisme de permutation entre différentes banques de mémoire. Le processeur sera de type « big-endian », c’est-à-dire que l’octet de poids fort d’un mot de 16 bits sera stocké en premier en mémoire.

Une partie de la mémoire sera utilisée comme mémoire vidéo. Le (petit) écran fera 80 pixels de large et 50 pixels de haut, et la couleur de chaque point sera codée sur 16 bits. La mémoire vidéo utilisera donc 8000 octets. Afin de coder chaque point sur 16 bits (ce qui simplifiera leur gestion et évitera de consommer trop de mémoire), le codage RGB565 sera utilisé. Ce type de codage, assez classique sur les vieilles machines, utilise 5 bits pour coder le rouge, 6 bits pour coder le vert et 5 bits pour coder le bleu, soit 16 bits au total. Le vert bénéficie d’un surplus de résolution de 1 bit par rapport au rouge et au bleu, car l'œil humain est le plus sensible dans la région verte du spectre visible, et est donc capable de détecter plus de nuances dans cette zone.

L’organisation de la mémoire sera donc la suivante :

Adresses début – fin

Taille

Utilisation

0x0000 – 0x1F3F

8000 octets

Mémoire vidéo

0x1F40 – 0x1FFF

192 octets

Réserve pour la gestion des périphériques (manette, clavier…)

0x2000 – 0xFFFF

57344 octets

Mémoire utilisateur (stockage du programme)

Au démarrage de la machine, le pointeur d’instruction sera initialisé à la valeur 0x2000 et débutera l’exécution des instructions à partir de cette adresse.

4. Conception d’un premier programme

Avant même de développer plus en avant l’émulateur, nous allons créer un premier programme pour notre mini-ordinateur afin de bien comprendre le fonctionnement du jeu d’instructions, des registres et de l’organisation mémoire.

4.1 Le programme

Pour commencer, nous allons faire simple. Le programme consistera à dessiner (en boucle) un carré blanc de 10 pixels par 10 pixels au centre de l’écran (noir).

L’écran faisant 80x50 pixels, le carré central de 10x10 pixels s’étend des lignes 20 à 29 et des colonnes 35 à 44. Le premier pixel de ce carré est donc le pixel numéro 20*80+35=1635. Chaque pixel occupant deux octets en mémoire, l’adresse du premier pixel du carré en mémoire est donc 1635*2=3270.

L’algorithme donnerait quelque chose comme cela en une espèce de pseudo-C :

// Couleur RGB565 blanche
couleur = 0xFFFF
// Position du premier pixel en mémoire
video_pos = 3270
for (y = 0; y < 10; y++)
{
  for (x = 0; x < 10; x++)
  {
    // Écriture de la couleur en mémoire vidéo (sur 2 octets !)
    ram[video_pos] = couleur
    // Incrémentation de la position en mémoire de 2 octets
    video_pos += 2
  }
  // Saut de ligne : incrémentation de 80-10 pixels = 70*2 = 140 octets
  video_pos += 140
}

Traduit en assembleur, le programme est un peu plus complexe, mais reste compréhensible avec un peu d’habitude :

; Point d'entrée du programme
.start
; Chargement de la position initiale dans la mémoire vidéo
LD R1,.init_video_pos
ST R1,.video_pos
; y = 0
LD R1, 0
ST R1, .cnt_y
 
; Boucle sur l'axe Y
.boucle_y
; x = 0
LD R1,0
ST R1, .cnt_x
; Boucle sur l'axe X
.boucle_x
; Chargement de la couleur dans R2
LD R2,.couleur
; Chargement de l'adresse en mémoire vidéo dans R1
LD R1,.video_pos
; Écriture de la couleur dans la mémoire vidéo
ST R2,R1
; Incrémentation de la position dans la mémoire vidéo
ADD R1,2
ST R1,.video_pos
; Fin de la ligne courante ?
LD R1,.cnt_x
CMP R1,9
BEQ .new_line
; Incrémentation de x
ADD R1,1
ST R1,.cnt_x
BRA .boucle_x
.new_line
; Incrémentation de y
LD R2,.cnt_y
ADD R2,1
ST R2,.cnt_y
; Fin du carré de 10px par 10px ?
CMP R2,10
BEQ .start
; Incrémentation de la position dans la mémoire vidéo
; (avance de 80-10 pixels = 70*2 octets = 140 octets)
LD R2,.video_pos
ADD R2,140
ST R2,.video_pos
BRA .boucle_y
 
; ===== Déclaration des variables =====
 
; Position linéaire initiale du carré de 10 pixels
; de côté dans la mémoire vidéo
.init_video_pos
DW 3270
; Couleur à utiliser (blanc = 0xFFFF)
.couleur
DW 0xFFFF
; Position linéaire actuelle dans la mémoire vidéo
.video_pos
DW 0x0000
; Compteur X (de 0 à 9)
.cnt_x
DW 0x0000
; Compteur Y (de 0 à 9)
.cnt_y
DW 0x0000

Outre les instructions vues précédemment, le code utilise des constructions particulières qui ne correspondent pas à du code qui sera exécuté par le processeur, mais qui permettent de donner des indications à l’assembleur (c’est-à-dire au programme qui va transformer ce code en langage machine) ou au lecteur :

  • Les commentaires : il s’agit des lignes qui commencent par le caractère « ; ».
  • Les labels : il s’agit des lignes qui commencent par le caractère « . » et qui permettent de faire référence à des adresses en mémoire. Ces labels peuvent ensuite être utilisés dans les instructions qui attendent une adresse mémoire. C’est l’assembleur qui va remplacer ces labels par des adresses effectives lors de la génération du fichier binaire.
  • Les réservations d’espace mémoire : l’instruction DW (Define Word) permet de réserver de l’espace mémoire pour stocker un mot de 16 bits et de l’initialiser à une certaine valeur. Les déclarations « DW » sont généralement précédées d’un label afin de pouvoir utiliser facilement leur adresse mémoire dans le code. On trouve aussi couramment des variantes DB (Define Byte) et DD (Define Double word), mais on ne les supportera pas ici.

4.2 Assembleur

C’est bien beau d’avoir un programme en assembleur, mais si c’est pour faire « l’assemblage » (c’est-à-dire la conversion d’un programme sous forme de texte vers un fichier binaire compréhensible par la machine) à la main, cela va vite être fatigant.

Nous allons donc en profiter pour créer notre propre petit assembleur en Python.

Le code étant un peu long (~100 lignes), on ne présentera ici que certains extraits, mais le lecteur pourra retrouver le code complet du fichier asm.py dans le dépôt GitHub [6].

On commence par lire et stocker toutes les lignes du fichier texte *.asm dans un tableau, puis on supprime les lignes de commentaires et les lignes vides :

# Lecture du fichier
with open(asm_file) as file:
   lines = [line.rstrip() for line in file]
# Suppression des lignes de commentaires et des lignes vides
lines = list(filter(lambda x: not x.startswith(';') and not x == "", lines))

On fait ensuite une première passe pour trouver toutes les lignes correspondant à des labels, et on stocke les labels dans un dictionnaire en leur associant leur adresse en mémoire (on rappelle que le fichier binaire sera stocké en mémoire à partir de l’adresse 0x2000). On supprime ensuite les lignes de labels qui sont maintenant devenues inutiles.

# Première passe pour récupérer la position en mémoire des labels
address = 0x2000
# Table de correspondance entre les labels et les adresses mémoire
labels = {}
for line in lines:
   if line.startswith('.'):
       # On a affaire à un label
       labels[line] = address
   elif line.startswith("DW"):
       # Les instructions DW n'occupent que 2 octets en mémoire
       address = address + 2
   else:
       # Toutes les autres instructions occupent 4 octets en mémoire
       address = address + 4
# Suppression des lignes de labels
lines = list(filter(lambda x: not x.startswith('.'), lines))

Enfin, on ouvre le fichier de sortie (*.bin) en écriture et en mode binaire, puis on parcourt à nouveau toutes les lignes pour générer la représentation binaire de chaque instruction via une grande suite de if/elif pour traiter chaque type d’instruction :

# Deuxième passe d'assemblage
with open(out_file, "wb") as bin_file:
   for line in lines:
       tokens = line.replace(',', ' ').split()
       if tokens[0] == "ST":
           # 1er opérande : numéro du registre Rx
           reg = int(tokens[1][1])
           if tokens[2].startswith('R'):
               # Le 2e opérande est un registre
               reg2 = int(tokens[2][1]) # Numéro du registre Ry
               bin_file.write(bytes([0x11, reg, 0, reg2]))
           else:
               # Le 2e opérande est une adresse
               addr = parse_address(tokens[2])
               bin_file.write(bytes([0x10, reg, (addr&0xFF00)>>8, addr&0xFF]))
       elif (...)

On génère ainsi le fichier binaire demo1.bin qui pourra être chargé par notre émulateur :

$ python3 asm.py demo1.asm

5. Émulation du mini-ordinateur

Maintenant que nous avons défini la machine et que nous avons un programme, il ne reste plus qu’à implémenter son émulation.

5.1 Émulation du jeu d’instructions

Commençons tout d’abord par créer les variables qui vont permettre de modéliser les caractéristiques principales du processeur à émuler : les registres et la mémoire.

short r1, r2;
unsigned short ip;
unsigned char cc;
// Mémoire 64ko
#define RAM_SIZE 0x10000
unsigned char ram[RAM_SIZE];

S’agissant d’un processeur 16 bits, nous allons avoir à lire et écrire en mémoire des mots de 16 bits. Le processeur émulé étant big-endian, alors que l’émulateur tournera la plupart du temps (mais pas toujours !) sur un système little-endian, nous allons utiliser les fonctions suivantes pour gérer les lectures et les écritures sur 16 bits :

static short read_short_from_ram(int pos)
{
  return (short)((ram[pos] << 8) + ram[pos+1]);
}
 
static void write_short_in_ram(int pos, short value)
{
  ram[pos] = (value & 0xFF00) >> 8;
  ram[pos+1] = value & 0xFF;
}

Vient ensuite la fonction principale qui va émuler le fonctionnement de la prochaine instruction (à l’adresse pointée par le registre IP). On décode les trois éléments de l’instruction : l’opcode, le numéro du registre et un dernier élément qui peut être une valeur, une adresse ou un numéro de registre. Un gros switch sur le numéro d’opcode permet ensuite de traiter les différentes instructions. On n’oubliera pas également d’incrémenter le pointeur d’instruction IP de 4 octets pour le positionner sur la prochaine instruction à décoder (sauf si un branchement est effectué par l’instruction courante).

// Exécute la prochaine instruction en mémoire
void run_next_opcode(void)
{
  unsigned char opcode = ram[ip];
  unsigned char reg_num = ram[ip+1];
  short val = read_short_from_ram(ip+2);
  short *reg = (reg_num == 1) ? &r1 : &r2;
  ip += 4;
  switch (opcode)
  {
    case 0x00: // LD Rx,0xABCD
      *reg = val;
      break;
    case 0x01: // LD Rx,$0xABCD
      *reg = read_short_from_ram(val);
      break;
    case 0x10: // ST Rx,$0xABCD
      write_short_in_ram(val, *reg);
      break;
    case 0x11: // ST Rx,Ry
      short *reg2 = (val == 1) ? &r1 : &r2;
      write_short_in_ram(*reg2, *reg);
      break;
    case 0x20: // CMP Rx,0xABCD
      cc = (*reg == val) ? 1 : 0;
      break;
    case 0x21: // CMP Rx,Ry
      cc = (r1 == r2) ? 1 : 0;
      break;
    case 0x30: // BRA 0xABCD
      ip = (unsigned short)(val & 0xFFFF);
      break;
    case 0x31: // BEQ 0xABCD
      if ((cc & 0x01) == 1)
      {
        ip = (unsigned short)(val & 0xFFFF);
      }
      break;
    case 0x40: // ADD Rx,0xABCD
      *reg += val;
      break;
    default: // NOP ou instruction invalide
      // On ne fait rien
  }
}

On rajoute à ça une petite fonction pour réinitialiser le processeur : remise à 0 de toute la mémoire et des registres, et configuration du registre IP pour pointer sur l’adresse de démarrage en mémoire.

#define USER_ADDR_START 0x2000
 
// Reset du processeur
void reset(void)
{
  memset(ram, 0, RAM_SIZE);
  r1 = 0;
  r2 = 0;
  cc = 0;
  ip = USER_ADDR_START;
}

Et enfin une fonction qui va placer le programme en mémoire :

// Chargement d'un disque en mémoire
void load_program(const unsigned char *data, int size)
{
  if (size <= RAM_SIZE - USER_ADDR_START)
  {
    memcpy(&ram[USER_ADDR_START], data, size);
  }
}

5.2 Gestion de la vidéo

Nous avons mis en place toutes les fonctions nécessaires à l’émulation, mais pour l’instant le code n’a aucun lien avec l’API Libretro. Nous allons maintenant faire le lien en complétant une partie des fonctions retro_* vues précédemment.

Nous allons modifier la fonction retro_set_environment() pour rendre le chargement d’un « jeu » obligatoire :

void retro_set_environment(retro_environment_t env)
{
  // L'émulateur nécessite le chargement d'un support
  bool no_rom = false;
  env(RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &no_rom);
  environ_cb = env;
}

Dans la fonction retro_init(), nous allons configurer le buffer vidéo pour pointer vers le début de la mémoire du système émulé :

// Buffer vidéo au format RGB565
static unsigned short *video_buffer = NULL;
 
void retro_init(void)
{
  video_buffer = (unsigned short *)ram;
}
 

La fonction retro_load_game() va configurer le format vidéo (RGB565), effectuer un reset du système émulé, puis charger le contenu du fichier de jeu dans sa mémoire.

bool retro_load_game(const struct retro_game_info *game)
{
  // Configuration de la vidéo au format RGB565
  enum retro_pixel_format fmt = RETRO_PIXEL_FORMAT_RGB565;
  environ_cb(RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &fmt);
  // Réinitialisation de l'état de la machine
  reset();
  // Chargement du programme en mémoire
  load_program(game->data, game->size);
  return true;
}

On appellera également la fonction reset() dans retro_reset() :

void retro_reset(void)
{
  reset();
}

Enfin, la fonction retro_run() va exécuter un nombre d’instructions correspondant à la durée d’un rafraîchissement d’écran. On rappelle que pour se simplifier la tâche, toutes les instructions du processeur émulé durent un cycle d’horloge. L’émulation d’un vrai processeur nécessiterait de compter le nombre de cycles d’horloge consommés par chaque instruction. Après émulation du nombre d’instructions voulu, on appelle la fonction du frontend qui avait été récupérée suite à l’appel de retro_set_video_refresh() pour mettre à jour son buffer vidéo.

// Fréquence vidéo (60 Hz)
#define VIDEO_FPS 60
// Nombre de pixels X/Y de l'écran
#define XBITMAP 80
#define YBITMAP 50
// Pitch = longueur en octets entre 2 lignes du buffer vidéo
#define PITCH (2 * XBITMAP)
// Fréquence processeur (1 MHz)
#define PROC_FREQ 1000000
// Nombre d'instructions exécutées pendant la durée d'une frame vidéo
#define NB_INSTR_PER_FRAME (PROC_FREQ / VIDEO_FPS)
 
void retro_run(void)
{
  int i;
  // Exécution du nombre d'instructions correspondant à la durée d'une frame vidéo
  for (i = 0; i < NB_INSTR_PER_FRAME; i++)
  {
    run_next_opcode();
  }
  // Mise à jour de l'image de l'écran
  video_cb(video_buffer, XBITMAP, YBITMAP, PITCH);
}

Le lecteur avisé aura remarqué que la durée d’un rafraîchissement d’écran ne correspond pas à un nombre rond d’instructions exécutées. Un émulateur précis compensera cet écart lors des appels ultérieurs de la fonction retro_run(). Ce ne sera pas fait ici par souci de simplification.

Nous pouvons maintenant essayer de lancer notre émulateur en chargeant le programme demo1.bin développé précédemment avec la commande suivante :

$ ../RetroArch/retroarch -L mon_emu_libretro.so demo1.bin

Ce qui nous permet d’admirer le résultat de notre programme, un magnifique carré blanc sur fond noir (Fig. 1). On peut en profiter pour tester quelques fonctionnalités de RetroArch : redimensionnement de la fenêtre, passage en mode plein écran (touche <F>), affichage du frame rate (touche <F3>)… La liste complète des raccourcis clavier de RetroArch est disponible sur la page [5].

demo1-s

Fig. 1 : Notre premier programme demo1, le simple dessin d’un carré blanc.

5.3 Gestion de la manette

Libretro modélise une manette physique (joystick, joypad…) sous la forme d’un « RetroPad », qui est une manette ressemblant à une manette de Super Nintendo survitaminée. Cette manette virtuelle dispose d’une croix directionnelle, de quatre boutons A, B, X, Y, quatre boutons de tranche, deux boutons Start et Select et deux sticks analogiques.

La gestion des manettes dans l’émulateur passe par la fonction de callback enregistrée suite à l’appel de la fonction retro_set_input_poll(), que nous avons appelée input_poll_cb dans notre code. Comme son nom l’indique, cette fonction va réaliser un « polling » (c’est-à-dire une scrutation) de l’état des manettes. Elle doit être appelée dans la fonction retro_run() pour acquérir l’état courant et s’en servir pour agir sur l’émulateur.

Mais avant de commencer à coder, il faut savoir comment notre mini-ordinateur va gérer une manette de jeu. Lors de la présentation de l’organisation mémoire, nous avons identifié la zone mémoire entre les adresses 0x1F40 et 0x1FFF comme pouvant être utilisée pour accéder à des périphériques. Nous allons utiliser l’octet à l’adresse 0x1F40 pour stocker un nombre correspondant au bouton de la manette enfoncé (on suppose ici pour simplifier qu’un seul bouton peut être enfoncé à un instant donné) : 0 signifie qu’aucun bouton n’est enfoncé, 1 signifie que le bouton A est enfoncé, 2 pour B, 3 pour X et 4 pour Y.

Revenons maintenant à l’émulateur. La première chose à faire est de signaler au frontend que l’on veut utiliser une (ou plusieurs) manette(s), et de lui spécifier les boutons que l’on souhaite utiliser. Dans notre cas, nous allons utiliser une seule manette, et uniquement les quatre boutons A, B, X, Y. Ajoutons donc le code suivant dans la fonction retro_init() :

struct retro_input_descriptor desc[] = {
       { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A, "A" },
       { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B, "B" },
       { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X, "X" },
       { 0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_Y, "Y" },
       { 0, 0, 0, 0, 0 },
};
environ_cb(RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS, desc);

Le code enregistre via la fonction de callback environ_cb une structure de données décrivant les manettes utilisées ainsi que leurs boutons. Chaque élément du tableau est constitué des éléments suivants : le numéro de périphérique (en commençant par 0), le type de périphérique (ici, un joypad de type « RetroPad », mais cela peut être aussi une souris, un clavier, un crayon optique, un stick analogique…), un numéro d’index (pour certains types de périphériques), un numéro de touche/bouton, et enfin une description textuelle qui pourra être affichée dans les menus du frontend. Le tableau doit être terminé par une structure avec des zéros pour signaler au frontend la fin du tableau.

Dans la fonction retro_run(), nous ajoutons ensuite les lignes suivantes :

// Lecture de l'état des boutons de la manette
input_poll_cb();
 
// Simulation de l'état de la manette dans la mémoire de la machine
if (input_state_cb(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_A))
    ram[0x1F40] = 1;
else if(input_state_cb(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_B))
   ram[0x1F40] = 2;
else if(input_state_cb(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_X))
    ram[0x1F40] = 3;
else if(input_state_cb(0, RETRO_DEVICE_JOYPAD, 0, RETRO_DEVICE_ID_JOYPAD_Y))
    ram[0x1F40] = 4;
else
    ram[0x1F40] = 0;

La fonction input_poll_cb() lit l’état de la manette, puis la fonction input_state_cb() permet de vérifier l’état enfoncé/relâché de chaque bouton individuellement. On met ensuite à jour la mémoire de la machine émulée en conséquence.

Il faut maintenant créer un programme qui exploite cette manette. Nous allons pour cela repartir du premier programme créé en en faisant une copie que l’on pourra modifier :

$ cp demo1.asm demo2.asm

On insère ensuite au début du programme le code suivant qui va permettre, à chaque itération d’affichage du carré, de changer la couleur en fonction du bouton enfoncé. On rappelle que les couleurs sont codées au format RGB565. On pourra s’aider par exemple de l’outil en ligne [7] pour facilement trouver le code RGB565 correspondant à une couleur donnée.

; Point d'entrée du programme
.start
; == Gestion de la couleur du carré ==
; Chargement dans R1 de l'état des boutons
LD R1,$0x1F40
; Bouton A enfoncé ?
CMP R1,0x0100
BEQ .btn_a
; Bouton B enfoncé ?
CMP R1,0x0200
BEQ .btn_b
; Bouton X enfoncé ?
CMP R1,0x0300
BEQ .btn_x
; Bouton Y enfoncé ?
CMP R1,0x0400
BEQ .btn_y
; Aucun bouton enfoncé : couleur blanche
LD R1,0xFFFF
ST R1,.couleur
BRA .start_frame
; Bouton A enfoncé : couleur rouge
.btn_a
LD R1,0xF800
ST R1,.couleur
BRA .start_frame
; Bouton B enfoncé : couleur jaune
.btn_b
LD R1,0xFFE0
ST R1,.couleur
BRA .start_frame
; Bouton X enfoncé : couleur bleue
.btn_x
LD R1,0x001F
ST R1,.couleur
BRA .start_frame
; Bouton Y enfoncé : couleur verte
.btn_y
LD R1,0x07E0
ST R1,.couleur
.start_frame
(...)

Il ne reste plus qu’à assembler notre nouveau programme, brancher une manette USB sur notre PC, puis lancer RetroArch :

$ python3 asm.py demo2.asm
$ ../RetroArch/retroarch -L mon_emu_libretro.so demo2.bin

Si la manette ne fonctionne pas, c’est sans doute qu’il faut la configurer dans le menu de RetroArch : appuyez sur <F1> pour afficher le menu de RetroArch, puis sur la touche <Backspace> pour revenir à la racine du menu et allez dans Réglages > Entrées > Affectation de RetroManettes > Touches du port 1 puis Assigner toutes les touches. Il suffit alors d’appuyer sur le bouton correspondant au nom affiché pour configurer rapidement toute la manette. Pour revenir au jeu, il suffit d’appuyer à nouveau sur <F1>.

Le carré devrait maintenant changer de couleur en fonction de la touche de la manette. Mais bizarrement, ce ne sont pas les couleurs attendues qui s’affichent (Fig. 2) !

demo2 avant correction-s 0

Fig. 2 : Première version du programme demo2, les couleurs ne sont pas celles attendues !

Quel est le problème ? Nous avons oublié une caractéristique importante de notre mini-ordinateur : celui-ci est « big-endian », c’est-à-dire qu’il stocke les octets de poids forts en premier, alors que notre PC est lui « little-endian ». Il n’est donc pas possible d’utiliser directement la mémoire vidéo du mini-ordinateur comme buffer vidéo pour le frontend, car l’ordre des octets n’est pas celui attendu. On peut imaginer plusieurs solutions de contournement, comme utiliser un buffer intermédiaire où l’on remettrait les octets dans le bon ordre avant chaque appel à video_cb(), par exemple. Pour faire simple, nous allons juste réécrire la fonction write_short_in_ram() pour que les mots écrits dans la mémoire vidéo le soient avec l’endianess de la machine hôte. Pour cela, il suffit d’écrire directement un mot de 16 bits au lieu d’écrire deux octets, ce qui rend transparent l’endianess (Fig. 3).

#define VIDEO_MEM_SIZE 0x1F40
 
static void write_short_in_ram(int pos, short value)
{
  if (pos < VIDEO_MEM_SIZE)
  {
    // Écriture en mémoire vidéo : on considère la mémoire vidéo
    // comme des mots de 16 bits pour éviter les problèmes d'endianess
    // entre la machine émulée et la machine émulant
    ((short *)&ram[pos])[0] = value;
  }
  else
  {
    ram[pos] = (value & 0xFF00) >> 8;
    ram[pos+1] = value & 0xFF;
  }
}

demo2 apres correction-s 1

Fig. 3 : Après correction de l’émulateur, les couleurs sont maintenant correctes.

5.4 Gestion du clavier

Notre mini-ordinateur va utiliser le mot de 16 bits à l’emplacement mémoire 0x1F50 pour stocker le code de la touche enfoncée, ou zéro si aucune touche n’est enfoncée.

Côté RetroArch, le support du clavier passe par la définition d’une fonction de rappel (callback) qui sera appelée par le frontend à chaque fois qu’une touche est enfoncée ou relâchée. Cette fonction doit prendre en argument un booléen indiquant si la touche est enfoncée ou relâchée, le code de la touche, le caractère concerné (en UTF-32), et l’état des touches de « contrôle » (<Shift>, <Ctrl>, <Alt>…). On peut voir la valeur des différents codes des touches dans le fichier libretro.h, au niveau de l’énuméré retro_key. Nous allons donc créer une fonction de ce type, qui mettra à jour l’emplacement mémoire 0x1F50.

void keyboard_cb(bool down, unsigned keycode,
                 uint32_t character, uint16_t key_modifiers)
{
  if (down)
  {
    ram[0x1F50] = (keycode & 0xFF00) >> 8;
    ram[0x1F51] = keycode & 0x00FF;
  }
  else
  {
    ram[0x1F50] = 0;
    ram[0x1F51] = 0;
  }
}

Puis nous enregistrons cette fonction auprès du frontend, par exemple dans la fonction retro_load_game() :

struct retro_keyboard_callback keyb_cb = { keyboard_cb };
environ_cb(RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK, &keyb_cb);

Il ne reste plus qu’à mettre à jour notre programme d’exemple pour tirer parti du clavier. Pour cela, nous allons utiliser les touches de direction du clavier (qui ont pour code RETRO_UP=273, RETRO_DOWN=274, RETRO_RIGHT=275 et RETRO_LEFT=276) pour bouger les coordonnées du coin supérieur gauche de notre carré. Pour éviter un code trop compliqué, nous ne gérerons pas les déplacements en dehors des limites de l’écran.

Commençons par recopier le programme précédent pour le compléter :

$ cp demo2.asm demo3.asm

Puis rajoutons au tout début du programme le code suivant qui teste si une des touches de direction est enfoncée et modifie la variable .init_video_pos en conséquence (on rappelle que 1 pixel sur l’écran occupe 2 octets en mémoire) :

; Point d'entrée du programme
.start
; == Gestion de la position du carré ==
; Chargement dans R1 de l'état du clavier
LD R1,$0x1F50
; Reset de l'état du clavier pour éviter les répétitions
LD R2,0
ST R2,$0x1F50
; Chargement dans R2 de la position actuelle du carré
LD R2,.init_video_pos
; Flèche vers le haut enfoncée ?
CMP R1,273
BEQ .kb_up
; Flèche vers le bas enfoncée ?
CMP R1,274
BEQ .kb_down
; Flèche vers la droite enfoncée ?
CMP R1,275
BEQ .kb_right
; Flèche vers la gauche enfoncée ?
CMP R1,276
BEQ .kb_left
; Aucune touche connue enfoncée
BRA .gestion_couleur
; Flèche vers le haut enfoncée
; init_video_pos -= 80colonnes*2octets
.kb_up
ADD R2,-160
ST R2,.init_video_pos
BRA .gestion_couleur
; Flèche vers le bas enfoncée
; init_video_pos += 80colonnes*2octets
.kb_down
ADD R2,160
ST R2,.init_video_pos
BRA .gestion_couleur
; Flèche vers la droite enfoncée
; init_video_pos += 2octets
.kb_right
ADD R2,2
ST R2,.init_video_pos
BRA .gestion_couleur
; Flèche vers la gauche enfoncée
; init_video_pos -= 2octets
.kb_left
ADD R2,-2
ST R2,.init_video_pos
.gestion_couleur
; == Gestion de la couleur du carré ==
(...)

Assemblons ce nouveau programme et lançons RetroArch :

$ python3 asm.py demo3.asm
$ ../RetroArch/retroarch -L mon_emu_libretro.so demo3.bin

Le test des touches directionnelles devrait en principe… ne pas fonctionner ! En effet, RetroArch accapare par défaut un grand nombre de touches du clavier pour sa propre utilisation. La solution la plus simple et la plus rapide pour utiliser facilement le clavier dans un émulateur consiste à passer en mode « Game focus » en appuyant sur la touche <Arrêt défilement> du clavier. Dans ce mode, le clavier est exclusivement utilisé par l’émulateur (à l’exception de la touche <Arrêt défilement> qui permet également de sortir du mode « Game focus »).

Nous pouvons maintenant faire bouger notre carré avec le clavier, en plus de pouvoir le faire changer de couleur avec la manette (Fig. 4). Ubisoft n’a qu’à bien se tenir :-) !

demo3-s

Fig. 4 : Un petit air de Snake sur Nokia 3310 :-) !

5.5 Gestion du son

Nous allons maintenant nous attaquer à la partie sonore de l’émulateur. Notre mini-ordinateur va disposer d’un générateur de fréquences piloté via l’adresse mémoire 0x1F60. L’octet à cette adresse permet de configurer la note à produire, selon la logique suivante : 0 = pas de son, 1 = do (261,63 Hz), 2 = (293,66 Hz)… jusqu’à 7 = si (493,88 Hz). La fréquence fondamentale des différentes notes peut se retrouver sur la page [8].

En fonction de la valeur présente à l’adresse 0x1F60, nous allons donc chercher à générer des échantillons audio de la forme :

sample = A * sin(2pi*f/fs*k)

avec A l’amplitude (volume), f la fréquence de la note, fs la fréquence d’échantillonnage (que nous allons fixer à 22 kHz), et k le numéro de l’échantillon.

Libretro dispose de deux fonctions pour gérer le son :

  • Une fonction de callback de type retro_audio_sample_t, obtenue via retro_set_audio_sample(). Cette fonction prend en argument un couple d’échantillons stéréo (des entiers signés sur 16 bits).
  • Une fonction de callback de type retro_audio_sample_batch_t, obtenue via retro_set_audio_sample_batch(). Cette fonction prend en argument un tableau de couples d’échantillons stéréo.

Ces deux fonctions sont équivalentes, on utilisera donc l’une ou l’autre selon que l’on souhaite générer les échantillons audio un par un ou en groupe (batch). Pour notre exemple, nous allons utiliser la première fonction. Dans tous les cas, il faut, à chaque appel de retro_run(), produire autant d’échantillons que nécessaire pour couvrir la durée d’un rafraîchissement d’écran, soit dans notre cas 22050 Hz / 60 Hz = 367 échantillons.

Là encore, le nombre d’échantillons à produire pendant la durée d’un rafraîchissement d’écran ne tombe pas rond et il faudrait en tenir compte pour compenser cet écart à l’appel suivant de retro_run(), ce que l’on ne fera pas ici.

Revenons au code et définissons certaines variables et constantes qui vont nous être utiles :

#include <math.h>
// Fréquence audio (22 kHz)
#define AUDIO_SAMPLE_RATE 22050
// Adresse en mémoire du numéro de la note à jouer
#define SOUND_ADDR 0x1F60
// Fréquence en Hz des notes : silence, do, ré, mi, fa, sol, la, si
static float notes[] = { 0, 261.63, 293.66, 329.63, 349.23, 392.0, 440.0, 493.88 };
// Nombre d'échantillons audio par image
#define AUDIO_SAMPLES_PER_FRAME (AUDIO_SAMPLE_RATE / VIDEO_FPS)
// Valeur courante de la phase du sinus
static double sound_phase = 0;
// Volume
static int volume = 32767;
#define M_PI 3.14159265358979323846

Nous pouvons ensuite ajouter la boucle suivante dans la fonction retro_run() :

// Génération des échantillons audio
for (i = 0; i < AUDIO_SAMPLES_PER_FRAME; i++)
{
  int16_t sample = (int16_t) (volume * sin(sound_phase));
  sound_phase += 2*M_PI*notes[ram[SOUND_ADDR]]/AUDIO_SAMPLE_RATE;
  sound_phase = fmod(sound_phase, 2*M_PI);
  audio_cb(sample, sample);
}

Afin de garantir la continuité des échantillons (et ainsi éviter la présence d’artefacts audio), nous mémorisons la phase précédente du sinus (la valeur 2pi*f/fs*k) et ne travaillons que par incrément de phase (modulo 2pi).

Comme nous utilisons la bibliothèque mathématique (via l’inclusion de math.h), nous devons rajouter à la variable LDFLAGS de notre Makefile l’option -lm afin que celle-ci soit prise en compte lors de l’édition des liens.

Maintenant que notre émulateur supporte la génération (basique) de sons, nous allons créer un petit programme qui exploite cette fonctionnalité. Ce programme va se contenter de jouer en boucle un arpège de l’accord de do majeur (do - mi - sol), chaque note durant environ 200 ms.

; Lecture d'une musique en boucle
; Point d'entrée du programme
.start
; Do
LD R1,0x0100
ST R1,0x1F60
; Attente de 200 ms
LD R1,0
.sleep_note1
CMP R1,50000
BEQ .note2
ADD R1,1
BRA .sleep_note1
; Mi
.note2
LD R1,0x0300
ST R1,0x1F60
; Attente de 200 ms
LD R1,0
.sleep_note2
CMP R1,50000
BEQ .note3
ADD R1,1
BRA .sleep_note2
; Sol
.note3
LD R1,0x0500
ST R1,0x1F60
; Attente de 200 ms
LD R1,0
.sleep_note3
CMP R1,50000
BEQ .start
ADD R1,1
BRA .sleep_note3

Le programme est très simple, la petite difficulté concerne les attentes de 200 ms entre chaque note. Pour cela, nous nous basons sur le fait que chaque instruction dure 1 µs, et nous comptons donc le nombre de tours de boucle nécessaire pour atteindre la durée souhaitée. Il serait aussi possible de rajouter dans les boucles d’attente une ou plusieurs instructions NOP pour allonger leur durée.

5.6 Gestion des sauvegardes instantanées

Libretro dispose d’un mécanisme de « sauvegarde instantanée » qui permet de sauvegarder l’état courant de l’émulateur en vue d’une reprise ultérieure. Cela permet par exemple de rajouter une fonctionnalité de sauvegarde à un jeu qui en serait dépourvu. Cela offre aussi d’autres possibilités comme le rembobinage (rewind), permettant de revenir quelques secondes en arrière à tout moment pendant un jeu, mais aussi la fonctionnalité de jeu à distance (Netplay) qui permet de jouer à plusieurs à distance sur un jeu qui ne permettait que du multijoueur local.

Toutes ces fonctionnalités reposent sur trois fonctions de l’API Libretro qui sont très simples à implémenter : retro_serialize_size(), retro_serialize() et retro_unserialize().

Il faut tout d’abord se poser la question suivante : sur quoi repose l’état courant de mon émulateur ? Il s’agit a minima de la valeur des registres et de la mémoire, plus éventuellement d’autres variables internes de l’émulateur. Dans notre cas très simple, il suffit de sauvegarder la valeur courante des registres et le contenu de la mémoire pour avoir capturé tout l’état interne de l’émulateur. Nous avons 3 registres de 16 bits, un registre de 8 bits, et 64 ko de mémoire, ce qui fait en tout 65535+3*2+1=65542 octets à enregistrer.

L’implémentation de la fonction retro_serialize_size() est donc immédiate :

size_t retro_serialize_size(void)
{
  return RAM_SIZE + sizeof(r1) + sizeof(r2) + sizeof(cc) + sizeof(ip);
}

La fonction retro_serialize() est appelée pour sauvegarder l’état courant de l’émulateur. Cette fonction passe en argument un buffer alloué par le frontend et d’une taille au moins égale à la valeur renvoyée par la fonction retro_serialize_size().

bool retro_serialize(void *data, size_t size)
{
  int offset = 0;
  char *buffer = (char *) data;
  memcpy(buffer+offset, ram, RAM_SIZE);
  offset += RAM_SIZE;
  memcpy(buffer+offset, &r1, sizeof(r1));
  offset += sizeof(r1);
  memcpy(buffer+offset, &r2, sizeof(r2));
  offset += sizeof(r2);
  memcpy(buffer+offset, &cc, sizeof(cc));
  offset += sizeof(cc);
  memcpy(buffer+offset, &ip, sizeof(ip));
  return true;
}

La fonction retro_unserialize() réalise l’opération inverse. Le frontend fournit un buffer contenant les données qui ont été précédemment enregistrées par retro_serialize(), et il suffit de réinitialiser l’état des registres et de la mémoire à partir du contenu du buffer.

bool retro_unserialize(const void *data, size_t size)
{
  int offset = 0;
  const char *buffer = (const char *) data;
  memcpy(ram, buffer+offset, RAM_SIZE);
  offset += sizeof(ram);
  memcpy(&r1, buffer+offset, sizeof(r1));
  offset += sizeof(r1);
  memcpy(&r2, buffer+offset, sizeof(r2));
  offset += sizeof(r2);
  memcpy(&cc, buffer+offset, sizeof(cc));
  offset += sizeof(cc);
  memcpy(&ip, buffer+offset, sizeof(ip));
  return true;
}

Par défaut, RetroArch utilise des fichiers *.info tels que ceux présents dans le dépôt https://github.com/libretro/libretro-super/tree/master/dist/info pour connaître certaines fonctionnalités supportées par les émulateurs, dont le support des sauvegardes instantanées. En phase de développement, il est plus simple de modifier la configuration de RetroArch : dans le menu de RetroArch (<F1>), revenir au menu racine avec la touche <Backspace>, puis aller dans le menu Réglages > Cœurs et mettre la propriété Ignorer les informations du cœur pour les fonctionnalités de sauvegarde instantanée à Activé. Il est aussi possible de faire la modification directement dans le fichier de configuration de RetroArch (~/.config/retroarch/retroarch.cfg) en modifiant la propriété core_info_savestate_bypass.

On peut maintenant relancer l’émulateur et tester la sauvegarde (touche <F2>) et la restauration (touche <F4>). À noter que RetroArch dispose de plusieurs emplacements de sauvegarde/restauration et qu’il est possible de changer d’emplacement avec les touches <F6> (emplacement précédent) et <F7> (emplacement suivant). Il est également possible de tester le rembobinage en appuyant sur la touche <R>. Attention, cette fonctionnalité, qui peut être gourmande en ressources, est désactivée par défaut dans RetroArch. Pour l’activer, il suffit d’aller dans le menu de configuration : <F1> puis <Backspace>, menu Réglages > Limiteur d’images/s > Rembobinage, et activer l’option Prise en charge du rembobinage.

5.7 Gestion des options de l’émulateur

Libretro permet de gérer des options pour l’émulateur. La valeur de ces options est automatiquement sauvegardée sur le disque, puis rechargée lors d’un démarrage ultérieur du frontend.

Nous allons ici définir une unique option, permettant de régler le niveau du volume audio. On pourrait imaginer d’autres options, par exemple le réglage de la fréquence d’horloge de la machine émulée, la taille de l’écran, ou la fréquence de rafraîchissement, mais cela nécessiterait un peu plus de modifications sur l’émulateur.

Les options peuvent être définies via un tableau de structures de type retro_variable. Ces structures comportent deux champs de type chaîne de caractères : le nom de l’option (qui doit être distinctif, car utilisé pour sa sauvegarde par le frontend ; il est donc de coutume de le préfixer par le nom du core), et sa valeur. Cette dernière a une syntaxe spécifique : le nom de l’option tel qu’il sera affiché dans l’interface graphique du frontend, un point-virgule, et une liste de choix possibles séparés par le caractère « | ». À noter que le premier élément de cette liste sera la valeur par défaut.

Dans le cas de notre exemple d’option pour régler le volume, cela donne :

static const struct retro_variable prefs[] = {
  {"mon_emu_volume", "Volume; 100%|90%|80%|70%|60%|50%|40%|30%|20%|10%|0%"},
  {NULL, NULL}
};

Il faut ensuite enregistrer ces options auprès du frontend. Cela peut se faire par exemple dans la fonction retro_set_environment() :

// Définition des options de l'émulateur
env(RETRO_ENVIRONMENT_SET_VARIABLES, (void *) prefs);

L’accès à la valeur courante d’une option se fait en utilisant la fonction de callback de type retro_environment_t avec le paramètre RETRO_ENVIRONMENT_GET_VARIABLE. Dans notre cas, nous allons vérifier la valeur de cette option dans la fonction retro_run(), avant de calculer la valeur des échantillons audio :

// Lecture du volume dans les options de l'émulateur
struct retro_variable var = {0, 0};
var.key = "mon_emu_volume";
if (environ_cb(RETRO_ENVIRONMENT_GET_VARIABLE, &var))
{
  volume = (32767 * atoi(var.value)) / 100;
}

Après recompilation du core, nous pouvons accéder à son option en appuyant sur <F1> puis en allant dans le menu Options de cœur.

5.8 Gestion des cheat codes

Il nous reste deux fonctions à voir de l’API Libretro, retro_get_memory_data() et retro_get_memory_size(). Ces méthodes permettent de donner au frontend un accès à la mémoire du système émulé, ce qui va lui permettre par exemple de trouver et d’appliquer des « cheat codes » (vies et armes illimitées…). Les deux fonctions prennent en argument un nombre représentant le type de mémoire auquel on cherche à accéder. Le cas le plus fréquent est l’accès à la mémoire vive (RAM) du système, et c’est le seul que nous allons gérer ici.

Nous complétons donc la définition de ces deux fonctions avec le code suivant :

void *retro_get_memory_data(unsigned id)
{
  switch (id)
  {
    case RETRO_MEMORY_SYSTEM_RAM:
      return ram;
  }
  return NULL;
}
 
 
size_t retro_get_memory_size(unsigned id)
{
  switch (id)
  {
    case RETRO_MEMORY_SYSTEM_RAM:
      return RAM_SIZE;
  }
  return 0;
}

Après recompilation du core avec make, lançons notre premier programme demo1.bin pour tester les fonctionnalités de « cheats » de RetroArch :

$ ../RetroArch/retroarch -L mon_emu_libretro.so demo1.bin

Nous allons essayer de trouver un cheat code permettant de modifier la couleur du carré. Allons dans le menu de RetroArch avec <F1>, puis dans Cheats > Lancer/continuer la recherche de cheats. La variable que nous cherchons à modifier est une valeur 16 bits, 0xFFFF (blanc en RGB565). Changeons la configuration du menu Lancer/redémarrer la recherche de cheats pour sélectionner « 16-bit, valeur max = 0xFFFF » puis appuyons sur <Entrée> pour initialiser la recherche. Allons ensuite sur l’option Recherche d’une valeur en mémoire pour mettre « Égale à 65535 (FFFF) » puis appuyons sur <Entrée>. RetroArch devrait trouver une centaine de correspondances en mémoire (les pixels blancs en mémoire vidéo, et la variable .couleur du programme). Sélectionnons ensuite plus bas l’option Ajouter les 100 correspondances à votre liste, puis revenons au niveau précédent du menu avec <Backspace>. Ce menu affiche toutes les correspondances trouvées. On voit que toutes, sauf la dernière, correspondent à des adresses dans la mémoire vidéo. C’est donc la dernière qui nous intéresse et qui correspond à la variable .couleur du programme. Sélectionnons donc ce dernier cheat, et changeons l’option Valeur pour mettre une autre couleur, par exemple 0xFFE0 (jaune). Sélectionnons enfin Activé puis revenons au niveau précédent avec <Backspace> et Appliquer les changements. On quitte (enfin) le menu de RetroArch avec <F1> et on admire notre carré de couleur… rose ! Eh oui, encore un problème d’endianess, car RetroArch applique la valeur en mémoire avec l’endianess de la machine hôte, et donc il faudrait spécifier 0xE0FF comme valeur au lieu de 0xFFE0 pour obtenir du jaune.

Conclusion

J’espère que ce petit tour de Libretro vous aura plu. Nous avons vu pas mal de choses : l’architecture générale de Libretro, la conception d’un mini-ordinateur, puis son émulation. Vous avez maintenant tous les éléments en main pour concevoir votre propre émulateur, ou plus simplement proposer des améliorations et des corrections à ceux existants. Et si vous êtes nostalgiques des micro-ordinateurs 8 bits Thomson (MO5, TO8…), n’hésitez pas à tester et contribuer à mon émulateur Theodore [3] !

Références

[1] https://www.libretro.com/

[2] https://www.retroarch.com/

[3] https://github.com/Zlika/theodore

[4] https://fr.wikipedia.org/wiki/Ordinateur_%C3%A0_jeu_d%27instruction_unique

[5] https://docs.libretro.com/guides/input-and-controls/

[6] https://github.com/Zlika/libretro-tutorial

[7] https://rgbcolorpicker.com/565

[8] https://fr.wikipedia.org/wiki/Note_de_musique



Article rédigé par

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous