
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 :
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 :
const mruby = @import("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,
x: u32,
y: u32,
mrb: *mruby.mrb_state,
pub fn init(allocator: std.mem.Allocator) !Engine {
var screen_size = width * height;
var framebuffer = try allocator.alloc(u8, screen_size * bpp);
var new_mrb = try mruby.open();
color_class = try new_mrb.define_class("Color", new_mrb.object_class());
new_mrb.define_method(color_class, "initialize", mrb_initialize_color, .{ .req = 3 });
_ = new_mrb.load_string(@embedFile("./mruby_ext.rb"));
new_mrb.define_module_function(new_mrb.kernel_module(), "draw_rect", mrb_draw_rect, .{ .req = 5 });
_ = 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,
};
}
pub fn deinit(self: Engine) void {
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
:
pub export fn mrb_initialize_color(mrb: *mruby.mrb_state, self: mruby.mrb_value) mruby.mrb_value {
var r: mruby.mrb_int = 0;
var g: mruby.mrb_int = 0;
var b: mruby.mrb_int = 0;
_ = mrb.get_args("iii", .{ &r, &g, &b });
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));
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 });
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;
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 :
pub fn run(self: Engine) void {
self.draw_rectangle(0, 0, self.width, self.height, clr.black);
_ = self.mrb.funcall(self.mrb.kernel_module().value(), "run", .{});
self.screen_to_frame_buffer();
}
Si vous essayez de compiler ici, vous aurez quelques erreurs, voici les étapes qu’il nous manque :
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 :
pub var engine: ngn.Engine = undefined;
Et on importe notre fichier main.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
:
const Key = enum {
up,
down,
left,
right,
start
};
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(),
}
}
}
}
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) :
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) :
export fn retro_set_environment(cb: lr.retro_environment_t) void {
environ_cb = cb;
if (cb.?(lr.RETRO_ENVIRONMENT_GET_LOG_INTERFACE, &logging)) {
log_cb = logging.log;
}
}
Ensuite on modifie la fonction retro_load_game
pour qu’elle charge notre jeu :
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;
}
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
pub const Engine = struct {
pub fn init(allocator: std.mem.Allocator) !Engine {
_ = try new_mrb.load_file("./src/game.rb");
}
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.
À lire aussi

La hiérarchie de l’information

Authentifiez les services derrière vos ingress avec OAuth2 Proxy

Ouidou x Atlassian
