Article écrit par Hugo Fabre
Aujourd’hui j’aimerais vous faire découvrir ce qu’implique l’écriture d’un Core Libretro.
Je vous conseille de lire d’abord mon article précédent sur la Libretro. Pour ceux qui l’auraient loupé, je vais faire un petit récapitulatif.
La Libretro est une interface distribuée sous la forme d’un fichier header en langage C. Il y a deux façons de l’implémenter. D’un côté, le Front qui implémente de manière indépendante toute la partie graphique et la récupération des entrées utilisateur (clavier/souris, manette) pour les plateformes qu’il souhaite supporter. Charge à lui ensuite d’appeler les différentes fonctions définies par la Libretro pour faire fonctionner les différents Cores disponibles.
Le Core quant à lui implémente directement les fonctions définies par la Libretro que le Front pourra appeler au moment où il en a besoin. La majorité des Cores sont aujourd’hui des émulateurs de vieux matériels, mais il est tout à faire possible d’écrire directement un jeu vidéo. Les avantages pour le développeur du Core c’est de pouvoir faire tourner son jeu sur la multitude de plateformes supportées par les différents Fronts existants sans se soucier de l’afficher de la gestion des inputs (ce qui peut être un vrai casse-tête si l’on souhaite supporter plusieurs plateformes très différentes).
De mon côté j’ai décidé de me lancer dans l’aventure en écrivant un Core minimal pour en apprendre plus sur le fonctionnement de la Libretro et pourquoi pas aller plus loin plus tard. À la fin de l’article, nous aurons donc un core tout simple, capable de dessiner des rectangles de couleur où l’on veut dans la fenêtre et de récupérer des entrées utilisateur.
Les technologies
Bien sûr on utilisera le header de la Libretro dont la dernière révision se trouve sur le GitHub de Retroarch. Ensuite, pour tester notre Core il nous faudra bien sûr un Front pour le lancer. Pour ma part j’ai décidé d’utiliser le Front de référence Retroarch, j’ai fait ce choix, car il est stable et supporte une multitude de plateformes.
Ensuite, il nous faut choisir un langage. La Libretro étant un header C, naturellement on se tourne vers le C ou bien le C++. Pour ma part j’ai décidé de suivre une route différente. En effet, même si j’aime bien le C, j’ai beaucoup de mal à me faire au manque d’outillage moderne (je peux me tromper ne pratiquant pas régulièrement ni professionnellement) notamment au niveau des build systems. De plus j’apprécie d’avoir sous la main certains avantages que peut offrir un langage plus moderne comme une bibliothèque standard plus fournie.
Mais bon, c’est bien de rêver, il faut quand même un langage qui s’interface bien avec le C, sinon on court à la catastrophe ou au maintien très complexe d’un binding entre le C et le langage choisi. Je ne me lancerai pas ici dans une comparaison des différents concurrents possibles, je ne maitrise pas assez le sujet et ça pourrait être un article à part entière. J’ai donc limité mon choix au D et à Zig, les deux sont très compatibles avec le C et ont une documentation à disposition sur le sujet. J’ai commencé par D pour lequel la documentation est plus fournie, mais pour différentes raisons, j’ai finalement basculé sur le Zig malgré le manque (à mon gout) de documentation orientée utilisateur et pas technique (c’est un sujet connu dans la communauté qui souhaite prioriser la première version stable).
Bon, fini le bla-bla, passons au code !
Un Core
Je ne rentrerai pas en détail dans le fonctionnement de Zig parce que c’est mon premier programme écrit dans ce langage, je ne le maitrise donc pas. Par contre, quand il y aura des petits détails intéressants ou différents de ce que l’on voit d’habitude, j’expliquerai au mieux ce que j’en ai compris. Pour que vous ne soyez pas perdu, voici l’arborescence du projet :
.
├── build.zig
├── readme.md
├── src
│ ├── libretro
│ │ └── libretro.h
│ └── main.zig
└── zig-out
│ └── lib
│ ├── libzigretro-core.0.0.1.dylib
│ ├── libzigretro-core.0.dylib -> libzigretro-core.0.0.1.dylib
│ └── libzigretro-core.dylib -> libzigretro-core.0.dylib
└──zig-cache
Le fichier build.zig
sera l’équivalent d’un Makefile
, dans zig-out
nous retrouverons le résultat de nos builds (l’extension peut varier selon votre système d’exploitation). Le dossier zig-cache
est un peu à part, il contient des artifacts de build, nous ne nous y intéresserons pas, il contient des informations uniquement utiles au compilateur.
Le build
C’est bien beau d’écrire un Core, mais il faut déjà être capable de le compiler comme il faut pour qu’il soit utilisable par un Front. Voilà notre fichier de définition de build :
// build.zig
const std = @import("std");
pub fn build(b: *std.build.Builder) void {
// Les options de version standard permettent à la personne qui exécute
// `zig build` de sélectionner entre Debug, ReleaseSafe, ReleaseFast et
// ReleaseSmall.
const mode = b.standardReleaseOptions();
// On veut obtenir une bibliothèque partagée (en gros, utilisable
// au _runtime_ et pas au moment de la compilation).
const lib = b.addSharedLibrary("zigretro-core", "src/main.zig", b.version(0, 0, 1));
lib.setBuildMode(mode);
lib.addIncludePath("src/libretro");
// On link la bibliothèque standard C car nous en aurons besoin.
lib.linkLibC();
lib.install();
}
Pour plus d’informations sur le sujet, vous pouvez lire cette suite d’articles dédiée (en anglais).
Notre Core
Vous pouvez retrouver le code source complet de cette première partie ici.
En premier lieu, il va falloir faire le point sur le fonctionnement de la Libretro. D’abord, nous devrons implémenter un certain nombre de fonctions dont les symboles doivent obligatoirement se retrouver dans notre bibliothèque une fois compilée, car le Front va les appeler et crasher si elles ne sont pas présentes. La documentation sur le sujet étant assez éparse, je me suis fixé comme premier objectif de traduire le projet skeletor qui est une implémentation minimale de la Libretro en C.
En premier lieu tout en haut de notre fichier main.zig
, je vous invite à définir les imports donc nous aurons besoin.
// main.zig
const std = @import("std");
const print = @import("std").debug.print;
const lr = @cImport(@cInclude("libretro.h"));
Très simple jusque-là, Zig nous offrant la possibilité d’importer directement et d’utiliser un header C sans action de notre part.
Avant de passer à la suite, deux petits points importants pour la compréhension de la suite :
Même si on ne gère pas directement l’affichage, on doit remplir un tableau de pixels (framebuffer) que l’on renverra au front. Il y a différents formats disponibles pour gérer ces pixels, j’ai choisi d’utiliser le RETRO_PIXEL_FORMAT_XRGB8888
en gros un pixel sera composé de 4 bits, un pour la transparence (non utilisé par la Libretro mais défini dans le format), un pour le rouge, un pour vert et un pour le bleu (ARGB
).
Deuxième point, la Libretro fonctionne principalement avec un système de callbacks, en C ce sont des pointeurs sur fonctions. Charge à nous d’appeler les différents callbacks dont nous aurons besoin au bon moment.
Petite aide au besoin, vous pourrez retrouver ici un tableau explicitant les différences de nommage entre les types en C et en Zig
Ensuite, nous aurons besoin de quelques valeurs (variables ou constantes) dont on se servira plus tard :
// main.zig
// ...
// Ici on définit la taille de notre fenêtre
const video_width = 200;
const video_height = 150;
const video_pixels = video_height * video_width;
const pitch = bpp * video_width * @sizeOf(u8);
// Bits par pixel
const bpp = 4;
// Notre allocateur.
// En effet en Zig, toutes les fonctions qui allouent de la mémoire le font
// explicitement. Plusieurs avantages à ça, on peut en changer facilement
// et on sait lorsque de la mémoire est allouée et doit être libérée.
// Pour savoir comment choisir l'allocateur qui convient le mieux, il
// existe une documentation dédiée :
// https://ziglang.org/documentation/0.9.1/#Choosing-an-Allocator
//
// Ici on choisit l'allocateur standard de la libc.
const allocator = std.heap.c_allocator;
// Nous ne les utiliserons pas, mais il faut les définir ; on met donc tout à 0.
var last_aspect: f32 = 0.0;
var last_sample_rate: f32 = 0.0;
// Et enfin nos callbacks. En zig une variable doit être initialisée.
// Ici on ne connait pas leur valeur, c'est le front qui sera chargé de
// les définir en appelant les fonctions que nous implémenterons plus tard.
var logging: lr.retro_log_callback = undefined;
var log_cb: lr.retro_log_printf_t = undefined;
var environ_cb: lr.retro_environment_t = undefined;
var video_cb: lr.retro_video_refresh_t = undefined;
var audio_cb: lr.retro_audio_sample_t = undefined;
var audio_batch_cb: lr.retro_audio_sample_batch_t = undefined;
var input_poll_cb: lr.retro_input_poll_t = undefined;
var input_state_cb: lr.retro_input_state_t = undefined;
// Le framebuffer qu'on passera au callback utilisé par le Front.
// Un simple tableau de pixel.
var frame_buffer: []u8 = undefined;
// Pour nous simplifier la vie nous travaillerons avec un tableau à deux
// dimensions pour pouvoir utiliser un système de coordonnées classique (x-y).
var screen: [video_height][video_width]Color = undefined;
Nous aurons besoin ensuite de quelques utilitaires. D’abord nous allons définir une structure pour nos couleurs et en prédéfinir deux :
// main.zig
// ...
const Color = struct {
a: u8,
r: u8,
g: u8,
b: u8
};
const black = Color {
.a = 0,
.r = 0,
.g = 0,
.b = 0
};
const white = Color {
.a = 0,
.r = 255,
.g = 255,
.b = 255
};
Rien de bien particulier ici si ce n’est la syntaxe de Zig un peu différente de ce dont on a l’habitude.
Puis deux fonctions, une pour dessiner sur notre écran un rectangle à une position donnée de la couleur souhaitée, et une pour effectuer le rendu de notre écran en deux dimensions dans notre framebuffer :
// main.zig
// ...
fn draw_rectangle(x: i32, y: i32, w: i32, h: i32, color: Color) void {
// En Zig il y a plusieurs moyens de caster un nombre entier selon
// le besoin (vérification de dépassement par exemple).
// Vous pouvez retrouver plus d'information sur cet article :
// https://www.lagerdata.com/articles/an-intro-to-zigs-integer-casting-for-c-programmers
var i: usize = @intCast(usize, y);
while (i < y + h) {
var j: usize = @intCast(usize, x);
while (j < x + w) {
screen[i][j] = color;
j += 1;
}
i += 1;
}
}
fn screen_to_frame_buffer() void {
var y: usize = 0;
while (y < video_height) {
var x: usize = 0;
while (x < video_width) {
var pixel = screen[y][x];
var pixel_index = y * video_width + x;
var base_index = pixel_index * bpp;
frame_buffer[base_index] = pixel.r;
frame_buffer[base_index + 1] = pixel.g;
frame_buffer[base_index + 2] = pixel.b;
frame_buffer[base_index + 3] = pixel.a;
x += 1;
}
y += 1;
}
}
C’est un code assez classique qui parlera à tout le monde sauf le point expliqué en commentaire.
C’est bon, nous pouvons commencer à implémenter l’interface de la Libretro. On commence par le principal, la fonction qui exécutera notre logique :
// main.zig
// ...
// L'utilisation d'export permet d'exposer la fonction
// dans notre bibliothèque une fois compilée.
export fn retro_run() void {
draw_rectangle(0, 0, video_width, video_height, black);
draw_rectangle(0, 0, 20, 20, white);
screen_to_frame_buffer();
// Alors, là c'est de la magie noire, j'explique ça hors commentaire.
video_cb.?(@ptrCast(*anyopaque, frame_buffer.ptr), video_width, video_height, bpp * video_width * @sizeOf(u8));
}
Première chose, l’appel à la fonction qui est fait de manière un peu particulière video_cb.?()
, en gros on signale une fonction unsafe qui pourrait renvoyer une erreur. Ensuite, le premier paramètre @ptrCast(*anyopaque, frame_buffer.ptr)
. Ça parait complexe mais c’est assez simple. Le callback que l’on appelle prend en premier paramètre un type void*
, en gros en C c’est un type un peu fourre-tout qui signale un pointeur vers n’importe quel type. Son équivalent en Zig (à priori jamais utilisé sauf pour la compatibilité avec le C) c’est *anyopaque
, et enfin en C on peut faire ce qu’on veut et caster comme on souhaite un pointeur, en Zig il faut passer par une fonction builtin
: @ptrCast
.
C’est tout pour les grosses complexités ici, un petit point sur le dernier paramètre, c’est la Libretro qui nous l’impose, il s’appelle le pitch
. Vous pouvez retrouver dans la Libretro l’explication exacte, en gros, c’est la variable qui permettra de passer facilement de notre framebuffer à plat à un écran en deux dimensions.
Ensuite, nous allons devoir initialiser plusieurs choses. Certaines pour le bon fonctionnement du Front avec notre Core et d’autres pour nous, par exemple, il va falloir allouer notre framebuffer. Ça va prendre un peu de place, mais pas grand-chose de compliqué ici.
// main.zig
// ...
// On alloue donc notre framebuffer via notre allocateur.
// Vous noterez la gestion d'erreur un peu particulière de Zig,
// Pour plus d'information : https://ziglearn.org/chapter-1/#errors.
// De plus lorsque qu'une fonction peu renvoyer une erreur,
// Zig nous force à la traiter.
// C'est un avantage mais dans le cadre d'un PoC ça peut être frustrant.
export fn retro_init() void {
frame_buffer = allocator.alloc(u8, video_pixels * bpp) catch {
std.log.info("Could not allocate memory", .{});
std.os.exit(1);
};
}
// On n'oublie pas d'être poli, on libère la mémoire qu'on avait réservé.
export fn retro_deinit() void {
allocator.free(frame_buffer);
}
// Second usage des callbacks, passer des informations au Front.
export fn retro_set_environment(cb: lr.retro_environment_t) void {
environ_cb = cb;
var allow_no_game = true;
// Si un logger est défini par le front, on l'utilise.
if (cb.?(lr.RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &logging)) {
log_cb = logging.log;
}
// On prévient le Front que notre Core peut se lancer sans jeu.
// La libretro étant à l'origine dédiée aux émulateurs, elle considère
// que les cores doivent être démarrés avec un jeu. Nous n'allons
// pas utiliser cette fonctionnalité vu que nous développons une
// simple application.
if (cb.?(lr.RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &allow_no_game)) {}
else {
print("Unable to allow no game booting\n", .{});
return;
}
}
// Ici on ne charge rien vu qu'on démarre sans jeu,
// en revanche on spécifie le format de pixel
// qu'on va utiliser dans notre framebuffer.
export fn retro_load_game(_: [*c]lr.retro_game_info) bool {
var fmt = lr.RETRO_PIXEL_FORMAT_XRGB8888;
if (!environ_cb.?(lr.RETRO_ENVIRONMENT_SET_PIXEL_FORMAT, &fmt))
{
print("XRGB8888 is not supported.\n", .{});
return false;
}
return true;
}
// Des informations sur notre Core qui sont mises à disposition du Front.
export fn retro_get_system_info(info: [*c]lr.struct_retro_system_info) void {
info.*.library_name = "zigretro";
info.*.library_version = "0.1";
info.*.need_fullpath = true;
info.*.valid_extensions = "";
}
// Idem
export fn retro_get_system_av_info(info: [*c]lr.retro_system_av_info) void {
const aspect = 0.0;
const sampling_rate = 30000.0;
info.*.geometry.base_width = video_width;
info.*.geometry.base_height = video_height;
info.*.geometry.max_width = video_width;
info.*.geometry.max_height = video_height;
info.*.geometry.aspect_ratio = aspect;
last_aspect = aspect;
last_sample_rate = sampling_rate;
}
Ensuite, nous allons implémenter les différentes fonctions que le Front appellera pour assigner les différents callback auxquels nous aurons accès :
// main.zig
// ...
export fn retro_set_audio_sample(cb: lr.retro_audio_sample_t) void {
audio_cb = cb;
}
export fn retro_set_audio_sample_batch(cb: lr.retro_audio_sample_batch_t) void {
audio_batch_cb = cb;
}
export fn retro_set_input_poll(cb: lr.retro_input_poll_t) void {
input_poll_cb = cb;
}
export fn retro_set_input_state(cb: lr.retro_input_state_t) void {
input_state_cb = cb;
}
export fn retro_set_video_refresh(cb: lr.retro_video_refresh_t) void {
video_cb = cb;
}
Et enfin nous allons finir par implémenter des fonctions obligatoires qui ne nous serviront pas pour notre Core et qui seront donc tout simplement vides :
// main.zig
// ...
export fn retro_api_version() c_uint {
return lr.RETRO_API_VERSION;
}
export fn retro_set_controller_port_device(_: c_uint, _: c_uint) void {
}
export fn retro_reset() void {
}
export fn audio_callback() void {
}
export fn audio_set_state(_: bool) void {
}
export fn retro_unload_game() void {
}
export fn retro_get_region() c_uint {
return lr.RETRO_REGION_NTSC;
}
export fn retro_load_game_special(_: c_uint, _: [*c]lr.retro_game_info, _: usize) bool {
return false;
}
export fn retro_serialize_size() usize {
return 0;
}
export fn retro_serialize(_: *anyopaque, _: usize) bool {
return false;
}
export fn retro_unserialize(_: *anyopaque, _: usize) bool {
return false;
}
export fn retro_get_memory_data(_: c_uint) ?*anyopaque {
return null;
}
export fn retro_get_memory_size(_: c_uint) usize {
return 0;
}
export fn retro_cheat_reset() void {
}
export fn retro_cheat_set(_: c_uint, _: bool, _: [*c]u8) void {
}
Pas grand-chose à signaler ici, si ce n’est certaines syntaxes de Zig :
[*c]u8
signaler un pointeur C vers unu8
_
Zig nous interdit de définir une variable non utilisée, on lui donne donc le nom de_
on retrouve cette convention (non obligatoire) en Rubyusize
,c_uint
,*anyopaque
on en a déjà parlé, l’équivalent Zig desize_t
,unsigned int
etvoid*
- Le type de retour
?*anyopaque
qui signale qu’on retourne unvoid*
ounull
Voilà pour cette première partie. Pour tester tout ça, rien de plus simple :
zig build
chemin_vers_retroarch -v -L chemin_vers_ce_projet/zig-out/lib/libzigretro-core.0.0.1.dylib
Le -v
sert à rendre Retroarch plus verbeux pour avoir des informations de debug et le -L
à préciser le chemin vers notre Core. Si vous lancez les commandes, vous aurez un beau fond noir avec dessiné par-dessus un petit rectangle blanc à la position donnée.
Les inputs
Vous pouvez retrouver directement le code source de cette partie ici
Bon c’est sympa ce que nous avons, mais ça serait vraiment bien de dynamiser tout ça ! Pour ça nous allons récupérer les inputs grâce à la Libretro et faire bouger notre carré blanc. Il est important de noter que nous allons utiliser une toute petite partie de ce qui est possible de faire grâce à la Libretro (gérer deux joueurs via plusieurs manettes par exemple).
Tout le code source présenté est à rajouter dans le fichier main.zig
Vous allez voir c’est très simple, on va commencer par définir trois nouvelles variables pour la position de notre carré et la vitesse de déplacement :
var player_x: i32 = 0;
var player_y: i32 = 0;
const speed = 1;
Ensuite, nous allons définir une structure de données pour faire un mapping entre les définitions de touches dans la Libretro et chez nous :
// Un hash comme on peut en retrouver en Ruby,
// avec pour clef un nombre entier non signé et pour valeur un enum maison.
var key_map = std.AutoHashMap(c_uint, Key).init(allocator);
const Key = enum {
up,
down,
left,
right
};
Avant toute chose, on va écraser notre fonction d’initialisation pour allouer notre HashMap
:
export fn retro_init() void {
frame_buffer = allocator.alloc(u8, video_pixels * bpp) catch {
handle_error("Could not allocate memory");
// Pour être tout à fait franc, je ne comprend pas
// pourquoi j'ai besoin de faire ça, à creuser.
return;
};
key_map.put(lr.RETRO_DEVICE_ID_JOYPAD_UP, Key.up) catch {
handle_error("Could not allocate memory");
};
key_map.put(lr.RETRO_DEVICE_ID_JOYPAD_DOWN, Key.down) catch {
handle_error("Could not allocate memory");
};
key_map.put(lr.RETRO_DEVICE_ID_JOYPAD_RIGHT, Key.right) catch {
handle_error("Could not allocate memory");
};
key_map.put(lr.RETRO_DEVICE_ID_JOYPAD_LEFT, Key.left) catch {
handle_error("Could not allocate memory");
};
}
// J'en ai profité pour définir une petite fonction pour gérer les erreurs.
// Pas d'export ici vu qu'elle est uniquement utilisée dans notre code Zig.
fn handle_error(message: []const u8) void {
std.log.info("{s}", .{message});
std.os.exit(1);
}
// Et on n'oublie pas de libérer notre mémoire !
export fn retro_deinit() void {
allocator.free(frame_buffer);
key_map.deinit();
}
Ensuite nous aurons besoin de gérer les inputs, pour ça on rajoute une fonction :
fn process_inputs() void {
var it = key_map.iterator();
while (it.next()) |kv| {
// On appelle un callback sur lequel le Front nous renvoie 0 si la
// touche en question n'est pas appuyée.
var pressed = input_state_cb.?(0, lr.RETRO_DEVICE_JOYPAD, 0, kv.key_ptr.*);
// Si la touche demandée est appuyée, la Libretro nous renvoie autre
// chose que 0.
if (pressed != 0) {
switch (kv.value_ptr.*) {
Key.up => player_y -= speed,
Key.down => player_y += speed,
Key.right => player_x += speed,
Key.left => player_x -= speed
}
}
}
}
Assez classique hormis les syntaxes de Zig encore une fois.
Et pour finir, il va falloir apporter quelques changements à notre fonction principale :
export fn retro_run() void {
// On fait des actions en fonction des entrées utilisateurs
process_inputs();
draw_rectangle(0, 0, video_width, video_height, black);
// On utilise maintenant notre position
draw_rectangle(player_x, player_y, 20, 20, white);
screen_to_frame_buffer();
video_cb.?(@ptrCast(*anyopaque, frame_buffer.ptr), video_width, video_height, pitch);
}
Et voilà, c’est tout. On re-compile via zig build
et on lance. Vous devriez avoir un carré blanc qui se déplace en fonction de vos appuis sur les flèches de votre clavier. Attention vous aurez remarqué qu’on n’a pas mis de garde-fou, si vous essayez de bouger en dehors de l’écran, vous aurez le droit à un beau crash parce que vous tentez d’accéder à une zone mémoire qui n’est pas à vous.
Vous avez maintenant tout ce dont vous avez besoin pour implémenter votre propre Core (bon il y a encore beaucoup de fonctionnalités à explorer, comme le son), à partir de maintenant votre imagination sera votre seule limite !
Vous pouvez retrouver une version un peu plus travaillée du code source ici. Et pour les plus curieux, les équivalents de la première partie et de la deuxième partie en D.