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,
};
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,
};
}
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;
}
}
}
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
Top comments (0)