Minimal end-to-end example for the plugin-Controller machinery introduced in PR #73 (RFC flying-platform-labelle#208).
-
Controllerdiscovery —plugin/src/root.zigexportspub const Controller = struct { setup, deinit, … }. The assembler'swritePluginControllersBlock(seesrc/main_zig.zig) scans every plugin module at comptime via@hasDecl(mod, "Controller")and emits aPluginControllersdispatcher thatmain()calls on scene load (setup) and scene unload (deinitviadefer). -
Plugin-shipped scripts —
plugin/scripts/playing/01_plugin_tick.zigis copied bygenerate()into<target>/scripts/.plugin_demo_plugin/playing/…and registered as a plugin-namespaced script block. It runs after the game's own scripts each tick, producing the interleaved log output the CI test asserts. -
ship_from_pluginconvention mode —plugin/plugin.labelledeclares ademo_playbooks/directory with.mode = .ship_from_plugin. The assembler's plugin-manifest loop copies that directory out of the plugin's cached package into the generated build target. The example itself doesn't use the copied content; the entry exists so the new convention mode is exercised by a reallabelle generaterun rather than only by unit tests. -
Null-backend lifecycle coverage — the example runs on the
.nullbackend (introduced in PR #74). The generatedmain()runs the engine's tick loop for a fixed number of frames (controlled byLABELLE_NULL_FRAMES, default 5) and then falls through to thedefer-bound teardown — meaningPluginControllers.deinit(&g)is observed at runtime, not just in the codegen-snapshot tests. Closes the runtime coverage gap PR #73 had to leave open because raylib's hidden-window loop can't exit cleanly.
examples/plugin-controllers/
├── project.labelle # .backend = .null, declares the fixture plugin
├── scenes/main.jsonc # empty scene, no entities
├── scripts/playing/01_game_tick.zig
├── README.md # this file
└── plugin/ # fixture plugin
├── build.zig
├── build.zig.zon
├── plugin.labelle # manifest_version = 1, demo_playbooks ship_from_plugin
├── demo_playbooks/README.zig # no-op smoke-test file for the ship_from_plugin copy pass
├── src/root.zig # Controller.setup / Controller.deinit
└── scripts/playing/01_plugin_tick.zig
The CI Runtime log-order check step diffs stderr against this
canonical sequence:
[demo-plugin] setup
[game] game-tick frame=1
[demo-plugin] plugin-tick frame=1
[game] game-tick frame=2
[demo-plugin] plugin-tick frame=2
…
[game] game-tick frame=5
[demo-plugin] plugin-tick frame=5
[demo-plugin] deinit
setup appears once, before any tick. Within each tick, the [game] …
line precedes the [demo-plugin] plugin-tick … line because block-1
(game) scripts run before block-2 (plugin) scripts — see
ScriptScanner.scanPluginDir and the PluginBlockOrdering tests in
test/script_scanner_tests.zig. The trailing [demo-plugin] deinit
line is the runtime proof that defer PluginControllers.deinit(&g) is
both wired and reached, which the prior raylib-based incarnation could
only assert at the codegen layer.
The .null backend (the out-of-tree labelle-null package) ships pure-Zig
no-op stubs for every gfx / input / audio / window symbol the engine expects, plus a
templates/headless.txt that emits a main() shaped like:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
var hooks = GameHooks{};
var g = AssembledGame.init(allocator);
defer g.deinit();
g.setHooks(&hooks);
// setup_code (loads scenes, calls runner.setup, calls
// PluginControllers.setup, registers `defer PluginControllers.deinit`)
…
const max_frames = getMaxFrames(allocator); // LABELLE_NULL_FRAMES, default 5
const dt: f32 = 1.0 / 60.0;
var frame: u32 = 0;
while (frame < max_frames) : (frame += 1) {
// tick_code (runner.tick + PluginSystems.tick blocks)
…
g.tick(dt);
}
}No window init, no GL context, no input poll, no windowShouldClose()
guard — the loop terminates on the frame counter and falls through to
the defer chain, which is what produces the trailing
[demo-plugin] deinit line.
That's it. Why this replaces the xvfb dance:
- Raylib's main loop polled
window.windowShouldClose(), which only flips on ESC / user-initiated close. A hidden-window CI run could never reach either, so the previous incarnation wrapped execution inxvfb-run … timeout 3 ./gameand accepted exit code 124 (SIGTERM). - The null backend has no window to poll, so the bounded
forloop is enough. CI runs./gamedirectly, expects exit code 0, and diffs the captured log against the fixed expected sequence.
cd examples/plugin-controllers
# labelle-cli's bundled generator is pinned to a release that predates
# the `.null` Backend variant — invoke the assembler binary directly
# until labelle-cli bumps its assembler dep past PR #74.
../../zig-out/bin/labelle-assembler generate --project-root .
cd .labelle/null_desktop
zig build
./zig-out/bin/game # default: 5 frames, exits cleanly
# Override the frame count for longer / shorter runs
LABELLE_NULL_FRAMES=20 ./zig-out/bin/gameThe first zig build will fail with an
invalid fingerprint: 0xBAD; … use this value: 0xGOOD diagnostic
because the assembler intentionally leaves the fingerprint at a
placeholder (labelle-cli normally patches it via a post-generate
runner.fixFingerprint pass). Substitute the value Zig prints into
build.zig.zon and rerun zig build.