RetroArch : quesaco ?

Retroarch logo

Dans le cadre d’un projet d’étude, nous avons été amené, mon équipe et moi même, à développer l’émulateur d’un système. Nous avons sélectionné CHIP-8, un langage de programmation interprété, développé par Joseph Weisbecker au milieu des années 80, pour les micro-ordinateurs 8-bit de l’époque. Ce n’est donc pas vraiment une machine à proprement parler, nous l’avons cru pendant un moment, puisqu’il est parfois confondu, sur le net, avec le premier appareil à en faire usage, à savoir le COSMAC VIP. On parle donc plutôt d’interpréteur ici pour être exact, mais le terme d’émulateur n’est pas complètement erroné. En tout cas, reproduire son fonctionnement est aujourd’hui le point de départ typique pour quelqu’un qui souhaite mettre les pieds dans cet univers, le Hello World de ce monde, en quelque sorte.

Et c’est ce que nous avons fait ! Après quelques jours, nous avions terminé et notre joli programme était prêt à être partagé au monde. Je n’entrerai pas dans les détails techniques de celui-ci, il y a déjà beaucoup d’articles qui détaille l’architecture et l’implémentation d’un tel projet. Je vous redirige vers celui-ci, écrit par un des membres de notre équipe. Quoi qu’il en soit, la question était la manière dont nous allions distribuer ce projet. Il existe une plateforme entièrement dédiée à ce genre de logiciel : RetroArch !

libretro et RetroArch

Quand on s’intéresse à cette plateforme, ces deux mots-clés reviennent souvent, et il est important de bien faire la distinction.

libretro est une API, basée en C, qui permet de structurer la création d’un émulateur. Concrètement, cela se traduit par un fichier d’en-tête composé de structures, de valeurs constantes, mais surtout de définitions de fonctions à implémenter. Le but d’un développeur est de mettre en oeuvre cette API et de la compiler en une bibliothèque dynamique,obtenant ainsi un fichier appelé cœur libretro.

RetroArch, quant à lui, est l’implémentation de référence de cette API. Si vous êtes intéressé par le retro-gaming, vous en avez probablement déjà entendu parler. Pour un utilisateur, il s’agit d’une application qui regroupe plusieurs émulateurs en un seul endroit. Pour un développeur, c’est un front-end capable d’exécuter des cœurs libretro. Ce sont en général des émulateurs, mais en réalité n’importe quel programme implémentant l’API est éligible. En d’autres termes, RetroArch est un lecteur de coeurs libretro. D’autres plateformes dialoguent également cette APIen voici une liste non-exhaustive donnée par libretro. Cela est permit grace à la la dimension open-source du projet.

. Il en existe et on y compte : des jeux vidéo, des moteurs de jeu, des lecteurs multimédia, etc.

Pour notre émulateur de CHIP-8, nous avions déjà réalisé un front-end via la bilbiothèque C SDL. Nous allons expliciter pourquoi libretro est plus intéressant (pour notre cas et même en général).

  1. Multiplateforme
  2. RetroArch a été porté vers un nombre conséquent de plateformes différentes,comparativement à SDL qui est une bibliothèque bien moins portable. On parle ici d’un système qui supporte des ordinateurs anciens comme modernes, ainsi que des consoles de jeu vidéo telles que la PlayStation 2 et la Nintendo 3DS. Un simple accès à un navigateur internet peut même suffire. Si votre implémentation de libretro est portable, c’est-à-dire qu’elle n’interagit pas directement avec des fonctions systèmes endémiques à certains OS, il suffit de la compiler correctement pour chaque plateforme souhaité, afin d’avoir un émulateur fonctionnel partout.

  3. Abstraction
  4. libretro propose, à travers ses fonctionnalités, un niveau d’abstraction spécialement conçu pour le développement d’émulateurs. Quelques exemples : la boucle prinicpale de l’émulateur, l’action de charger une ROM, de réaliser une save-state, etc. Le front-end se chargera de lui-même d’appeler ces fonctions au moment opportun. La manette est elle aussi abstraite à travers RetroPad. Il s’agit d’une manette fictive qu’un développeur de coeur libretro est recommendé d’implémenter. RetroArch se charge d’adapter n’importe quelle entrée qu’il reçoit comme une entrée RetroPad. Ces abstractions nous permette de nous concentrer sur les parties intéressantes du développement d’émulateur et d’éviter de réinventer la roue pour chaque fonctionnalité basique.

  5. Non-destructeur
  6. Pour finir, l’intégration d’un émulateur déjà développé ne modifie pas l’architecture initiale du projet. Pour reprendre notre émulateur de CHIP-8, nous avons toujours notre implémentation via SDL de fonctionnelle : créer un cœur s’est contenté à créer un fichier dédié et y rediriger les fonctions demandées par libretro vers celles déjà produites dans notre système. Nous détaillerons dans la partie suivante notre implémentation de chacune de des fonctions de l’API libretro, où cela sera visible. Un véritable travail de postier. Nombre d’émulateurs proposent eux aussi leur cœur libretro comme une alternative à l’accès direct au programme originel. libretro joue alors un deuxième rôle fort appréciable dans ces cas-là : celui d’auto-documentation. La base de code des gros projets d’émulateurs tel que Dolphin peut être extrêmmement dur à lire pour un profane à leur architecture. Toutefois, leur implémentation de libretro peut jouer le rôle de guide dans ce labyrinthe, en nous redirigeant vers les fichiers pertinents pour chaque tâche principale qui incomberait d’un émulateur. Le rôle de chaque fonction de libretro étant déjà documenté.

