Skip to content

feat(plugins): Controller discovery + ship_from_plugin + two-block scripts - #73

Merged
apotema merged 9 commits into
mainfrom
feat/plugin-controllers-support
Apr 18, 2026
Merged

feat(plugins): Controller discovery + ship_from_plugin + two-block scripts#73
apotema merged 9 commits into
mainfrom
feat/plugin-controllers-support

Conversation

@apotema

@apotema apotema commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Step 1 of the Plugin Controllers RFC (flying-platform-labelle#208). Three additive, backward-compatible changes to labelle-assembler:

  • Controller discovery & auto-wiring. Generated main now includes a PluginControllers scaffold that discovers pub const Controller = struct { setup, deinit, ... } in each plugin root via comptime @hasDecl. setup fires after scene load; deinit fires on unload. Plugins without a Controller export contribute nothing (no runtime cost).
  • ship_from_plugin ConventionDirMode. New variant of ConventionDirMode. Copies <plugin>/<name>/** into the generated build tree (reverse of today's copy_and_scan, which flows <game>/<name>/ → target). Enables plugins to ship their own scanned directories.
  • Two-block script ordering. ScriptScanner now emits the game's scripts first (unchanged), then plugin-shipped scripts per-plugin in project.labelle .plugins declaration order. Each plugin lives in its own numeric-prefix namespace — 05_foo.zig in two different plugins is fine; 05_foo.zig twice in the same plugin is a build error. Game-vs-plugin and plugin-vs-plugin prefix collisions are impossible by construction.

The three changes land in two commits:

  1. feat(controllers): discover plugin Controller exports and auto-wire setup/deinit
  2. feat(plugins): ship_from_plugin mode + two-block script ordering

Every change is opt-in. Existing plugins (labelle-fsm, labelle-pathfinding today) continue to work unchanged — no Controller export, no plugin scripts/ directory, no ship_from_plugin declarations.

Refs Flying-Platform/flying-platform-labelle#210
Refs Flying-Platform/flying-platform-labelle#208

Test plan

  • zig build test passes locally.
  • Snapshot tests cover Controller-exporting plugin wiring (PLUGIN_CONTROLLERS in test/tests.zig).
  • Non-Controller plugins keep working (backward-compat snapshot test).
  • ship_from_plugin mode parses and validates end-to-end (plugin_manifest.zig tests).
  • copyAndScanAbs copies + scans files and preserves subdirectory structure (script_scanner_tests.zig).
  • Two-block script ordering puts game scripts before plugin scripts; .plugins declaration order drives inter-plugin order.
  • Per-plugin namespace duplicate-prefix validator: same prefix across plugins is OK, within one plugin errors.
  • scanDir ignores .plugin_<name>/ subdirs so plugin entries register through scanPluginDir only.
  • Downstream games (flying-platform-labelle, bakery-game) re-generate cleanly against this assembler — to be validated in step 2 PR.

apotema and others added 2 commits April 18, 2026 13:31
…etup/deinit

Plugins declaring `pub const Controller = struct { setup, deinit, ... }`
in their root module now get those lifecycle hooks invoked from the
generated game's scene setup / teardown path. Discovery is a comptime
`@hasDecl` scan of each plugin root, so plugins that don't opt in
emit no code and continue to work unchanged (backward-compatible).

Backends:
 - Loop lifecycle (raylib/sdl/bgfx/wgpu): `try PluginControllers.setup`
   + `defer PluginControllers.deinit` alongside `PluginSystems`.
 - Callback lifecycle (sokol, wasm): `setup` in init with `catch @panic`,
   `deinit` emitted in cleanup in reverse of setup so controllers tear
   down before the plugin systems they depend on.

Refs Flying-Platform/flying-platform-labelle#208
Refs Flying-Platform/flying-platform-labelle#210

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the third `ConventionDirMode.ship_from_plugin` variant so a plugin
can declare a directory whose source lives in the plugin's own package
rather than the consuming game's tree. Same "copy and scan" shape as
`copy_and_scan`, just with the plugin as the origin.

Separately — and hand in hand with the new mode — extends the script
scanner to emit two ordered blocks: the game's `scripts/` first (existing
behavior, unchanged), then plugin-shipped scripts per-plugin in
`project.labelle` `.plugins` declaration order. Each plugin's scripts
live in their own numeric-prefix namespace, so:
 - `05_foo.zig` in two different plugins does NOT collide (by design)
 - `05_foo.zig` twice in the same plugin IS a build error (unchanged rule)
 - game-vs-plugin prefix collisions are impossible by construction

The assembler copies plugin scripts into
`<target>/scripts/.plugin_<name>/…` so game + plugin trees share a single
`scripts/` root without filename collisions. `scanDir` ignores the
`.plugin_` subtree so plugin entries register through `scanPluginDir` only.

Generator-side: plugin scripts end up in `AllScripts` with `rel_path`
prefixed by `.plugin_<name>/`, and `pathToIdent` now maps `.` → `_` so
the generated Zig identifiers stay valid.

Backward-compatible: plugins that don't ship a `scripts/` directory (every
current plugin) register zero plugin-block entries and the generated
tick is unchanged. The `ship_from_plugin` mode is opt-in; existing
manifests stay valid.

Refs Flying-Platform/flying-platform-labelle#208
Refs Flying-Platform/flying-platform-labelle#210

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Apr 18, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches code generation and script discovery/ordering for plugin-enabled projects, so regressions could break runtime behavior or generated code imports. Changes are additive/guarded but affect core generation paths and CI now runs a new raylib binary check.

Overview
Adds plugin Controller lifecycle wiring to generated main.zig via a new PluginControllers dispatcher that @hasDecl-discovers Controller.setup/deinit per plugin and invokes them during scene load/unload (with loop vs callback backend differences).

Extends plugin manifests with ConventionDirMode.ship_from_plugin, allowing convention directories to be copied/scanned from a plugin’s cached package into the generated target (with validation that scan modes must specify an extension).

Introduces two-block script ordering: plugin-shipped scripts/ trees are copied into scripts/.plugin_<name>/…, scanned as per-plugin namespaces, and sorted after game scripts (ordered by .plugins declaration order); updates identifier sanitization to handle leading . paths. Adds extensive tests, a new examples/plugin-controllers fixture, and CI steps that build and headlessly run it under xvfb, asserting canonical log order.

Reviewed by Cursor Bugbot for commit 6a3b4e2. Bugbot is set up for automated code reviews on this repo. Configure here.

Comment thread src/root.zig
Comment thread src/root.zig Outdated

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a plugin controller system that allows plugins to hook into the game's lifecycle via setup and deinit methods. It also adds a new convention mode, ship_from_plugin, which enables plugins to provide their own scripts and assets that are automatically integrated into the generated build. Key changes include the generation of a PluginControllers dispatcher in main.zig, updates to the script scanner to support plugin-specific namespaces and ordering, and the addition of absolute path scanning utilities. Review feedback suggests refactoring repetitive code generation logic into a helper function, ensuring consistent error handling for plugin resolution, and simplifying complex conditional logic within the script scanner's validation process.

Comment thread src/main_zig.zig
Comment on lines +150 to +169
try bw.writeAll(" /// Call Controller.setup(game) on every plugin that declares one.\n");
try bw.writeAll(" /// Plugins whose root module does not export a `Controller` are silently skipped.\n");
try bw.writeAll(" pub fn setup(game: anytype) !void {\n");
try bw.writeAll(" inline for (_plugin_mods) |mod| {\n");
try bw.writeAll(" if (@hasDecl(mod, \"Controller\")) {\n");
try bw.writeAll(" const C = @field(mod, \"Controller\");\n");
try bw.writeAll(" if (@hasDecl(C, \"setup\")) try C.setup(game);\n");
try bw.writeAll(" }\n");
try bw.writeAll(" }\n");
try bw.writeAll(" }\n\n");
try bw.writeAll(" /// Call Controller.deinit(game) on every plugin that declares one.\n");
try bw.writeAll(" /// Mirrors setup(). Skips plugins without a Controller export or without a deinit.\n");
try bw.writeAll(" pub fn deinit(game: anytype) void {\n");
try bw.writeAll(" inline for (_plugin_mods) |mod| {\n");
try bw.writeAll(" if (@hasDecl(mod, \"Controller\")) {\n");
try bw.writeAll(" const C = @field(mod, \"Controller\");\n");
try bw.writeAll(" if (@hasDecl(C, \"deinit\")) C.deinit(game);\n");
try bw.writeAll(" }\n");
try bw.writeAll(" }\n");
try bw.writeAll(" }\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The code generation for the setup and deinit functions is very repetitive. You could extract the common logic into a helper function to make writePluginControllersBlock more concise and easier to maintain, especially if more lifecycle methods are added in the future.

For example, you could introduce a helper like writeLifecycleFn:

fn writeLifecycleFn(bw: anytype, comptime name: []const u8, comptime ret: []const u8, comptime try_kw: []const u8) !void {
    try bw.print("    /// Call Controller.{s}(game) on every plugin that declares one.\n", .{name});
    // ... other comments ...
    try bw.print("    pub fn {s}(game: anytype) {s} {{\n", .{name, ret});
    try bw.writeAll("        inline for (_plugin_mods) |mod| {\n");
    try bw.writeAll("            if (@hasDecl(mod, \"Controller\")) {\n");
    try bw.writeAll("                const C = @field(mod, \"Controller\");\n");
    try bw.print("                if (@hasDecl(C, \"{s}\")) {s} C.{s}(game);\n", .{name, try_kw, name});
    try bw.writeAll("            }\n");
    try bw.writeAll("        }\n");
    try bw.writeAll("    }\n\n");
}

// Then in writePluginControllersBlock:
try writeLifecycleFn(bw, "setup", "!void", "try");
try writeLifecycleFn(bw, "deinit", "void", "");

Comment thread src/root.zig Outdated
// Plugins without a `scripts/` dir contribute nothing — backward-compat
// with every existing plugin (labelle-fsm, labelle-pathfinding today).
for (cfg.plugins) |plugin| {
const plugin_src_dir = cache.resolvePlugin(allocator, plugin, game_dir) catch continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The error handling for cache.resolvePlugin is inconsistent. Here, you use catch continue, which silently skips if a plugin can't be resolved. However, in the plugin manifest loading loop (lines 202-203), plugin_manifest.loadOptional calls resolvePlugin with try, which would fail the entire generation process.

For consistency and robustness, it would be better to use try here as well. If a plugin is declared in project.labelle, it should probably be an error if it cannot be found.

        const plugin_src_dir = try cache.resolvePlugin(allocator, plugin, game_dir);

Comment thread src/script_scanner.zig
Comment on lines +354 to 369
const same_plugin = blk: {
if (a.plugin_name == null and b.plugin_name == null) break :blk true;
if (a.plugin_name) |a_p| {
if (b.plugin_name) |b_p| break :blk std.mem.eql(u8, a_p, b_p);
}
break :blk false;
};
if (!same_plugin) continue;

const same_subdir = blk: {
if (a.subdir == null and b.subdir == null) break :blk true;
if (a.subdir) |a_sub| {
if (b.subdir) |b_sub| {
break :blk std.mem.eql(u8, a_sub, b_sub);
}
if (b.subdir) |b_sub| break :blk std.mem.eql(u8, a_sub, b_sub);
}
break :blk false;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic to determine same_plugin and same_subdir using labeled blocks is a bit complex and can be hard to read. You can simplify this logic using if and else with optional unwrapping, which would improve readability.

                const same_plugin = if (a.plugin_name) |a_p| (b.plugin_name) |b_p| and std.mem.eql(u8, a_p, b_p) else b.plugin_name == null;
                if (!same_plugin) continue;

                const same_subdir = if (a.subdir) |a_sub| (b.subdir) |b_sub| and std.mem.eql(u8, a_sub, b_sub) else b.subdir == null;

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements step 1 of the Plugin Controllers RFC for labelle-assembler: adds generated PluginControllers discovery/wiring, introduces a ship_from_plugin convention-dir mode, and extends script scanning to support a second “plugin scripts” block ordered by .plugins declaration order.

Changes:

  • Generate a PluginControllers scaffold in main.zig and wire setup/deinit into both loop and callback lifecycles.
  • Add plugin-shipped scripts support: copy plugin scripts/ into scripts/.plugin_<name>/ and scan them as a separate ordered block.
  • Add ship_from_plugin convention-dir mode and supporting copyAndScanAbs helper; expand tests for the new behaviors.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/main_zig.zig Emits PluginControllers scaffold, wires lifecycle calls, and updates identifier sanitization for .plugin_* script paths.
src/root.zig Adds ship_from_plugin handling and copies/scans plugin scripts/ into .plugin_<name> namespaces.
src/script_scanner.zig Tracks plugin provenance per script entry and enforces two-block ordering + per-plugin duplicate-prefix validation.
src/scanner.zig Adds copyAndScanAbs to support copying/scanning between arbitrary absolute paths.
src/plugin_manifest.zig Adds ConventionDirMode.ship_from_plugin plus parsing/validation tests (extension required).
test/tests.zig Adds snapshot-style assertions for generated PluginControllers output and plugin-script import identifiers.
test/script_scanner_tests.zig Adds tests for two-block script ordering, per-plugin duplicate-prefix scoping, and .plugin_* subtree ignore behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/root.zig Outdated

// Probe for existence — plugins without a `scripts/` dir are the
// norm and must not error.
_ = cwd.openDir(plugin_scripts_src, .{}) catch continue;

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existence probe opens a directory handle and immediately discards it (_ = cwd.openDir(...)) without closing. This can leak file descriptors when multiple plugins are processed. Store the opened Dir in a variable and defer close() (or use openDir just-in-time inside the copy step and handle error.FileNotFound).

Suggested change
_ = cwd.openDir(plugin_scripts_src, .{}) catch continue;
var plugin_scripts_dir = cwd.openDir(plugin_scripts_src, .{}) catch continue;
defer plugin_scripts_dir.close();

Copilot uses AI. Check for mistakes.
Comment thread src/script_scanner.zig Outdated
Comment on lines +165 to +169
/// then loop over plugins calling `scanPluginDir`. After all calls,
/// either call `finalize()` or rely on `scanDir`'s end-of-call
/// sort+validate — but note that the final sort must see every entry,
/// so plugins added *after* `scanDir` returned will need `finalize()`
/// (or a re-sort and re-validate).

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doc comment references a finalize() API, but ScriptScanner doesn’t define finalize() (only sortEntries() + validateNoDuplicateOrders()). Either add the method or update the comment to describe the actual required call sequence (e.g. caller must explicitly sort/validate after all scans, or scanPluginDir always sorts/validates internally).

Suggested change
/// then loop over plugins calling `scanPluginDir`. After all calls,
/// either call `finalize()` or rely on `scanDir`'s end-of-call
/// sort+validate — but note that the final sort must see every entry,
/// so plugins added *after* `scanDir` returned will need `finalize()`
/// (or a re-sort and re-validate).
/// then loop over plugins calling `scanPluginDir`. `scanDir()` sorts
/// and validates at the end of its own call, so if any plugins are
/// scanned afterward, the caller must explicitly call `sortEntries()`
/// and then `validateNoDuplicateOrders()` after all scans complete so
/// the final ordering and duplicate-prefix checks see every entry.

Copilot uses AI. Check for mistakes.
Comment thread src/root.zig
Comment on lines +339 to +343
// Feed the plugin's scripts into the scanner as a new block,
// isolated under the plugin's namespace so the duplicate-prefix
// validator treats it independently of the game block.
try script_scan.scanPluginDir(plugin_scripts_dst, plugin.name);
}

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

script_entries is captured via script_scan.getEntries() before this plugin-scripts loop runs (see earlier const script_entries = script_scan.getEntries();). Since getEntries() returns a slice with a fixed length, any later scanPluginDir calls will not be reflected in script_entries, so plugin-shipped scripts won’t be emitted into generated main.zig. Fix by deferring the getEntries() call until after all scanPluginDir calls (or passing script_scan.getEntries() directly into generateMainZigFromTemplate).

Copilot uses AI. Check for mistakes.
apotema added 5 commits April 18, 2026 13:49
…, doc

Three review items from the first round:

- **HIGH (cursor + copilot)** — `script_entries` captured via
  `script_scan.getEntries()` BEFORE the plugin-shipped-scripts loop
  ran. Each `scanPluginDir` call appended more entries and could
  reallocate the ArrayList backing buffer, leaving `script_entries`
  as a dangling pointer. Even if no realloc, `.len` reflected only
  the game's block — plugin scripts never reached the codegen. Fix:
  defer the `getEntries()` capture until just before
  `generateMainZigFromTemplate`, after every plugin has been
  scanned. Matches the doc contract on `scanPluginDir` and makes
  the end-to-end pathfinder migration (#211) actually work.

- **MEDIUM (cursor + copilot)** — the plugin-scripts existence
  probe at `root.zig` opened a `Dir` handle via `cwd.openDir` and
  discarded it with `_ =`, leaking one file descriptor per plugin
  that ships a `scripts/` directory. Fix: store the handle and
  close it immediately (we only needed the existence probe, not a
  live handle — the actual copy reopens via `copyAndScanAbs`).

- **MEDIUM (gemini)** — `cache.resolvePlugin` used `catch continue`
  in the plugin-scripts loop while the manifest-load loop above
  uses `try`. The plugin was already successfully resolved during
  manifest-load, so a second-resolution failure is a cache
  corruption, not a plugin configuration error — `try` is the
  correct contract. Made consistent.

Also cleaned up a stale `finalize()` reference in `scanPluginDir`'s
doc comment (copilot). `scanPluginDir` re-sorts + re-validates
internally, so the external `finalize()` step doesn't exist.

Skipped two style suggestions (gemini's code-gen helper and a
labeled-block simplification) — those are real but can land in a
follow-up if taste agrees.

Tests: `zig build test` green (60 pass across the three suites).

Refs Flying-Platform/flying-platform-labelle#210
Refs Flying-Platform/flying-platform-labelle#208
…anifest

Adds the self-contained fixture plugin used by the plugin-controllers
E2E example. Ships:

  - plugin.labelle: manifest_version = 1, declaring a `demo_playbooks/`
    convention with `mode = .ship_from_plugin` to exercise the new mode
    on a real `labelle generate` run. `scripts/` is reserved so the
    plugin's per-frame script is shipped via the auto-discovery path.
  - src/root.zig: minimal `pub const Controller = struct { setup, deinit }`
    export. Both lifecycle hooks emit a tagged `game.log.info` line the
    CI log-order assertion can key on.
  - scripts/playing/01_plugin_tick.zig: state-bound per-frame script.
    Lands under `<target>/scripts/.plugin_demo_plugin/playing/` after
    generate — runs in the plugin's own block-2 namespace.
  - demo_playbooks/README.zig: no-op smoke-test for the ship_from_plugin
    copy pass.
  - build.zig + build.zig.zon: exposes a `labelle_demo_plugin` module so
    the generated build.zig can do
    `plugin_demo_plugin_dep.module("labelle_demo_plugin")`.

Standalone commit — this plugin contributes nothing to the assembler
tests yet; the matching game project and CI test arrive in follow-up
commits. Existing PR #73 feature commits untouched.
…ugin

Adds the game-side of the plugin-controllers E2E example:

  - project.labelle: raylib + mock ECS, hidden window, single `playing`
    state. Declares the fixture plugin via `local:./plugin`. No atlases,
    no GUI, no custom layers — the smallest project.labelle that still
    exercises the full setup → tick → defer lifecycle.
  - scenes/main.jsonc: empty scene (no entities, no assets) so
    `g.setScene("main")` has a name to bind against.
  - scripts/playing/01_game_tick.zig: state-bound per-frame script that
    logs `[game] game-tick frame=N`. Runs in block-1 (game) before the
    plugin's block-2 script each tick — matching `ScriptScanner`'s
    game-before-plugin ordering contract (see `PluginBlockOrdering`
    tests in `test/script_scanner_tests.zig`).
  - README.md: explains the expected log sequence and why
    `[demo-plugin] deinit` is not part of the runtime assertion (raylib's
    main loop guards on `windowShouldClose`, which can't flip in a
    hidden-window CI run without crashing the subsequent draw calls;
    the `defer PluginControllers.deinit(&g)` wiring is covered by
    snapshot tests instead).
  - .gitignore: the generated `.labelle/` tree.

Generate locally:
    cd examples/plugin-controllers
    labelle generate
    cd .labelle/raylib_desktop && zig build && ./zig-out/bin/game

Verified end-to-end on macOS: setup + 5 interleaved game/plugin tick
pairs emit in the canonical order documented in README.md. The CI
runtime assertion arrives in the follow-up commit.
Adds two steps to the `examples-integration` job:

  1. `Generate + build the plugin-controllers example` — mirrors the
     existing raylib / asset-streaming-smoke recipe. Shakes out
     "generated code doesn't compile" bugs one consumer-repo update
     before the pathfinder migration (flying-platform-labelle#211)
     depends on PR #73 landing.

  2. `Runtime log-order check for plugin-controllers` — actually runs
     the generated binary under xvfb-run + `timeout 3`, captures
     stderr, and asserts the canonical `setup` → interleaved
     `game-tick` / `plugin-tick` sequence via `diff -u`.

Uses the existing `labelle generate` + `LABELLE_ASSEMBLER` env-var
pattern (not a bespoke shell-out to `labelle-assembler` directly) so
plugin resolution, cache population, and fingerprint fixups all take
the same code path as a real consumer.

xvfb dependency: raylib creates its OpenGL context at init even with
`.hidden = true`. On headless Linux runners the context needs a
display server; `xvfb-run` provides the minimal X server. The existing
build-only steps don't need xvfb because they stop before running the
binary. Tolerates `timeout` exit code 124 since raylib's main loop
polls `windowShouldClose()` which can't flip on a hidden window — the
example caps its log output at 5 frames, so extra silent iterations
between the last logged line and the timeout kill don't affect the
asserted pattern.

The `[demo-plugin] deinit` line is NOT asserted at runtime for the
reason documented in `examples/plugin-controllers/README.md` — it's
covered by the snapshot tests in `test/tests.zig::PLUGIN_CONTROLLERS`
which verify the generated `main.zig` emits
`defer PluginControllers.deinit(&g)` in the right scope.
…E_QUIT

Both `01_game_tick.zig` and `01_plugin_tick.zig` now early-return once
`state.frame >= FRAMES_BEFORE_QUIT` (= 5). Without the cap, raylib's
main loop keeps polling — `game.quit()` flips the engine's internal
`running` flag but `windowShouldClose()` doesn't honour it for a hidden
window — and both scripts emit ~150 extra log lines before the CI
`timeout 3` kills the process.

That extra output doesn't change the asserted ordering *pattern* but
makes the expected-vs-actual `diff -u` non-deterministic (exact frame
count depends on CI runner scheduling) and the captured `run.log` noisy
when a test does fail.

Discovered by running the log-order check locally. Caught before CI
would have flaked on the first PR update.
@apotema

apotema commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

Implementation notes for reviewers

A couple of deliberate trade-offs in the `examples/plugin-controllers/` addition that are worth surfacing:

1. Runtime assertion covers `setup` + `tick`, NOT `deinit`

The CI test (`xvfb-run timeout 3 ./game` + `diff -u` against a canonical 11-line log) asserts:

```
[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` is not part of the runtime assertion because:

  • raylib's main-loop guard `windowShouldClose()` can't flip on a hidden window, so the loop doesn't exit naturally.
  • Calling `rl.closeWindow()` from inside a tick crashes the same iteration's draw calls.
  • The binary is terminated via `timeout 3`, which SIGTERMs before the `defer PluginControllers.deinit(&g)` can run.

The `deinit` wiring itself is covered by the snapshot tests in `test/tests.zig::PLUGIN_CONTROLLERS` — we assert the generated `main.zig` contains the correct `defer` call in both loop (raylib/sdl) and callback (sokol) lifecycle paths. So the codegen is verified; just not the runtime exit path.

Follow-up opportunity: a sokol-backend variant of the example would let CI exercise the deinit runtime path too (sokol's main-loop ownership makes graceful shutdown tractable). Out of scope for this PR; a single-backend example demonstrates the pattern. Tracking at Flying-Platform/flying-platform-labelle#210's follow-up list.

2. New CI dependency: `xvfb` on Linux runner

The `examples-integration` job now needs `xvfb` to run the raylib binary headlessly on ubuntu-latest. The dependency is declared inline in the workflow step but worth calling out here so it's not a stealth infrastructure cost if this project ends up running on non-default runners (self-hosted, ARM, etc.). macOS doesn't need it — the agent verified the whole flow on macOS without xvfb.

If CI flakiness emerges around the xvfb step, a fallback is to run the binary under `LABELLE_HEADLESS=1` (not wired yet) and have the generated main skip raylib's window init entirely — that's a bigger change than this PR warrants.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e92c48e. Configure here.

Comment thread .github/workflows/ci.yml
# on sokol-style ports) and 124 (timeout kill).
set +e
xvfb-run -a -s "-screen 0 320x240x24" \
timeout 3 ./zig-out/bin/game > run.log 2>&1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI references wrong binary name for runtime check

High Severity

The CI runtime log-order check runs ./zig-out/bin/game but the project is named plugin_controllers_demo in project.labelle. The README's local-run instructions correctly reference ./zig-out/bin/plugin_controllers_demo. The generated build.zig names the executable after the project's .name field, so the binary will be plugin_controllers_demo, not game. This will cause the CI step to fail with a "file not found" error every time.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e92c48e. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 20 out of 20 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread examples/plugin-controllers/README.md Outdated
labelle generate
cd .labelle/raylib_desktop
zig build
timeout 3 ./zig-out/bin/plugin_controllers_demo 2>&1 | head -40

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The run command uses ./zig-out/bin/plugin_controllers_demo, but the generated build template hardcodes the executable name as game (build_zig.txt uses .name = "game"). Update the example's local run instructions (and any mentions of the binary name) to match the actual output path so the docs work as written.

Suggested change
timeout 3 ./zig-out/bin/plugin_controllers_demo 2>&1 | head -40
timeout 3 ./zig-out/bin/game 2>&1 | head -40

Copilot uses AI. Check for mistakes.
Comment thread src/root.zig Outdated
Comment on lines +303 to +307
// Scanning is driven by the `ScriptScanner` (see addPluginBlock below),
// so the duplicate-prefix validator treats each plugin block as
// independent. Cross-plugin prefix collisions are impossible by
// construction. Game-vs-plugin collisions are also impossible — the
// game scripts live under `scripts/` while plugin scripts live under

Copilot AI Apr 18, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment references addPluginBlock below, but there is no addPluginBlock in this file (or nearby). Consider updating the wording to point at the actual implementation (ScriptScanner.scanPluginDir / the plugin-script loop here) to avoid misleading future readers.

Suggested change
// Scanning is driven by the `ScriptScanner` (see addPluginBlock below),
// so the duplicate-prefix validator treats each plugin block as
// independent. Cross-plugin prefix collisions are impossible by
// construction. Game-vs-plugin collisions are also impossible — the
// game scripts live under `scripts/` while plugin scripts live under
// Scanning is driven by the `ScriptScanner` via the plugin-script loop
// here and the `script_scan.scanPluginDir(...)` call below, so the
// duplicate-prefix validator treats each plugin block as independent.
// Cross-plugin prefix collisions are impossible by construction.
// Game-vs-plugin collisions are also impossible — the game scripts
// live under `scripts/` while plugin scripts live under

Copilot uses AI. Check for mistakes.
…ild_zig.txt template

Bugbot flagged a mismatch on PR #73: README says `./zig-out/bin/plugin_controllers_demo`
but CI runs `./zig-out/bin/game`. The CI is correct — the build.zig
template hardcodes `.name = "game"` for the executable regardless
of the project's `.name` field in project.labelle. README was lying
about local-run behavior. Fixed.

(The template-hardcodes-'game' design is arguably a separate issue
— a project.labelle should probably drive the binary name — but
that's orthogonal to this PR.)
@apotema

apotema commented Apr 18, 2026

Copy link
Copy Markdown
Contributor Author

@gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces plugin controller machinery, allowing plugins to export lifecycle hooks and ship their own scripts via a new ship_from_plugin manifest mode. The implementation includes a comptime dispatcher and updates to the script scanner for plugin-namespaced scripts. Review feedback identifies several improvement opportunities, including the use of errdefer to prevent memory leaks during allocation failures in ScriptScanner, the propagation of directory iteration errors, and the removal of a redundant directory probe.

Comment thread src/root.zig Outdated
Comment on lines +328 to +329
var plugin_scripts_dir = cwd.openDir(plugin_scripts_src, .{}) catch continue;
plugin_scripts_dir.close();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This directory probe is redundant. Both scanner.copyAndScanAbs (called on line 341) and script_scan.scanPluginDir (called on line 352) already handle the case where the source directory does not exist by silently returning or skipping. Removing this avoids unnecessary system calls.

Comment thread src/script_scanner.zig
Comment on lines +175 to +176
const name_dup = try self.allocator.dupe(u8, plugin_name);
try self.shared_plugin_names.append(self.allocator, name_dup);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If shared_plugin_names.append fails with OutOfMemory, the name_dup allocation is leaked. Using errdefer ensures the memory is freed if the append operation fails.

        const name_dup = try self.allocator.dupe(u8, plugin_name);
        errdefer self.allocator.free(name_dup);
        try self.shared_plugin_names.append(self.allocator, name_dup);

Comment thread src/script_scanner.zig
defer self.allocator.free(rel_prefix);

var iter = dir.iterate();
while (iter.next() catch return) |entry| {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Iteration errors from iter.next() are silently swallowed, which can lead to incomplete script scanning being reported as a success. It is better to propagate the error. This would require adding std.fs.Dir.IterateError to the ScanError error set.

Comment thread src/script_scanner.zig
Comment on lines +200 to +202
const name_copy = try self.allocator.dupe(u8, entry.name);
const rel_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ rel_prefix, entry.name });
try self.addEntryWithPath(name_copy, null, &.{}, rel_path);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These allocations are leaked if subsequent operations fail. For example, if allocPrint fails, name_copy is leaked. If addEntryWithPath fails, both name_copy and rel_path are leaked. errdefer should be used to manage these temporary ownerships until they are successfully handed off to the scanner's internal state.

                const name_copy = try self.allocator.dupe(u8, entry.name);
                errdefer self.allocator.free(name_copy);
                const rel_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ rel_prefix, entry.name });
                errdefer self.allocator.free(rel_path);
                try self.addEntryWithPath(name_copy, null, &.{}, rel_path);

Comment thread src/script_scanner.zig
Comment on lines +206 to +213
const dir_states = try self.parseDirStates(entry.name);
if (dir_states.len == 0) continue;

const subdir_path = try std.fmt.allocPrint(self.allocator, "{s}/{s}", .{ plugin_scripts_dir, entry.name });
defer self.allocator.free(subdir_path);
const subdir_name = try self.allocator.dupe(u8, entry.name);
try self.shared_subdirs.append(self.allocator, subdir_name);
try self.shared_states.append(self.allocator, dir_states);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Multiple allocations in this block are leaked if subsequent operations fail before ownership is transferred to shared_subdirs or shared_states. Specifically, dir_states is leaked if subdir_path, subdir_name, or the appends fail. subdir_name is leaked if its append fails. errdefer blocks should be used to ensure proper cleanup on failure.

…update stale doc

Per gemini + copilot review on PR #73:

- Removed the explicit directory-existence probe
  (`cwd.openDir(plugin_scripts_src, .{}) catch continue`). Both
  downstream calls already handle missing source dirs silently:
  `scanner.copyAndScanAbs` via `copyAndScanRecursive` (scanner.zig:181)
  and `script_scan.scanPluginDir` (script_scanner.zig:173). The probe
  was an extra syscall + fd open/close for no semantic gain.

- Fixed the stale `see addPluginBlock below` doc reference — the
  function doesn't exist; we call `scanPluginDir` directly.

No behavior change in the generated output. `zig build test`
still green across all suites.

Skipping the four OOM-errdefer comments (script_scanner.zig
~176/198/202/213) — they're correct but low-impact (leak paths
only hit on OOM, which Zig treats as process-fatal anyway) and
#198 would require adding error variants to `ScanError`. Better
addressed as a systematic errdefer audit in a separate PR; filing
a follow-up ticket.
@apotema
apotema merged commit 7e14965 into main Apr 18, 2026
4 checks passed
@apotema
apotema deleted the feat/plugin-controllers-support branch April 18, 2026 17:33
apotema added a commit that referenced this pull request Apr 18, 2026
Plugin-exported Controllers (#73).
apotema added a commit that referenced this pull request Apr 18, 2026
Adds a `null` backend variant — pure-Zig no-op implementations of every
gfx/input/audio/window symbol the engine expects, plus a desktop template
whose generated `main()` runs the tick loop for a bounded number of frames
(LABELLE_NULL_FRAMES env var, default 5) at a fixed 1/60s timestep, then
exits cleanly so `defer`-bound teardown actually runs.

Wires `.null` as a valid value for `.backend` in `project.labelle`:
  - `src/config.zig`: extend the `Backend` enum
  - `src/build_files.zig`: route `.null` through new build/zon sections
  - `src/gui_resolve.zig`: GUI plugins are incompatible with `.null`
  - `src/templates/{build_zig,build_zig_zon}.txt`: backend_null + dep_null_path
  - `backends/null/`: build.zig, build.zig.zon, src/{gfx,input,audio,window}.zig,
    templates/desktop.txt, plus self-contained unit tests

Use case: lifecycle / determinism / integration tests that don't exercise
rendering — closes the runtime `deinit` coverage gap from PR #73 (raylib's
hidden-window loop can't exit cleanly, so `defer PluginControllers.deinit`
was only codegen-tested) and removes the xvfb dependency for headless CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema added a commit that referenced this pull request Apr 18, 2026
…74)

Migrates the plugin-controllers E2E example off raylib (with hidden window
+ xvfb on Linux CI + timeout-kill exit path) onto the new `.null` backend
(headless tick loop, exits cleanly after `LABELLE_NULL_FRAMES` frames).

Concrete changes:
- project.labelle: `.backend = .null`, drop `.hidden`
- scripts/playing/01_game_tick.zig + plugin scripts: drop the
  `game.quit()` workaround and the raylib-specific lifecycle prose; the
  null backend's frame counter terminates the loop on its own
- .github/workflows/ci.yml: drop the `xvfb-run` wrapper, drop `timeout 3`,
  drop the apt-get xvfb install. The runtime check is now a plain
  `./game` invocation whose exit code must be 0
- Extend the canonical log-order assertion with the trailing
  `[demo-plugin] deinit` line — reachable now that the loop exits cleanly
  and `defer PluginControllers.deinit(&g)` actually runs (the runtime
  coverage gap PR #73 had to leave to snapshot tests)
- .gitignore: the assembler writes plugin-shipped scripts into
  `scripts/.plugin_<name>/` (PR #71 symlink design); ignore them

Because labelle-cli's bundled generator module is pinned to v0.8.0 and
doesn't know about `.null`, the CI step invokes the assembler binary
directly and replicates labelle-cli's `fixFingerprint` post-pass inline.
Routes back through `labelle generate` once labelle-cli bumps past this
PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema added a commit that referenced this pull request Apr 18, 2026
* feat(backends): add null backend for headless lifecycle/CI tests (#74)

Adds a `null` backend variant — pure-Zig no-op implementations of every
gfx/input/audio/window symbol the engine expects, plus a desktop template
whose generated `main()` runs the tick loop for a bounded number of frames
(LABELLE_NULL_FRAMES env var, default 5) at a fixed 1/60s timestep, then
exits cleanly so `defer`-bound teardown actually runs.

Wires `.null` as a valid value for `.backend` in `project.labelle`:
  - `src/config.zig`: extend the `Backend` enum
  - `src/build_files.zig`: route `.null` through new build/zon sections
  - `src/gui_resolve.zig`: GUI plugins are incompatible with `.null`
  - `src/templates/{build_zig,build_zig_zon}.txt`: backend_null + dep_null_path
  - `backends/null/`: build.zig, build.zig.zon, src/{gfx,input,audio,window}.zig,
    templates/desktop.txt, plus self-contained unit tests

Use case: lifecycle / determinism / integration tests that don't exercise
rendering — closes the runtime `deinit` coverage gap from PR #73 (raylib's
hidden-window loop can't exit cleanly, so `defer PluginControllers.deinit`
was only codegen-tested) and removes the xvfb dependency for headless CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(examples/plugin-controllers): switch to null backend, drop xvfb (#74)

Migrates the plugin-controllers E2E example off raylib (with hidden window
+ xvfb on Linux CI + timeout-kill exit path) onto the new `.null` backend
(headless tick loop, exits cleanly after `LABELLE_NULL_FRAMES` frames).

Concrete changes:
- project.labelle: `.backend = .null`, drop `.hidden`
- scripts/playing/01_game_tick.zig + plugin scripts: drop the
  `game.quit()` workaround and the raylib-specific lifecycle prose; the
  null backend's frame counter terminates the loop on its own
- .github/workflows/ci.yml: drop the `xvfb-run` wrapper, drop `timeout 3`,
  drop the apt-get xvfb install. The runtime check is now a plain
  `./game` invocation whose exit code must be 0
- Extend the canonical log-order assertion with the trailing
  `[demo-plugin] deinit` line — reachable now that the loop exits cleanly
  and `defer PluginControllers.deinit(&g)` actually runs (the runtime
  coverage gap PR #73 had to leave to snapshot tests)
- .gitignore: the assembler writes plugin-shipped scripts into
  `scripts/.plugin_<name>/` (PR #71 symlink design); ignore them

Because labelle-cli's bundled generator module is pinned to v0.8.0 and
doesn't know about `.null`, the CI step invokes the assembler binary
directly and replicates labelle-cli's `fixFingerprint` post-pass inline.
Routes back through `labelle generate` once labelle-cli bumps past this
PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(null-backend): document tick loop, env var control, replacement of xvfb (#74)

- README.md: list `null` in the --backend table, link to the example and
  the backend implementation
- examples/plugin-controllers/README.md: rewrite the "what it
  demonstrates" + "how this works" sections around the null-backend
  tick loop, document `LABELLE_NULL_FRAMES`, explain why this replaces
  the previous xvfb / `timeout 3` dance, and update the local-run
  recipe (currently bypasses labelle-cli because its bundled generator
  is pinned to an older release)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(null-backend/getMaxFrames): clarify error paths don't leak per gemini review on PR #76

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema added a commit that referenced this pull request May 22, 2026
Systematic audit of alloc-then-hand-off patterns where an OOM in a
later step of the same function would leak an earlier allocation.

script_scanner.zig
- scanDir / scanPluginDir / scanZigFilesRecursive: a per-file
  `dupe`'d name (and `allocPrint`'d rel_path) leaked if the
  `addEntryWithPath` append OOM'd. Added `errdefer` on each.
- scanDir / scanPluginDir state-dir branch: `subdir_name` (dupe) and
  `dir_states` (owned slice from parseDirStates) leaked if the
  shared-list append OOM'd. Reworked to reserve list capacity before
  the dupe so the appends are infallible; an explicit `transferred`
  flag scopes the errdefers to the pre-handoff window.
- scanPluginDir: `name_dup` leaked if shared_plugin_names append
  OOM'd (gemini PR #73 finding) — reserve-then-appendAssumeCapacity.
- parseDirStates: a mid-loop dupe/append OOM leaked already-duped
  state strings + the list buffer. Added an errdefer that frees both.
- getEntriesForState: the `result` ArrayList leaked on append OOM.
  Added `errdefer result.deinit`.
- iter.next() errors were swallowed by `catch return`, silently
  truncating the script list. Now propagated; ScanError folds in
  std.Io.Dir.Iterator.Error.

main_zig.zig
- generateMainZigFromTemplate: each emitted block was `toOwnedSlice`'d
  then `allocs.append`'d; an OOM in that append leaked the block.
  Reserve allocs capacity up front and use appendAssumeCapacity for
  all 18 sites, closing the window.

root.zig audited — no genuine leaks (rgba_path_allocs already has a
matching errdefer; loaded_manifests already reserves capacity).

Tests: added two checkAllAllocationFailures-based regression tests
exercising every OOM point in scanDir and scanPluginDir.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema added a commit that referenced this pull request Jul 10, 2026
…gins P3, #577) (#588)

* feat(assets): validate studio panel.jsonc at generate time (Asset Plugins P3, #577)

Validate every `studio/*.panel.jsonc` a plugin (or a pack it bundles) ships,
at `labelle generate` time, alongside the Phase-1/2 asset + pack validation —
so the studio never renders an invalid panel from a generated project. A
malformed panel is a build error with a file (and, for parse errors, line)
location.

The reference is the studio POC's zod schema (labelle-studio
src/services/pluginPanels.ts, #73); src/panel_validate.zig is a faithful Zig
port: strict top-level keys (id/title/icon/fields/actions), id identifier +
non-empty title, per-type field branches (number/slider/select/text/toggle)
with strict keys, and the semantic pass (duplicate names, min>max, default
out of range, select default not in options). All problems for one file are
collected and rendered as `<file>: <where>: <problem>` (the studio's rules
sidecar shape).

- src/panel_validate.zig (new): parser (JSONC strip + std.json) + schema +
  semantic validation; a bounded `studio/`-gated directory walk that discovers
  panels at a plugin root and inside its nested packs; validatePluginPanels
  drives it over cfg.plugins.
- src/root.zig: call validatePluginPanels in generate, before any target is
  written; test aggregation + pub re-export.
- Tests: 18 schema/semantic accept+reject cases plus 2 filesystem-discovery
  tests (studio-gating + no-panel no-op).

Additive: a project with no panels is unaffected; an unresolvable plugin dir
is skipped (other passes report a genuinely missing plugin).

Deferred (pairs with engine#729): a `"target":"preview"` command naming a
plugin-declared handler can't be cross-checked here yet — the engine declares
handlers by runtime subscription to engine__editor_plugin_command, so no
static handler-name list exists at generate time. The schema/semantic gate is
the bulk of the acceptance.

`zig build test` passes (the pre-existing flow_catalog sidecar failure is
unrelated and present on origin/main).

Closes #577

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw

* fix(assets): scope panel validation to declared packs only (#577 review)

The Phase-3 panel validator walked EVERY directory below a plugin root, so a
`studio/*.panel.jsonc` in an UNDECLARED/example pack (e.g.
`packs/experimental/studio/`, an `examples/` tree) was validated and could
fail `labelle generate` even though that pack isn't shipped — nested packs are
only part of generation when declared in `plugin.labelle`'s `.packs` list
(codex review on #588).

Replace the blanket recursive `walk` with a scoped discovery that mirrors the
pack-discovery path (`generate_phases.discoverNestedPacks`): validate ONLY
  (a) the plugin-root `studio/` dir, and
  (b) the `studio/` dir of each pack the plugin DECLARES in `.packs`.
`collectPluginPanelErrors` loads the plugin manifest (`plugin_manifest.loadOptional`)
and scans exactly those units via a new non-recursive `scanStudioDir`; the
undeclared/example descendants are never touched.

Tests: swap the recursive-walk tests for `scanStudioDir` unit tests and add a
declared-vs-undeclared scope test — a DECLARED pack's broken panel fails, an
UNDECLARED pack's broken panel does NOT. `zig build test` passes (the
pre-existing flow_catalog sidecar failure is unrelated).

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants