Article écrit par Hugo Fabre
La dernière fois, nous avons pu mettre en place un Core Libretro basique,
mais nous avons vu les principaux points à prendre en compte pour pouvoir itérer par-dessus.
Aujourd’hui, je vous propose d’aller encore plus loin : intégrer Mruby à notre Core pour pouvoir le scripter en Ruby.
Pour information, Mruby est une implémentation plus légère du standard ISO de Ruby.
Bien qu’il y ait d’autres usages, cette implémentation est principalement dédiée à être embarquée dans un autre programme pour lui ajouter des possibilités de script en Ruby.
Avant d’aller plus loin, voilà quelques liens qui vous seront utiles :
- Le premier article de la série. C’est une présentation de la Libretro et de ses concepts
- Le deuxième article de la série. Un guide pour implémenter un Core Libretro basique (en (Zig)[https://ziglang.org/])
- Le binding Mruby. Ce binding permet d’utiliser Mruby de manière plus idiomatique
- L’état du code après le dernier article et un peu de refactoristaion. Je me baserais sur cette version pour la suite de l’article.
Intégrer Mruby
On suit simplement le cette version du Readme qui va avec le binding.
Petite note, si vous utilisez la version de en cours de développement (10.0.0) de Zig :
Vous pouvez simplement vous référer à la version courrante du Readme.
Vous n’aurez pas non plus besoin de cloner Mruby comme un sous-module, le binding l’intégrant à votre place.
Par contre il faudra penser à cloner le binding avec ses sous-modules:
git clone --recurse-submodules git@github.com:dantecatalfamo/mruby-zig.git
Sinon :
Il faut d’abord récupérer le code source d’Mruby, pour ma part je l’ai rajouté comme un sous-module Git.
Si vous n’avez pas envie de rajouter cette complexité, libre à vous de simplement copier de votre répertoire de travail.
On copie ensuite les fichiers src/mruby.zig
et src/mruby_compat.c
.
Pour être clair, voilà mon arborescence après avoir fait tout ça :
.
├── build.zig
├── readme.md
└── src
├── color.zig
├── engine.zig
├── libretro
│ └── libretro.h
├── main.zig
└── mruby
├── mruby
├── * (Code source de Mruby)
├── mruby.zig
└── mruby_compat.c
Il faut ensuite rajouter les instructions de compilation dans notre fichier build.zig
.
Je vous invite à vous suivre les étapes ci-dessous :
// build.zig
// Vous pouvez simplement copier ça à la place de ligne 10 dans la précédente version du fichier
lib.addIncludeDir("src/libretro");
lib.addSystemIncludeDir("src/mruby/mruby/include");
lib.addLibPath("src/mruby/mruby/build/host/lib");
lib.addCSourceFile("src/mruby/mruby_compat.c", &.{});
lib.addPackagePath("mruby", "src/mruby/mruby.zig");
lib.linkSystemLibrary("mruby");
Et pour finir, on compile Mruby :
cd src/mruby/mruby
./minirake
Si vous avez bien suivi les indications, tout devrait compiler sans problème :
zig build
Mise en place de l’environnement
D’abord, on va définir ce que l’on cherche à obtenir. J’ai voulu rester sur quelque chose de simple
mais qui couvre selon moi une bonne partie de ce qu’on peut vouloir faire avec Mruby :
- Mettre à disposition de notre environnement une nouvelle classe
- Appeler une fonction définie dans notre moteur en Ruby
- Appeler une fonction définie en Ruby depuis notre moteur
- Préremplir l’environnement Mruby en Ruby directement
On va commencer par initialiser l’environnement Mruby :
// engine.zig
// On importe Mruby
const mruby = @import("mruby");
// La variable qui contiendra notre classe Mruby
var color_class: *mruby.RClass = undefined;
pub const Engine = struct {
pub const bpp = 4;
pub const speed = 1;
pub const width: u32 = 200;
pub const height: u32 = 150;
width: u32,
height: u32,
screen_size: u32,
pitch: u32,
framebuffer: []u8,
allocator: std.mem.Allocator,
// TODO: Extract this in a GameState
x: u32,
y: u32,
// On rajoute l'état de Mruby dans un nouveau membre de notre structure Engine
mrb: *mruby.mrb_state,
// On modifie la fonction d'initialisation :
pub fn init(allocator: std.mem.Allocator) !Engine {
var screen_size = width * height;
var framebuffer = try allocator.alloc(u8, screen_size * bpp);
// C'est ici que ça se passe
// On initialise l'environnement Mruby
var new_mrb = try mruby.open();
// On enregistre notre nouvelle classe
color_class = try new_mrb.define_class("Color", new_mrb.object_class());
// On enregistre le constructeur de notre classe
// Le premier paramètre sert à définir dans quel scope sera accessible notre fonction (ici la classe Color)
// Le second c'est le nom de la fonction en Ruby
// Le troisième c'est la fonction Zig à appeler
// Le dernier paramètre sert à préciser les arguments qu'on attend, ici 3 obligatoires
new_mrb.define_method(color_class, "initialize", mrb_initialize_color, .{ .req = 3 });
// On prérempli l'environnement mruby avec le contenu du fichier donné
// La fonction builtin @embedFile permet d'écrire le contenu voulu dans le fichier
// qui sera ensuite remplacé au moment de la compilation à cet endroit
// C'est pratique pour éviter de prendre trop de place à définir
// une chaine de caractère multilignes
_ = new_mrb.load_string(@embedFile("./mruby_ext.rb"));
// On défini une méthode, ici dans le module Kernel de Mruby, ce qui la rend accessible partout
new_mrb.define_module_function(new_mrb.kernel_module(), "draw_rect", mrb_draw_rect, .{ .req = 5 });
// Et enfin on charge notre jeu
_ = try new_mrb.load_file("./src/game.rb");
return Engine {
.allocator = allocator,
.width = width,
.height = height,
.screen_size = screen_size,
.pitch = bpp * width * @sizeOf(u8),
.framebuffer = framebuffer,
.x = 0,
.y = 0,
.mrb = new_mrb, // On oublie pas de stocker l'état de Mruby dans notre nouvelle instance
};
}
pub fn deinit(self: Engine) void {
// Et bien sur, on pense à fermer Mruby quand on quitte
self.mrb.close();
self.allocator.free(self.framebuffer);
}
};
Si vous êtes attentif, vous remarquerez qu’on demande à Mruby de faire appel à des fonctions que nous n’avons jamais définies.
C’est le moment de le faire, pour ma part, je les ai mises tout en bas du fichier engine.zig
:
// engine.zig
// Les fonctions que nous allons intégrer à l'environnement Mruby ont toujours le même prototype
// En premier l'état actuel de l'interpréteur
// En second, le contexte, ici l'instance de la classe
// Et on doit renvoyer un `mruby.mrb_value` ce qui représente n'importe quelle valeur. Ici notre instance.
pub export fn mrb_initialize_color(mrb: *mruby.mrb_state, self: mruby.mrb_value) mruby.mrb_value {
// L'interpreteur Mruby définis son propre type pour gérer les ints
var r: mruby.mrb_int = 0;
var g: mruby.mrb_int = 0;
var b: mruby.mrb_int = 0;
// Cette fonction sert à récupérer les arguments d'un appel de fonction
// On passe par là simplement parce qu'en Ruby il n'y a pas de limite
// au nombre d'arguments, ça peut être arguments nommés ou bien avec une valeur par défaut
// et tout ça de n'importe quel type. Forcément le traitement est un peu complexe et
// c'est cette fonction qui va le faire pour nous selon le format qu'on lui donne.
// Pour avoir plus d'info sur ce format : https://github.com/mruby/mruby/blob/HEAD/include/mruby.h#L909
_ = mrb.get_args("iii", .{ &r, &g, &b });
// On remplit des variables d'instances avec les valeurs récupérées en paramètre
// La fonction intern("") sert à récupérer l'équivalent d'une chaine de caractère en symbole
// La fonction int_value(1) sert à passer d'un mrb_int à un mrb_value (le type utilisé de partout)
mrb.iv_set(self, mrb.intern("@r"), mrb.int_value(r));
mrb.iv_set(self, mrb.intern("@g"), mrb.int_value(g));
mrb.iv_set(self, mrb.intern("@b"), mrb.int_value(b));
// Et on retourne notre instance
return self;
}
pub export fn mrb_draw_rect(mrb: *mruby.mrb_state, self: mruby.mrb_value) mruby.mrb_value {
var x: mruby.mrb_int = 0;
var y: mruby.mrb_int = 0;
var w: mruby.mrb_int = 0;
var h: mruby.mrb_int = 0;
var mrb_color: mruby.mrb_value = undefined;
_ = mrb.get_args("iiiio", .{ &x, &y, &w, &h, &mrb_color });
// Ici le unreachable est généralement utilisé en Zig
// Pour signaler que s'il y a une erreur à cet endroit, c'est une erreur
// du développeur qui a mal utilisé quelque chose et sortir une backtrace
const r = mrb.iv_get(mrb_color, mrb.intern("@r")).integer() catch unreachable;
const g = mrb.iv_get(mrb_color, mrb.intern("@g")).integer() catch unreachable;
const b = mrb.iv_get(mrb_color, mrb.intern("@b")).integer() catch unreachable;
// On rappelle que cette fonction sera appelée depuis Ruby, on recevra donc des mrb_int
// On convertit donc tout ça vers le type dont nous avons besoin
var c = clr.Color {
.r = @intCast(u8, r),
.g = @intCast(u8, g),
.b = @intCast(u8, b),
};
main.engine.draw_rectangle(@intCast(u32, x), @intCast(u32, y), @intCast(u32, w), @intCast(u32, h), c);
return self;
}
Il nous reste plus que trois petites choses à faire pour pouvoir tester.
En premier, écrire le fichier Ruby avec lequel on souhaite précharger des
choses dans notre environnement Mruby :
# mruby_ext.rb
BLACK = Color.new(0, 0, 0)
WHITE = Color.new(255, 255, 255)
GREY = Color.new(122, 122, 122)
RED = Color.new(255, 0, 0)
GREEN = Color.new(0, 255, 0)
BLUE = Color.new(0, 0, 255)
Ensuite il faut notre jeu, pour le moment on va faire très simple, juste pour vérifier que tout fonctionne :
# game.rb
def run
c = Color.new(122, 0, 255)
p c
draw_rect(20, 20, 10, 10, c)
end
Eh mais attends, c’est quoi cette fonction run ?
Bien vu, il nous manque un petit quelque chose, pour cela, on se retrouve
dans la fonction run()
de notre engine
pour la modifier :
// engine.zig
pub fn run(self: Engine) void {
// On dessine un fond noir
self.draw_rectangle(0, 0, self.width, self.height, clr.black);
// On appel la fonction run défini en Ruby
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "run", .{});
// Et on colle tout ça dans notre frame buffer
self.screen_to_frame_buffer();
}
Si vous essayez de compiler ici, vous aurez quelques erreurs, voici les étapes qu’il nous manque :
// color.zig
// On a voulu trop bien, mais comme de toute façon la Libretro ne gère pas la transparence,
// on lui met une valeur par défaut à 0 dans notre structure Color :
pub const Color = struct {
a: u8 = 0,
r: u8,
g: u8,
b: u8
};
Ensuite, de nos fonctions mruby on a parfois besoin de faire appel à notre moteur, pour dessiner notamment.
Pour ça on change la déclaration de notre variable engine
dans le fichier main.zig
pour la rendre publique :
// main.zig
// On rajoute simplement pub
pub var engine: ngn.Engine = undefined;
Et on importe notre fichier main.zig
// engine.zig
const main = @import("main.zig"):
On compile puis on lance et vous devriez avoir un carré rose qui s’affiche ainsi que des logs qui affichent notre variable c
.
Un peu d’interactivité
Et si on rajoutait un peu d’interactivité dans tout ça ?
Voilà le code du jeu auquel on voudrait arriver en Ruby :
# game.rb
class Game
SPEED = 1
def initialize
@colors = [RED, GREEN, BLUE, GREY, WHITE]
@color = WHITE
@pos_x = 0
@pos_y = 0
end
def move(direction)
case direction
when :up
@pos_y -= SPEED
when :down
@pos_y += SPEED
when :right
@pos_x += SPEED
when :left
@pos_x -= SPEED
end
end
def change_color
@color = @colors.sample
end
def tick
draw_rect(@pos_x, @pos_y, 10, 10, @color)
end
end
def run
@game ||= Game.new
@game.tick
end
def up_press
@game.move(:up)
end
def down_press
@game.move(:down)
end
def right_press
@game.move(:right)
end
def left_press
@game.move(:left)
end
def start_press
@game.change_color
end
Ensuite, il nous suffit de deux choses :
- Rajouter la gestion de la touche start (Entrée sur le clavier)
- Transmettre les appuis touche à notre script Ruby
Pour ça, retour dans notre fichier main.zig
:
// main.zig
// On rajoute la touche start à notre enum
const Key = enum {
up,
down,
left,
right,
start // Ici
};
// Puis on fait en sorte de transmettre l'information à notre engine
fn process_inputs() void {
var it = key_map.iterator();
while (it.next()) |kv| {
var pressed = input_state_cb.?(0, lr.RETRO_DEVICE_JOYPAD, 0, kv.key_ptr.*);
if (pressed != 0) {
switch (kv.value_ptr.*) {
Key.up => engine.up_press(),
Key.down => engine.down_press(),
Key.right => engine.right_press(),
Key.left => engine.left_press(),
Key.start => engine.start_press(), // Ici
}
}
}
}
// Et enfin on pense à signaler à la Libretro qu'on souhaite capter l'information
// lorsque la touche start est appuyée
export fn retro_init() void {
engine = ngn.Engine.init(allocator) catch unreachable;
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");
};
key_map.put(lr.RETRO_DEVICE_ID_JOYPAD_START, Key.start) catch {
handle_error("Could not allocate memory");
};
}
Et enfin dans notre moteur il faut changer le fonctionnement des fonctions
qui gère l’appui touche (et rajouter celle pour la touche start) :
// engine.zig
pub fn up_press(self: *Engine) void {
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "up_press", .{});
}
pub fn down_press(self: *Engine) void {
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "down_press", .{});
}
pub fn right_press(self: *Engine) void {
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "right_press", .{});
}
pub fn left_press(self: *Engine) void {
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "left_press", .{});
}
pub fn start_press(self: *Engine) void {
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "start_press", .{});
}
Vous pouvez aussi supprimer les membres x
et y
de notre structure Engine
qui sont maintenant gérés par le script Ruby.
On compile et on lance :
zig build
chemin/vers/retroarch -v -L chemin/vers/ce/dossier/lib/libzigretro-core.{dylib,so}
Et voilà, si tout a bien fonctionné, on peut de nouveau faire bouger le carré blanc et le faire changer de couleur de manière aléatoire en appuyant sur la touche entrée.
Un peu plus d’intégration avec la Libretro
Si vous vous souvenez bien, dans la partie précédente, nous avions signalé à la Libretro que nous ne souhaitions pas démarrer avec un jeu vu que celui-ci était intégré à notre core.
Eh bien maintenant que nous avons intégré Mruby je pense que nous aurions tout intérêt à la permettre, comme ça n’importe qui utilisant notre core pourrait le lancer avec le script (jeu) de son choix !
En premier lieu, il faut retourner dans notre fichier principal. On devra ensuite modifier la configuration pour lui dire qu’on attend désormais un jeu, cela se fait dans la fonction retro_set_environment
(tout ce qui est commenté, c’est ce qu’il faut supprimer) :
// main.zig
export fn retro_set_environment(cb: lr.retro_environment_t) void {
environ_cb = cb;
// Ici
// var allow_no_game = true;
if (cb.?(lr.RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &logging)) {
log_cb = logging.log;
}
// Et ici
// if (cb.?(lr.RETRO_ENVIRONMENT_SET_SUPPORT_NO_GAME, &allow_no_game)) {
// print("Unable to allow no game booting\n", .{});
// return;
// }
}
Ensuite on modifie la fonction retro_load_game
pour qu’elle charge notre jeu :
// main.zig
export fn retro_load_game(game_info: [*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;
}
// Ici. L'appel à @ptrCast permet de convertir la chaine de caractère C
// en slice Zig de manière à ne pas leaker le C dans notre moteur.
engine.load_game(@ptrCast([*:0]const u8, game_info.*.path)) catch {
handle_error("Failed to load game, make sure the path is correct");
};
return true;
}
Et enfin, on modifie notre moteur pour ne plus charger le jeu de lui même et pour lui rajouter une fonction permettant de lui en faire charger un à la demande
// engine.rb
pub const Engine = struct {
// ...
pub fn init(allocator: std.mem.Allocator) !Engine {
// ...
_ = try new_mrb.load_file("./src/game.rb"); // On supprime cette ligne
// ...
}
// Et on rajoute simplement une fonction pour charger un jeu
pub fn load_game(self: Engine, path: [*:0]const u8) !void {
_ = try self.mrb.load_file(path);
}
}
On compile et on lance (on note le changement dans la seconde ligne de commande pour donner le chemin vers le jeu)
zig build
/chemin/vers/RetroArch -v -L ./zig-out/lib/libzigretro-core.0.0.1.{dylib,so} ./src/game.rb
Et voilà
Vous pouvez retrouver le code source complet de l’article en suivant ce lien
Je pense que cet article va clore la série sur la Libretro, nous avons maintenant toutes les bases pour nous amuser avec même s’il y a encore plein de choses à voir. Je suis vraiment content d’avoir pu intégrer Mruby au projet, c’est la première fois que je l’utilise et j’ai vraiment aimé découvrir son fonctionnement. Le gros bémol pour moi, c’est que la documentation qui est quasiment inexistante, lorsque j’étais bloqué, j’ai quasiment tout le temps dû me tourner vers du code source (soit Mruby directement, soit des projets qui l’utilisent).
Même si c’est génial de mettre les mains dans le cambouis, sachez pour les plus frileux qu’il existe des projets du même genre (pas basé sur la Libretro) qui vous permettront de coder vos jeux en Mruby, je pense bien sûr à DragonRuby
au sujet duquel j’ai déjà écrit une introduction, mais aussi à son pendant libre, Taylor.