Pour un utilisateur final, l’interface graphique navigable à la manette, l’unification de tous les émulateurs dans un seul paquet, le support massif de différents contrôleurs avec bindings complètements personallisables, etc. sont des arguments largement suffisants pour rentrer dans l’écosystème RetroArch.

Comment qu’on fait et comment que ça marche ?

Dans cette partie, je vais rentrer dans le détail du travail d’implémentation de libretro pour un émulateur. Comme on l’a vu plus haut, il suffit de glisser le fichier libretro.h dans notre projet et d’écrire le fichier libretro.c correspondant qui implémente les définitions de fonctions demandées. Par chance notre émulateur était déjà écrit en C, mais il existe des bindings pour libretro dans d’autres languages. Il faut que le langage de programmation utilisé permette d’une manière ou d’une autre de travailler avec du C. Les fonctions sont déjà documentés dans le fichier lui-même et dans le site de RetroArch, mais je me sens d’humeur généreuse et je vous propose d’expliquer tout ça encore plus simplement avec la meilleure technique d’apprentissage qui soit : par l’exemple.

Ci-joint un diagramme qui montre à quel moment et dans quel ordre sont appelés les fonctions de l’API. La documentation de libretro donne des indications à ce sujet, mais il ne s’agit pas d’un ordre strict. On peut imaginer que RetroArch suit ces lignes directrices à la lettre puisqu’il s’agit de l’implémentation de référence, mais d’autres frontend sont libres de suivre les règles qu’ils souhaitent.

Fonctions d’initialisation

La première fonction à laquelle on est confronté est responsable de déclarer la version de l’API libretro sur laquelle notre coeur se base. Pour ce faire, il suffit donc de retourner la valeur de la constante RETRO_API_VERSION elle-même déjà présente dans le fichier d’en-tête.

RETRO_API unsigned retro_api_version(void) {
    return RETRO_API_VERSION;
}

Le front-end se chargera alors de vérifier la version de notre implémentation et prendra une décision adéquate. On peut imaginer que certains front-end ne supportent qu’une version précise de l’API et refuseront donc de charger le coeur si celle-ci ne correspond pas. D’autres pourraient en supporter plusieurs, mais en modifiant leur fonctionnement selon la version pour assurer d’une rétro-compatibilité, etc. Heureusement, ce n’est pas de notre ressort. Et de toute manière, à l’heure de publiage de cet article, il n’a existé qu’une seule version de libretro : la numéro 1.

On peut toutefois légitimement se demander l’intérêt d’implémenter manuellement une telle fonction étant donné que la valeur de retour est déjà présente dans le header. Selon certains, il existe des plateformes où il est difficile d’exporter un constant en C sans passer par une fonction, mais cela ne reste que des ouï-dire. Il se peut aussi qu’il s’agisse une pratique assurant que les développeurs de coeur comprennent ce principe de version.

Quoi qu’il en soit, après s’être assuré que les versions correspondent, RetroArch fournit à notre coeur une variable callback d’environnement. Le front-end nous fournira également d’autres callbacks spécialisés plus tard pour la vidéo, l’audio, etc. Ici, il s’agit d’un type extrêmement générique pour pouvoir attribuer un grand nombre d’options que les autres ne couvriraient pas. On stocke les arguments que la fonction nous fournit en tant que variable globale pour pouvoir s’en servir dans toutes les fonctions appelées plus tard. Pour manipuler le callback d’environnement, on s’en sert syntaxiquement comme d’une fonction C, avec comme premier argument l’opération qu’on souhaite réaliser, puis, selon la commande, les arguments pertinents supplémentaires. Les commandes sont définies par des constantes ayant comme préfixe le mot clé RETRO_ENVIRONMENT. Elles sont accompagnés de commentaire documentant leur usage dans le fichier libretro.h, avec notamment des détails sur les arguments à donner. Le callback a une valeur de retour : un booléen signifiant le succès de l’opération.

retro_environment_t environ_cb;
struct retro_log_callback log_cb;

