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.
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 :
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 :
On récupère ensuite le code source :
La compilation ensuite est triviale :
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.
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.
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 :
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 :
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 » :
Après compilation du core avec make, on lance l’émulateur via RetroArch avec la commande :
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 :
Traduit en assembleur, le programme est un peu plus complexe, mais reste compréhensible avec un peu d’habitude :
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 :
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.
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 :
On génère ainsi le fichier binaire demo1.bin qui pourra être chargé par notre émulateur :
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.
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 :
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).
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.
Et enfin une fonction qui va placer le programme en mémoire :
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 :
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é :
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.
On appellera également la fonction reset() dans retro_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.
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 :
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].
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() :
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 :
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 :
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.
Il ne reste plus qu’à assembler notre nouveau programme, brancher une manette USB sur notre PC, puis lancer RetroArch :
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) !
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).
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.
Puis nous enregistrons cette fonction auprès du frontend, par exemple dans la fonction retro_load_game() :
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 :
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) :
Assemblons ce nouveau programme et lançons RetroArch :
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 :-) !
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 = ré (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 :
Nous pouvons ensuite ajouter la boucle suivante dans la fonction retro_run() :
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.
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 :
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().
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.
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 :
Il faut ensuite enregistrer ces options auprès du frontend. Cela peut se faire par exemple dans la fonction retro_set_environment() :
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 :
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 :
Après recompilation du core avec make, lançons notre premier programme demo1.bin pour tester les fonctionnalités de « cheats » de RetroArch :
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
[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



