Zig NEWS

jack
jack

Posted on

Hot-Reloading in zig

Recently, I've implemented hot-reloading for my little game framework jok.

I didn't consider hot-reloading for a long time, cause my framework is deadly simple, and I don't want to complicate it. However, after watched Billy Basso's wonderul interview, I think maybe it's not that hard at all. Let's do it, in zig of course.

First of all, in order to make some stuff capable of hot-reloading, you have to come up with some kind of standard api for your game objects or plugins, whatever you call it (let's use term plugin for convenience). Here's what I defined:

const InitFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const DeinitFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const EventFn = *const fn (ctx: *const jok.Context, e: *const jok.Event) callconv(.C) void;
const UpdateFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const DrawFn = *const fn (ctx: *const jok.Context) callconv(.C) void;
const GetMemoryFn = *const fn () callconv(.C) ?*const anyopaque;
const ReloadMemoryFn = *const fn (mem: ?*const anyopaque) callconv(.C) void;

pub const Plugin = struct {
    lib: std.DynLib,
    path: []const u8,
    last_modify_time: i128,
    init_fn: InitFn,
    deinit_fn: DeinitFn,
    event_fn: EventFn,
    update_fn: UpdateFn,
    draw_fn: DrawFn,
    get_mem_fn: GetMemoryFn,
    reload_fn: ReloadMemoryFn,
};
Enter fullscreen mode Exit fullscreen mode

There are 7 functions in C ABI. The InitFn/DeinitFn are for initializing and destroying plugins, only called once when plugins are registered and unregistered. The EventFn/UpdateFn/DrawFn are for receiving input events, updating internal state and do renderings in every frame. And there are GetMemoryFn/ReloadMemoryFn, called by framework when reloading plugins and restore plugins' internal states. Plugin struct stores handle to loaded library, path to library and function pointers of all exported api.

The parameter jok.Context is standard application context of my framework, which I normally wouldn't pass around using pointer, however, I kinda have to in this case, cause otherwise API won't be compatible to C ABI.

Let's implement the function for loading shared library.

fn loadLibrary(allocator: std.mem.Allocator, path: []const u8) !struct {
    lib: std.DynLib,
    init_fn: InitFn,
    deinit_fn: DeinitFn,
    event_fn: EventFn,
    update_fn: UpdateFn,
    draw_fn: DrawFn,
    get_mem_fn: GetMemoryFn,
    reload_fn: ReloadMemoryFn,
} {
    const lib_path = try std.fmt.allocPrint(allocator, "./jok.{s}", .{std.fs.path.basename(path)});
    defer allocator.free(lib_path);

    // Create temp library files
    std.fs.cwd().deleteFile(lib_path) catch |e| {
        if (e != error.FileNotFound) return e;
    };
    try std.fs.cwd().copyFile(path, std.fs.cwd(), lib_path, .{});

    // Load library and lookup api
    var lib = try DynLib.open(lib_path);
    const init_fn = lib.lookup(InitFn, "init").?;
    const deinit_fn = lib.lookup(DeinitFn, "deinit").?;
    const event_fn = lib.lookup(EventFn, "event").?;
    const update_fn = lib.lookup(UpdateFn, "update").?;
    const draw_fn = lib.lookup(DrawFn, "draw").?;
    const get_memory_fn = lib.lookup(GetMemoryFn, "get_memory").?;
    const reload_memory_fn = lib.lookup(ReloadMemoryFn, "reload_memory").?;

    return .{
        .lib = lib,
        .init_fn = init_fn,
        .deinit_fn = deinit_fn,
        .event_fn = event_fn,
        .update_fn = update_fn,
        .draw_fn = draw_fn,
        .get_memory_fn = get_memory_fn,
        .reload_memory_fn = reload_memory_fn,
    };

}
Enter fullscreen mode Exit fullscreen mode

Thanks to zig's standard library, we don't need to investigate os documentation to do library loading (it would be great learning experience though). std.DynLib is very easy to use, just open the library with path to it and lookup symbols right away. A little problem that need to be aware of is DLL file can't be written/update when it's being loaded on Windows, so I copied the original library and load the temporary file instead.

OK, time to do hot-reloading. My strategy is very simple: check library file's modify time every frame, reload it if it's different from previously remembered one. On my old Linux notebook, the strategy works fine. However, reloading might fail due to incomplete update of library if you got slow disk IO, and I also noted from documentation of std.fs.statFile that it takes 3 system calls in order to get mtime on Windows, something you guys might need to concern. Here's code:

var plugins = std.StringArrayHashMap(Plugin).init(allocator);

pub fn update(ctx: jok.Context) void {
    var it = self.plugins.iterator();
    while (it.next()) |kv| {
        kv.value_ptr.update_fn(&ctx);

        // Do hot-reload checking
        const stat = std.fs.cwd().statFile(kv.value_ptr.path) catch continue;
        if (stat.mtime != kv.value_ptr.last_modify_time) {
            const mem = kv.value_ptr.get_mem_fn();
            kv.value_ptr.lib.close();

            const loaded = loadLibrary(allocator, kv.value_ptr.path) catch |e| {
                log.err("Load library {s} failed: {}", .{ kv.value_ptr.path, e });
                continue;
            };
            loaded.reload_fn(mem);
            kv.value_ptr.lib = loaded.lib;
            kv.value_ptr.last_modify_time = stat.mtime;
            kv.value_ptr.init_fn = loaded.init_fn;
            kv.value_ptr.deinit_fn = loaded.deinit_fn;
            kv.value_ptr.event_fn = loaded.event_fn;
            kv.value_ptr.update_fn = loaded.update_fn;
            kv.value_ptr.draw_fn = loaded.draw_fn;
            kv.value_ptr.get_mem_fn = loaded.get_mem_fn;
            kv.value_ptr.reload_fn = loaded.reload_fn;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, I'm using std.StringArrayHashMap to maintain all loaded plugins, and update is called by framework in every frame. When modification is discovered, get_memory will be used to withdraw plugin's internal state before closing shared library, and reload_memory will be used to restore plugin's internal state.

This is a fairly simple solution for hot-reloading plugins. I'm sure there are better and more robust ways to implement the idea, but it's a good start!

BTW, here's a little example showcasing the above code if you guys are interested.
https://x.com/jichengde/status/1897996358155743707

Latest comments (0)