RETRO_API void retro_set_environment(retro_environment_t cb) {
    environ_cb = cb;
    bool no_rom = true;
    cb(RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &no_rom);
    cb(RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &log_cb);
    cb(RETRO_ENVIRONMENT_GET_SAVESTATE_CONTEXT, NULL);
}

Dans notre cas, on a décidé de laisser la possibilité à RetroArch de lancer notre coeur même sans jeu. Un écran noir est alors présenté à l’utilisateur. Cette commande prend en argument un pointeur vers un booléen, ce qui explique pourquoi on a défini une variable locale booléenne séparée au préalable. On récupère également l’interface de log de libretro, permettant des affichages de texte cross-platform. Après tests, écrire directement à la sortie stdout via une fonction comme printf de la librairie stdio de C fonctionne également pour les versions Windows et Ubuntu de notre coeur, mais pour s’assurer d’un fonctionnement adéquat même sur des plateformes plus exotiques, c’est la chose à faire. C’est l’un des intérêts et philosophie derrière libretro. Un développeur plus strict aurait également vérifier le succès de ces opérations avec le booléen de retour, mais personne n’est parfait.

La dernière étape de l’intialisation d’un coeur est l’appel à la fonction éponyme retro_init. Les opérations réalisées à l’intérieur dépendent grandement du système que l’on souhaite émuler.

chip8_t chip8;

RETRO_API void retro_init(void) {
    initialize_chip8(&chip8);
}

RETRO_API void retro_deinit(void) {
}

Pour notre part, on a simplement décidé de rediriger la fonction vers la notre qui sert le même rôle ! Ce morceau de code représente parfaitement le point de non-destruction auquel je faisais référence plus tôt : avec un émulateur est déjà prêt, le recâbler à libretro est un jeu d’enfant. Pour plus de détail sur notre implémentation de CHIP-8, je vous rappelle de nouveau l’existence de l’article soeur à celui-ci. Nous avons laissé sa fonction soeur, retro_deinit(), vide étant donné que l’initialisation joue également le rôle de remise à zéro dans notre cas.

Il existe une fonction documentée comme étant appelable à tout moment, y compris avant retro_init(). Il s’agit de retro_get_system_info(). Le rôle de celle-ci est de donner au frontend des informations basiques sur notre système, avec, entre autres, son nom, sa version, et, si le coeur supporte de charger des fichiers de contenu telles que des ROM, les extensions qu’elle devrait avoir dans son nom, séparés par un signe | ainsi que si oui ou non il faut donner le chemin entier du fichier sélectionné par l’utilisateur au coeur pour qu’il puisse être chargé.

RETRO_API void retro_get_system_info(struct retro_system_info *info) {
    // info->block_extract = false;
    info->library_name = "Chip8";
    info->library_version = "1.0";
    info->need_fullpath = true;
    info->valid_extensions = "ch8|rom";
}

Le front-end nous fournit en argument un pointeur vers une structure que l’on va modifier. Celui-ci s’en servira alors comme information pour l’affichage utilisateur. Sur la version Windows et Ubuntu de RetroArch, on peut apercevoir le nom et la version du coeur chargé dans le coin inférieur gauche de l’écran. Les extensions valides sont utilisés dans l’explorateur interne de RetroArch, jouant le rôle de filtre. Pour need_fullpath, son rôle sera plus clair lorsque nous verrons la fonction retro_load_game(). retro_get_system_info() semble jouer un rôle important en termes d’interface utilisateur : il n’en est en réalité pas grand chose, nous le verrons notamment lorsque nous parlerons du fichier de type INFO.

Fonctions pour lancer une ROM/le coeur

On s’attaque maintenant au coeur du sujet de notre coeur… ba dum tuss. Lancer le contenu sélectionné par un utilisateur.

En premier lieu, il s’agit, si notre coeur le supporte, de charger celui-ci.

RETRO_API bool retro_load_game(const struct retro_game_info *game) {
    if (game == NULL) {
        return false;
    }
    
    return load_rom(&chip8, game->path);
}

RETRO_API void retro_unload_game(void) {
    unload_rom(&chip8);
}

On aperçoit similairement à retro_init() que ce genre de fonction peut être très court si l’émulateur est déjà prêt en avance. RetroArch nous fournit un pointeur vers un struct détenant des informations sur le contenu chargé par l’utilisateur. On aperçoit que contrairement à retro_get_system_info(), le pointeur est accompagné du mot-clé const, indiquant qu’on se contentera de lecture ici. Il y contient, selon les cas, le chemin vers le fichier ou son contenu interna et sa taille. Cela dépend de l’option choisie dans retro_get_system_info(). Si vous avez attribué à info->need_fullpath la valeur booléenne vraie, le chemin devrait être tout le temps renseigné et les deux autres champs vides, sinon l’inverse. On a pris la précaution de faire un null-check sur le pointeur mais il ne devrait pas être nécessaire. Selon le système que vous émulez, cette fonction peut être non-essentielle. On peut imaginer qu’un émulateur de borne d’arcade de Pac-Man peut s’en passer. Cela dépendra exclusivement de la valeur que vous attribuez à l’option RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME via le callback générique.

Après avoir chargé notre contenu (ou s’en être passé), RetroArch fournit à notre coeur des variables de callbacks spécialisés via des fonctions ayant pour préfixe retro_set. Comme pour le callback générique plus tôt, on stocke les arguments de la fonction en tant que variables gloabales pour s’en servir plus tard. Pas tous, à vrai, mais autant tous les prendre puisqu’on les a.

retro_video_refresh_t video_cb;
retro_audio_sample_t audio_cb;
retro_audio_sample_batch_t audio_batch_cb;
retro_input_poll_t input_poll_cb;
retro_input_state_t input_state_cb;

RETRO_API void retro_set_video_refresh(retro_video_refresh_t cb) {
    video_cb = cb;
}

RETRO_API void retro_set_audio_sample(retro_audio_sample_t cb) {
    audio_cb = cb;
}

RETRO_API void retro_set_audio_sample_batch(retro_audio_sample_batch_t cb) {
    audio_batch_cb = cb;
}

RETRO_API void retro_set_input_poll(retro_input_poll_t cb) {
    input_poll_cb = cb;
}

RETRO_API void retro_set_input_state(retro_input_state_t cb) {
    input_state_cb = cb;
}

Si vous êtes familiers aux languages haut-niveau typés objet à la Java/C#, ces fonctions ressemblent beaucoup aux typique setters qu’on y retrouve partout, que ce soit dans le fond et la forme.

Avant de faire apparaître une fenêtre, RetroArch doit récolter des informations sur comment notre coeur doit s’afficher. Pour être précis, il nous est demandé la longueur/largueur en pixel, le facteur de forme, le taux d’images par seconde et d’échantillonnage audio.

RETRO_API void retro_get_system_av_info(struct retro_system_av_info *info) {
    enum retro_pixel_format pixel_format = RETRO_PIXEL_FORMAT_RGB565;
    environ_cb(RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &pixel_format);
    
    info->geometry.base_width = DISPLAY_WIDTH;
    info->geometry.base_height = DISPLAY_HEIGHT;
    info->geometry.max_width = DISPLAY_WIDTH;
    info->geometry.max_height = DISPLAY_HEIGHT;
    info->geometry.aspect_ratio = 0.0;

    info->timing.fps = FRAMERATE;
    info->timing.sample_rate = 44100.0;
}

À la manière de la fonction retro_get_system_info() que l’on verra plus tard, on nous fournit en argument un pointeur vers une structure à remplir. Il suffit de remplir les champs de cette struct avec les informations demandées. C’est également l’occasion de renseigner le format des pixels qu’on va donner au callback vidéo. Le format recommandé par défaut est RETRO_PIXEL_FORMAT_RGB565. En d’autres termes, cela signifie que les couleurs seront représentées par des entiers à 16 bits/2 octets, avec, dans l’ordre de gauche à droite : 5 bits pour la couleur rouge, 6 pour le vert et 5 pour le bleu. Il en existe d’autres, tous documentés dans le fichier d’en-tête avec pour préfixe RETRO_PIXEL_FORMAt, mais c’est celui-ci que nous avons sélectionné.

Pour finir, probablement la fonction la plus importante : la boucle principale d’exécution ! Elle joue le même rôle qu’une fonction run ou update ferait dans un framework de création de jeu vidéo. Cette fonction sera appelée avant la production de chaque image par RetroArch. Donc, celle-ci sera exécuté à la même vitesse que le taux d’images par seconde attribué dans la fonction retro_get_system_av_info().

RETRO_API void retro_run(void) {
    log_cb.log(RETRO_LOG_INFO, "[CHIP-8] Running frame.\n");
    
    // polling input
    int inputs[0xF] = { RETRO_DEVICE_ID_JOYPAD_UP, RETRO_DEVICE_ID_JOYPAD_DOWN, RETRO_DEVICE_ID_JOYPAD_LEFT, RETRO_DEVICE_ID_JOYPAD_RIGHT, RETRO_DEVICE_ID_JOYPAD_A, RETRO_DEVICE_ID_JOYPAD_B, RETRO_DEVICE_ID_JOYPAD_X, RETRO_DEVICE_ID_JOYPAD_Y, RETRO_DEVICE_ID_JOYPAD_L, RETRO_DEVICE_ID_JOYPAD_R, RETRO_DEVICE_ID_JOYPAD_SELECT, RETRO_DEVICE_ID_JOYPAD_START, RETRO_DEVICE_ID_JOYPAD_L2, RETRO_DEVICE_ID_JOYPAD_R2, RETRO_DEVICE_ID_JOYPAD_L3, RETRO_DEVICE_ID_JOYPAD_R3 };
    for (int i = 0; i < 0xF; i++) {
        chip8.pressed_keys[i] = false;
    }
    input_poll_cb();
    for (int i = 0; i < MAX_PLAYERS; i++) {
        chip8.pressed_keys[i] |= input_state_cb(i, RETRO_DEVICE_JOYPAD, 0, inputs[i]);
    }

    // update chip 8 state
    update_timers(&chip8);
    run(&chip8, 1. / FRAMERATE * chip8.instructions_per_second);

    // traduction des graphismes vers retroarch
    uint16_t buffer[DISPLAY_WIDTH * DISPLAY_HEIGHT];
    for (int i = 0; i < DISPLAY_WIDTH * DISPLAY_HEIGHT; i++) {
        buffer[i] = chip8.display[i] ? 0xFFFF : 0x0000;
    }
    video_cb(buffer, DISPLAY_WIDTH, DISPLAY_HEIGHT, sizeof(uint16_t) * DISPLAY_WIDTH);
}

Comme pour retro_init, les opérations réalisées à l’intérieur de cette fonction dépend de ce que l’on souhaite implémenter. Toutefois, la plupart des émulateurs (voire des jeux vidéo en général) ont un schéma de fonctionnement très similaire, à savoir :

  1. Récupération des entrées de l’utilisateur
  2. Il faut d’abord sonder l’état des entrées de l’utilisateur via le callback approprié : input_poll_cb(). Ensuite, pour savoir si une touche a été bien utilisée, on se sert du callback input_state_cb(), avec les arguments suivants dans l’ordre : l’index du port que l’on souhaite sonder, où l’index 0 représenterait le joueur 1 (par défaut, mettre cette valeur à 0 sauf si vous voulez supporter du multijoueur local) ; le type de périphérique que vous souhaitez sonder ; un index quelconque (je n’ai pas réussi à comprendre son rôle, l’attribuer à 0 fait l’affaire…) ; et pour finir, la touche en question que l’on souhaite tester. La fonction renvoit 0 si celle-ci a été appuyée depuis le dernier sondage, sinon une valeur strictement positive.

  3. Mise à jour de l’état du système.
  4. C’est ici qu’on exécuterait les instructions de notre système pour faire avancer son état. Étant donné que la fonction run est exécutée autant de fois que d’images sont produites en une seconde, on obtient le nombre de cycles à lancer lors d’une itération via le calcul suivant : 1.0 / f * hz, où f est le taux d’images par seconde attribué et hz le nombre d’hertz du CPU émulé (ou autre valeur équivalente)

  5. Afficher les graphismes
  6. La dernière étape consiste à communiquer à RetroArch les pixels à afficher. Le format de pixel que le callback vidéo prend en entrée dépend de la valeur qui a été attribué via la commande RETRO_ENVIRONMENT_SET_PIXEL_FORMAT avec le callback générique, comme nous l’avions fait dans la fonction retro_get_system_av_info. Ici, on procède en initialisant un tableau ayant pour capacité le nombre total de pixels (largeur * longueur), représentant ceux-ci de gauche à droite. Après l’avoir alimenté avec les valeurs adéquates, on le donne au callback vidéo, en fournissant dans l’ordre : le pointeur vers le tableau, la longueur en pixel, la largeur en pixel, et la taille mémoire du tableau en octet.

Et voilà le travail ! Avec tout cela, on a le minimum requis pour le fonctionnement d’un coeur sur RetroArch. Mais on peut aller plus loin, avec notamment des fonctionnalements typiques des émulateurs qui peut être facilement intégrée grâce à RetroArch…

Fonctionnalités annexes

L’implémentation des fonctions qui suivent permet la support de la fonctionnalité des save states. Celle-ci permet par effet de cascade le rewind.

Le fonctionnement est le suivant : RetroArch va nous demander le nombre d’octets nécessaire à la représentation de l’état courant de l’émulateur. Ensuite, soit on enregistre l’état actuel, via retro_serialize(), soit on en charge un déjà existant via retro_unserialize().

RETRO_API size_t retro_serialize_size(void) {
    return sizeof(chip8_t);
}

RETRO_API bool retro_serialize(void *data, size_t size) {
    if (size < sizeof(chip8_t)) {
        return false;
    }
    chip8_t *state = data;
    *state = chip8;
    return true;
}
    
RETRO_API bool retro_unserialize(const void *data, size_t size) {
    if (size < sizeof(chip8_t)) {
        return false;
    }
    const chip8_t *state = data;
    chip8 = *state;
    return true;
}

Dans notre cas, le struct chip8 est le candidat parfait pour représenter l’état actuel de l’émulateur. Il contient également des informations superflues à ce but, et la taille des save states aurait donc pu être encore optimisé, mais on est pas à quelques octets près. On profite du mot-clé sizeof de C, qui se charge de la sale besogne de nous fournir le nombre qu’on cherche. Il faut donner à RetroArch une taille suffisante (ou plus…) pour qu’il puisse allouer autant d’espace que nécessaire lors des opérations qui suivront.

Dans retro_serialize(), le frontend nous donne un pointeur vers un emplacement de stockage ainsi que sa taille. Notre but est donc de charger vers ce pointeur tout ce qui nous sera nécessaire pour réobtenir dans retro_unserialize() des données traitables pour retourner à l’état où l’on était. Pour ce faire, avec un struct, un simple jeu de casting de pointeurs fait l’affaire.

Pour retro_unserialize(), c’est l’inverse. On a accès aux mêmes arguments, mais on constate que le pointeur vers data est cette fois accompagné du mot-clé const, indiquant qu’on se contentera de lecture ici. On réalise tout simplement l’opération inverse de la sérialisation faite précédemment, en cette fois-ci retournant à l’état récupéré via l’argument data.

Dans les deux cas, on retourne un booléen signifiant le succès de l’opération. Il faut donner une attention particulière à ce que la taille donnée en argument soit bien suffisante, auquel cas des comportements inattendus pourraient avoir lieu. Certains systèmes peuvent avoir des états à taille fluctuente : il faut s’assurer qu’avec le temps, le même espace ou plus soit requis pour recouvrer l’état et jamais le contraire afin de s’assurer que les données qu’on produit restent lisibles.

Autres fonctions

Pour finir, ces fonctions jouent soit un rôle extrêmement mineurs, ou alors n’ont pas été suffisament comprise pour que l’on puisse faire une analyse dessus. Je vous propose tout de même de les lister en vrac avec un court commentaire pour chacun !

RETRO_API void retro_set_controller_port_device(unsigned port, unsigned device) {
    if (port - 1 > MAX_PLAYERS) {
        log_cb.log(RETRO_LOG_INFO, "[CHIP-8] Cannot plug more devices, port is too high (%d)", port);
        return;
    }
    static struct retro_input_descriptor empty_input_descriptor[] = { { 0 } };
    struct retro_input_descriptor descriptions[2+1] = {0}; /* set final record to nulls */
    struct retro_input_descriptor *needle = &descriptions[0];

    log_cb.log(RETRO_LOG_INFO, "[CHIP-8] Blanking existing controller descriptions.\n", device, port);
    environ_cb(RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS, empty_input_descriptor); /* is this necessary? it was in the sample code */

    log_cb.log(RETRO_LOG_INFO, "[CHIP-8] Plugging device %u into port %u.\n", device, port);    switch (device) {
        case RETRO_DEVICE_JOYPAD:
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_UP; needle->description = "0"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_DOWN; needle->description = "1"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_LEFT; needle->description = "2"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_RIGHT; needle->description = "3"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_A; needle->description = "4"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_B; needle->description = "5"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_X; needle->description = "6"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_Y; needle->description = "7"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_L; needle->description = "8"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_R; needle->description = "9"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_SELECT; needle->description = "A"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_START; needle->description = "B"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_L2; needle->description = "C"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_R2; needle->description = "D"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_L3; needle->description = "E"; needle++;
            needle->port = port; needle->device = device; needle->index = 0; needle->id = RETRO_DEVICE_ID_JOYPAD_R3; needle->description = "F"; needle++;
            break;
        case RETRO_DEVICE_KEYBOARD:
        default: log_cb.log(RETRO_LOG_ERROR, "[CHIP-8] Invalid device type: %u\n", device);
    }

    /* construct final zeroed record */
    needle->port = 0;  needle->device = 0;  needle->index = 0;
    needle->id = 0;    needle->description = NULL;
    environ_cb(RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS, descriptions);
}

J’ai mentionné plus haut l’existence de RetroPad, une manette fictive qui permet de faire abstraction sur tous les périphériques que l’utilisateur pourrait utiliser comme entrée. libretro propose, en plus de RetroPad, un support direct pour d’autres controleurs alternatifs où RetroPad ne suffirait pas (gyroscope, par exemple). Cette fonction joue le rôle de lier ces entrées alternatives à libretro, pour que le sondage des entrées les prenne en compte. Pour ce faire, un tableau de retro_input_descriptor que l’on remplit avec une description de son rôle, et que l’on alimente à RetroArch via la commande RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS. Dans notre cas, cette fonction est complètement inutile puisque RetroPad nous suffit et qu’il est par défaut lier à tous les ports de RetroArch. Plus haut, vous êtes témoin d’un idiot qui a essayé d’adapter cela pour rien.

RETRO_API unsigned retro_get_region(void) {
    return RETRO_REGION_NTSC;
}

Une fonction pour détermienr la région du système. Les valeurs de retour possibles sont les régions PAL et NTSC. Son rôle est aujourd’hui encore inconnu. Certains jeux vidéo de l’époque de la PlayStation 2 proposaient des affichages différents selon la région de la console. Les télévisions européennes, PAL, avec un taux d’images par seconde limité à 50. Chez les américains, NTSC, on montait jusqu’à 60. Peut être que certains front-end permettent de jouer avec cette fonctionnalité via ce paramètre, mais ce ne sont que des théories…

RETRO_API void *retro_get_memory_data(unsigned id) {
    switch (id) {
    case RETRO_MEMORY_SYSTEM_RAM:
        return chip8.memory;
    default:
        return NULL;
    }
}

RETRO_API size_t retro_get_memory_size(unsigned id) {
    switch (id) {
    case RETRO_MEMORY_SYSTEM_RAM:
        return MEMORY_SIZE;
    default:
        return 0;
    }
}

Ces fonctions permettent au front-end de peek vers des emplacements mémoires du système émulé. C’est la seule information qu’on a pu tirer. Dans notre cas, on a indiqué l’emplacement à regarder pour la RAM de celle-ci. Quel rôle joue-t-elle ? Pourquoi distinguer les différentes mémoires existentes ? Aucune idée.

Compilation

Il est bien joli tout ce code, ce serait cool de pouvoir l’utiliser. Pour rappel, les coeurs libretro sont des bibliothèques dynamiques. Ci-suit, le groupe de règles Makefile que nous avons utilisé pour sa création sur Linux.

CC_LINUX=gcc
COMMON_FLAGS=-g -Wall
SHARED_FLAGS=-fPIC
LD_FLAGS=-shared

$(BIN)/$(CORE_NAME)_linux.so: $(OBJ)/libretro.o $(OBJ)/chip8.o $(OBJ)/stack.o
	$(CC_LINUX) $(COMMON_FLAGS) -o $@ $^ $(LD_FLAGS)

$(OBJ)/libretro.o: $(CHIP8_SRC_DIR)/libretro.c
	$(CC_LINUX) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

$(OBJ)/chip8.o: $(CHIP8_SRC_DIR)/chip8.c
	$(CC_LINUX) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

$(OBJ)/stack.o: $(CHIP8_SRC_DIR)/stack.c
	$(CC_LINUX) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

Pour obtenir un coeur libretro, on peut compiler chaque fichier utilisé en object, y compris le fichier implémentant libretro, puis tous les réunir en une bibliothèque dynamique. Ces dernières ont, sur les systèmes Unix, par convention, l’extension .so pour shared object. Sur Windows, on parle plutôt de .dll. Pour faire fonctionner le coeur sur un autre système, il suffit de remplacer le compilateur par celui adéquat pour la plateforme visée. Voilà par exemple un aperçu des règles de notre Makefile pour la version Android de notre coeur.

CC_ANDROID=aarch64-linux-android21-clang

$(BIN)/chip8_core_android.so: $(OBJ)/libretro_android.o $(OBJ)/chip8_android.o $(OBJ)/stack_android.o
	$(CC_ANDROID) $(COMMON_FLAGS) -o $@ $^ $(LD_FLAGS)

    $(OBJ)/libretro_android.o: $(CHIP8_SRC_DIR)/libretro.c
	$(CC_ANDROID) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

$(OBJ)/chip8_android.o: $(CHIP8_SRC_DIR)/chip8.c
	$(CC_ANDROID) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

$(OBJ)/stack_android.o: $(CHIP8_SRC_DIR)/stack.c
	$(CC_ANDROID) $(COMMON_FLAGS) $(SHARED_FLAGS) -c $< -o $@

La seule différence est effectivement le compilateur utilisé. Lorsque l’on compile pour un système A depuis un système B, on parle de cross compilateur. RetroArch propose, dans son répertoire GitHub, une longue liste de fichiers Makefile chacun adaptés pour une plateforme différente officiellement supporté par RetroArch. Pour en apprendre dessus, il serait pertinent d’y jeter un oeil.

Fichier info

On en a enfin fini avec le code ! Mais pas tout à fait. Il est recommandé que notre coeur soit accompagné d’un fichier intitulé nom_du_fichier_core.info. Celui-ci dispose d’informations sur ce que doit afficher RetroArch dans son interface à propos de notre coeur. Tout ce que l’on voit avant de le lancer dans RetroArch vient de ce fichier.

Ça ne vous dit pas quelque chose ? Et oui, c’est exactement le rôle de la fonction retro_get_system_info() que je vous décris là. En fait, RetroArch se servira très peu des informations recueuillies dans ce dernier. S’il le peut, il s’en passera. Le raisonnement est simple : pour certains systèmes, charger un coeur est un processus long. Le faire pour tous les coeurs, ça peut vouloir dire attendre plusieurs dizaines de minutes. On compte par exemple la Nintendo 3DS, comme système où le chargement de coeur est long. C’est pourquoi ces fichiers textes ont été conçus, permettant un chargement bien plus rapide.

En se servant de ce template, on peut facilement obtenir un fichier avec les informations adéquates. Chaque champ y est également accompagné d’un commentaire qui explique ce qui est attendu. Ce répertoire GitHub comprend une liste exhaustive des fichiers .info de tous les coeurs intégrés à RetroArch, ce qui peut être utile si vous avez un doute sur un champ en particulier. Ci-dessous, le notre.

## All data is optional, but helps improve user experience.

# Software Information - Information about the core software itself
# Name displayed when the user is selecting the core:
display_name = "Joseph Weisbecker - Chip8 (Chip8 Emule Team)"

# Categories that the core belongs to (optional):
categories = "Emulator"

# Name of the authors who wrote the core:
authors = "Bilal Seddiki|Alban Le Jeune|William Benakli"

# Name of the core:
corename = "Chip8 Emule Team"

# List of extensions the core supports:
supported_extensions = "ch8|rom"

# License of the cores source code:
license = "GPLv3"

# Privacy-specific permissions needed for using the core:
permissions = ""

# Version of the core:
display_version = "v0.2.97.30"

# Hardware Information - Information about the hardware the core supports (when applicable)
# Name of the manufacturer who produced the emulated system:
manufacturer = "Joseph Weisbecker"

# Name of the system that the core targets (optional):
systemname = "Chip8"

# ID of the primary platform the core uses. Use other core info files as guidance if possible.
# If blank or not used, a standard core platform will be used (optional):
systemid = "chip_8"

# The number of mandatory/optional firmware files the core needs:
firmware_count = 0

# Firmware entries should be named from 0
# Firmware description
# firmware0_desc = "filename (Description)" ex: firmware0_desc = "bios.gg (GameGear BIOS)"
# Firmware path
# firmware0_path = "filename.ext"  ex: firmware0_path = "bios.gg"
# Is firmware optional or not, if not defined RetroArch will assume it is required
# firmware0_opt = "true/false"

# Additional notes:
# notes = "(!) hash|(!) game rom|(^) continue|[1] notes|[^] continue|[*] list"

# Libretro Features - The libretro API features the core supports. Useful for sorting cores
# Does the core support savestates
savestate = "true"
# If true, how complete is the savestate support? basic, serialized (rewind), deterministic (netplay/runahead)
savestate_features = "deterministic"
# Does the core support the libretro cheat interface?
cheats = "false"
# Does the core support libretro input descriptors
input_descriptors = "true"
# Does the core support memory descriptors commonly used for achievements
memory_descriptors = "false"
# Does the core use the libretro save interface or does it do its own thing (like with shared memory cards)?
libretro_saves = "false"
# Does the core support the core options interface?
core_options = "false"
# What version of core options is supported? (later versions allow for localization and descriptions)
# core_options_version = "1.0"
# Does the core support the subsystem interface? Commonly used for chained/special loading, such as Super Game Boy
load_subsystem = "false"
# Whether or not the core requires an external file to work:
supports_no_game = "true"
# Does the core have a single purpose? Does it represent one game or application, requiring predetermined support files or no external data? Used to indicate to a frontend that the core may be presented/handled independently from 'regular' cores that run a variety of content.
single_purpose = "false"
# Name of the database that the core supports (optional):
database = "CHIP-8.rdb"
# Does the core support/require support for libretro-gl or other hardware-acceleration in the frontend?
# hw_render = "false"
# Which hardware-rendering APIs does the core support? Delimited by pipe characters.
# required_hw_api = "Vulkan >= 1.0 | Direct3D >= 10.0 | OpenGL Core >= 3.3 | OpenGL ES >= 3.0"
# Does the core require ongoing access to the file after loading? Mostly used for softpatching and streaming of data
needs_fullpath = "true"
# Does the core support the libretro disk control interface for swapping disks on the fly?
disk_control = "false"
# Is the core currently suitable for general use? That is, will regular users find it useful or is it for development/testing only (subject to change over time)?
is_experimental = "false"

# Descriptive text, useful for tooltips, etc.
description = "Intepréteur du langage de programmation CHIP-8, développé par Joseph Weisbecker en 1977. Développé par la team Emule dans le cadre du projet pour le cours d'Architecture de Systèmes Avancées de l'Université Paris-Cité tenu par Jean-Baptiste Yunès."

On aperçoit à la syntaxe du fichier que malgré l’extension différente, il s’agit tout simplement d’une structure YAML déguisée.

Conclusion

Avec toutes données, vous avez toutes les clés en main pour écrire un coeur libretro pour ce que vous souhaitez : que ce soit votre tout premier projet de programmation de calculatrice graphique ou votre émulateur de Neo-Geo.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.