Skip to content

feat(backends): relocate windowless-SDL desktop gamepad source out of core into assembler (core#28) - #271

Merged
apotema merged 5 commits into
mainfrom
feat/sdl-desktop-gamepad-source
Jun 11, 2026
Merged

feat(backends): relocate windowless-SDL desktop gamepad source out of core into assembler (core#28)#271
apotema merged 5 commits into
mainfrom
feat/sdl-desktop-gamepad-source

Conversation

@apotema

@apotema apotema commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

What & why

SDL2 is a third-party C library and does not belong in labelle-core's dependency-free per-OS gamepad layer (core's other sources reach the OS HID stack directly: evdev/udev on Linux, JNI on Android, GameController on ios/tvos). This relocates the windowless-SDL desktop gamepad source OUT of labelle-core and INTO the assembler as a shared sub-package, wired into the raylib + sokol DESKTOP backends through the existing InputInterface(Impl) seam.

Precedent followed exactly: Android gamepad STATE already lives in the assembler at backends/sokol/src/android_gamepad_state.zig (NOT core) and is consumed through the Impl seam. This does the same for the desktop SDL source.

Pairs with the core revert labelle-core#30. Refs labelle-core#28.

What moved

  • New in-tree sub-package backends/sdl_gamepad/ (own build.zig + build.zig.zon, the toolkit's own-build.zig sub-package convention) holding the relocated desktop.zig logic: SDL externs gated behind comptime is_desktop, per-slot state table, hotplug event ring, button/axis snapshot with canonical raylib-compatible mapping, startup enumeration, spin-lock discipline, and host-testable pure mapping helpers. ONE copy, depended on by BOTH raylib and sokol via .path = "../sdl_gamepad".
  • It imports labelle-core under the labelle_core key for GamepadEvent/GamepadDescription/SourceClass/TypeHint; each consuming backend's build.zig unifies its own core onto the module so the event types match across the engine↔backend boundary.

raylib / sokol desktop wiring (Impl seam)

  • backends/raylib/src/input.zig: on a desktop target the four gamepad state methods + pollGamepadEvents/describeGamepads route to the shared SDL Source instead of rl.isGamepad*; off-desktop keeps raylib's GLFW path. New newFrame()/initGamepad()/deinitGamepad() drive the source. Gated behind comptime sdl_gp.is_desktop.
  • backends/sokol/src/input.zig: on desktop the state methods route to the shared source; on Android they keep android_gamepad_state (existing); ios/tvos keep the GameController bridge. Gated mirroring the existing agp.is_android style.
  • The engine synthesizes connect/disconnect by diffing isGamepadAvailable per frame (game.zig), so routing the state surface IS the complete hotplug integration — no new pollGamepadEvents contract needed on sokol.

SDL2 link gating

  • backends/raylib/build.zig and backends/sokol/build.zig link SDL2 (linkSystemLibrary("SDL2"), Homebrew /opt/homebrew/lib path on macOS, no @cImport/include) for DESKTOP targets ONLY. Android/iOS/wasm pull NO SDL.
  • The SDL render backend is intentionally untouched — it keeps its own SDL_PollEvent loop (two consumers on one queue would steal each other's events).

Pump location

  • sokol: backends/sokol/src/input.zigsnapshotGamepadButtons() calls Source.update() once per frame (reached via the sokol desktop template's existing @import("backend_input").newFrame()).
  • raylib: backends/raylib/templates/desktop.txt frame-loop top calls @import("backend_input").newFrame(); initGamepad()/deinitGamepad() wrap the loop.

Verification (compile-level; live input not testable headless on macOS — reads zero, expected)

  • zig build test green: sdl_gamepad, raylib (+ test-host links raylib & SDL2), sokol, and the top-level assembler. zig fmt --check clean.
  • Cross-compile gating: a gating-obj step emits the gamepad surface as an object (no sokol_clib/NDK needed). aarch64-linux-android0 undefined SDL_* symbols; wasm32 → none; macOS → 14 SDL_* externs (proves the probe defeats DCE and the comptime gate actually toggles). Android keeps android_gamepad_state.

For reviewers to scrutinize

  • Core unification for the GENERATED game build (deferred): each backend's build.zig unifies its own core onto the sdl_gamepad module for standalone backend builds (the CI cd backends/<b> && zig build test path). The full end-to-end generated-game build (which overrideImports core onto backend_input) and the examples/gamepad app are explicitly out of scope here per the task and deferred to a follow-up slice. Verified at the assembler/backend compile level only.
  • .path = "../sdl_gamepad" sibling dependency: standard in-tree sub-package convention, but it references a directory outside each backend's own package root — confirm that matches how the toolkit wants narrow shared deps wired (vs. a URL-pinned repo).

… core (core#28)

SDL2 is a third-party C library and does not belong in labelle-core's
dependency-free per-OS gamepad layer. This relocates the windowless-SDL
desktop source out of core (paired with the core revert, labelle-core#30)
and INTO the assembler as a shared sub-package, wired into the raylib and
sokol DESKTOP backends through the existing InputInterface(Impl) seam —
mirroring how Android gamepad STATE already lives at
backends/sokol/src/android_gamepad_state.zig (NOT core) and is consumed
through the same seam.

What moved and why:
- New in-tree sub-package backends/sdl_gamepad/ (own build.zig +
  build.zig.zon, toolkit own-build.zig sub-package convention) holding the
  relocated desktop.zig logic: SDL externs gated behind comptime is_desktop,
  the per-slot state table, hotplug via the event ring, button/axis snapshot
  with canonical raylib-compatible mapping, startup enumeration, spin-lock
  discipline, and host-testable pure mapping helpers. ONE copy, depended on
  by BOTH raylib and sokol via .path = "../sdl_gamepad".

Wiring (Impl seam):
- raylib backends/raylib/src/input.zig: on a desktop target the four gamepad
  state methods + pollGamepadEvents/describeGamepads route to the shared SDL
  source instead of rl.isGamepad*; off-desktop keeps raylib's GLFW path. New
  newFrame()/initGamepad()/deinitGamepad() drive the source.
- sokol backends/sokol/src/input.zig: on desktop the state methods route to
  the shared source; on Android they keep android_gamepad_state (existing);
  ios/tvos keep the GameController bridge. Gated like the existing is_android
  style. The pump lives in snapshotGamepadButtons (called from newFrame).
- The engine synthesizes connect/disconnect by diffing isGamepadAvailable per
  frame, so routing the state surface is the complete hotplug integration.

SDL link gating:
- raylib + sokol build.zig link SDL2 (Homebrew lib path on macOS;
  linkSystemLibrary("SDL2"); no @cImport/include) for DESKTOP targets ONLY.
  Android/iOS/wasm pull NO SDL. The SDL RENDER backend is intentionally
  untouched — it keeps its own SDL_PollEvent loop (two consumers on one queue
  would steal each other's events).

Pump location:
- sokol: backends/sokol/src/input.zig snapshotGamepadButtons (per-frame, via
  the sokol desktop template's existing @import("backend_input").newFrame()).
- raylib: backends/raylib/templates/desktop.txt frame-loop top calls
  @import("backend_input").newFrame(); init/deinit wrap the loop.

Verification (compile-level; live input not testable headless on macOS):
- zig build test green for sdl_gamepad, raylib (+ test-host links SDL2),
  sokol, and the top-level assembler.
- Cross-compile gating object proves the sokol/sdl_gamepad path pulls NO SDL
  on aarch64-linux-android (0 SDL_ symbols) and wasm, while macOS references
  14 SDL_ externs — and Android keeps android_gamepad_state.

Refs labelle-toolkit/labelle-core#28; pairs with labelle-core#30.
@cursor

cursor Bot commented Jun 11, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Touches desktop input/linking (new SDL2 dependency on raylib/sokol desktop) and generated-game module unification; non-desktop paths are gated but regressions could affect cross-compile or gamepad hotplug behavior.

Overview
Introduces a new in-tree backends/sdl_gamepad sub-package: windowless SDL2 gamecontroller/joystick (no video window) with raylib-compatible button/axis mapping, hotplug events, and comptime is_desktop gating so Android/iOS/wasm never reference or link SDL.

Raylib and sokol desktop input modules now delegate gamepad state, hotplug pollGamepadEvents/describeGamepads, and per-frame pumping to this shared source instead of GLFW/raylib or “no gamepad” defaults; Android (sokol) and iOS/tvOS paths are unchanged. Raylib adds initGamepad/deinitGamepad/newFrame and wires them in desktop.txt; sokol pumps via existing newFrameSource.update().

Build wiring: both backends depend on ../sdl_gamepad, link SDL2 (plus Homebrew lib path on native macOS) only for desktop targets, unify labelle-core onto the sub-module, and add gating-obj steps to verify non-desktop builds emit no undefined SDL_* symbols. deps_linker.zig stages sdl_gamepad for generated raylib/sokol projects; build_zig.txt overrideImports app core onto the transitive sdl_gamepad module to avoid duplicate GamepadEvent types. CI examples integration installs libsdl2-dev.

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

Comment thread backends/sdl_gamepad/src/sdl_gamepad.zig

@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 shared, windowless SDL2-backed desktop gamepad module (backends/sdl_gamepad) integrated into both the Raylib and Sokol backends to handle advanced controller hotplugging and state decoding on desktop platforms while keeping non-desktop builds free of SDL. The review feedback suggests declaring external C functions using extern "c" fn instead of extern fn for proper cross-platform resolution. Additionally, it recommends replacing the heavy std.Io.Threaded directory existence checks in the build files with a simpler, synchronous call to std.fs.accessAbsolute.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +327 to +344
extern fn SDL_InitSubSystem(flags: u32) c_int;
extern fn SDL_QuitSubSystem(flags: u32) void;
extern fn SDL_SetHint(name: [*:0]const u8, value: [*:0]const u8) c_int;
extern fn SDL_GameControllerUpdate() void;
extern fn SDL_PollEvent(event: *SDL_Event) c_int;
// Count of currently-attached joysticks; used to enumerate controllers
// already plugged in at startup (SDL does not emit CONTROLLERDEVICEADDED
// for those). Returns a negative value on error.
extern fn SDL_NumJoysticks() c_int;
extern fn SDL_IsGameController(joystick_index: c_int) c_int; // SDL_bool (SDL_TRUE == 1)
extern fn SDL_GameControllerOpen(joystick_index: c_int) ?*SDL_GameController;
extern fn SDL_GameControllerClose(gamecontroller: *SDL_GameController) void;
extern fn SDL_GameControllerName(gamecontroller: *SDL_GameController) ?[*:0]const u8;
extern fn SDL_GameControllerGetButton(gamecontroller: *SDL_GameController, button: c_int) u8;
extern fn SDL_GameControllerGetAxis(gamecontroller: *SDL_GameController, axis: c_int) i16;
// Instance id of an opened controller's underlying joystick.
extern fn SDL_GameControllerGetJoystick(gamecontroller: *SDL_GameController) ?*anyopaque;
extern fn SDL_JoystickInstanceID(joystick: *anyopaque) i32;

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

When declaring external C functions in Zig (with link_libc = true), use extern "c" fn instead of extern fn without a library name. The "c" is a logical reference to libc, not a literal filename, and is correctly resolved by the compiler across all platforms, including Windows MSVC.

    extern "c" fn SDL_InitSubSystem(flags: u32) c_int;
    extern "c" fn SDL_QuitSubSystem(flags: u32) void;
    extern "c" fn SDL_SetHint(name: [*:0]const u8, value: [*:0]const u8) c_int;
    extern "c" fn SDL_GameControllerUpdate() void;
    extern "c" fn SDL_PollEvent(event: *SDL_Event) c_int;
    // Count of currently-attached joysticks; used to enumerate controllers
    // already plugged in at startup (SDL does not emit CONTROLLERDEVICEADDED
    // for those). Returns a negative value on error.
    extern "c" fn SDL_NumJoysticks() c_int;
    extern "c" fn SDL_IsGameController(joystick_index: c_int) c_int; // SDL_bool (SDL_TRUE == 1)
    extern "c" fn SDL_GameControllerOpen(joystick_index: c_int) ?*SDL_GameController;
    extern "c" fn SDL_GameControllerClose(gamecontroller: *SDL_GameController) void;
    extern "c" fn SDL_GameControllerName(gamecontroller: *SDL_GameController) ?[*:0]const u8;
    extern "c" fn SDL_GameControllerGetButton(gamecontroller: *SDL_GameController, button: c_int) u8;
    extern "c" fn SDL_GameControllerGetAxis(gamecontroller: *SDL_GameController, axis: c_int) i16;
    // Instance id of an opened controller's underlying joystick.
    extern "c" fn SDL_GameControllerGetJoystick(gamecontroller: *SDL_GameController) ?*anyopaque;
    extern "c" fn SDL_JoystickInstanceID(joystick: *anyopaque) i32;
References
  1. When declaring external C functions in Zig (with link_libc = true), use extern "c" fn instead of extern fn without a library name.

Comment thread backends/raylib/build.zig
Comment on lines +27 to +31
var threaded = std.Io.Threaded.init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
std.Io.Dir.accessAbsolute(io, path, .{}) catch return false;
return true;

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

Using std.Io.Threaded to check if a directory exists is unnecessarily heavy and complex, as it spawns threads and allocates memory. In Zig 0.16.0, std.fs.accessAbsolute is a synchronous, standard function that does not require std.fs.cwd() or libc, making it a much simpler and more efficient choice for build-time checks.

    std.fs.accessAbsolute(path, .{}) catch return false;
    return true;

Comment thread backends/sokol/build.zig
Comment on lines +28 to +32
var threaded = std.Io.Threaded.init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
std.Io.Dir.accessAbsolute(io, path, .{}) catch return false;
return true;

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

Using std.Io.Threaded to check if a directory exists is unnecessarily heavy and complex, as it spawns threads and allocates memory. In Zig 0.16.0, std.fs.accessAbsolute is a synchronous, standard function that does not require std.fs.cwd() or libc, making it a much simpler and more efficient choice for build-time checks.

    std.fs.accessAbsolute(path, .{}) catch return false;
    return true;

Comment on lines +114 to +118
var threaded = std.Io.Threaded.init(std.heap.page_allocator, .{});
defer threaded.deinit();
const io = threaded.io();
std.Io.Dir.accessAbsolute(io, path, .{}) catch return false;
return true;

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

Using std.Io.Threaded to check if a directory exists is unnecessarily heavy and complex, as it spawns threads and allocates memory. In Zig 0.16.0, std.fs.accessAbsolute is a synchronous, standard function that does not require std.fs.cwd() or libc, making it a much simpler and more efficient choice for build-time checks.

    std.fs.accessAbsolute(path, .{}) catch return false;
    return true;

The raylib and sokol desktop backends declare the in-tree
backends/sdl_gamepad sub-package as a relative-path dependency
(.labelle_sdl_gamepad = .{ .path = "../sdl_gamepad" }). The assembler
stages each resolved backend verbatim into .labelle/deps/labelle-<backend>/
but never added the backend's transitive sdl_gamepad sub-package to the
DepEntry set, so the staged backend zon's ../sdl_gamepad pointed at a
.labelle/deps/sdl_gamepad directory that was never hardlinked -> generated
game builds failed with FileNotFound (unit/standalone backend builds pass
because they resolve the path in-tree).

Register sdl_gamepad as a deps entry (link_name exactly "sdl_gamepad" to
match the un-rewritten ../sdl_gamepad path) gated to the raylib/sokol
backends, resolved via resolveBundledPackage("backends/sdl_gamepad").

Reproduced examples/raylib generated-game build: FileNotFound before,
green after. Standalone raylib/sokol backend tests still pass and the
aarch64-linux-android SDL-gating object still has no undefined SDL_*
symbols.

@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 3 potential issues.

There are 4 total unresolved issues (including 1 from previous review).

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 90a0b71. Configure here.

// sokol desktop gamepad path.
if (comptime use_sdl_gamepad) {
sdl_gp.Source.update();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sokol pumps gamepads after tick

High Severity

On sokol desktop, sdl_gamepad.Source.update() runs from newFrame() at the end of frame(), after g.tick(). Gameplay reads isGamepad* during tick, so button state, edges, axes-vs-buttons coherence, and isGamepadAvailable hotplug diffs reflect the previous frame’s pump—not input since the last tick.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90a0b71. Configure here.

// sokol desktop gamepad path.
if (comptime use_sdl_gamepad) {
sdl_gp.Source.update();
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sokol never tears down SDL gamepads

Low Severity

The raylib desktop template pairs initGamepad() and deinitGamepad() around the main loop, but the sokol backend never exposes or calls sdl_gp.Source.deinit(). After the lazy SDL subsystem init from Source.update(), controllers and SDL joystick state may remain open until process exit on sokol desktop builds.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 90a0b71. Configure here.

Comment thread backends/raylib/src/input.zig
Source.describe skipped free slots, but the raylib backend documents a
fixed MAX_GAMEPADS-slot diagnostic snapshot (one row per slot with a
connected flag). Emit every slot 0..MAX-1 in order, connected=false +
empty name for free slots, matching the original describeGamepads
contract (Cursor review). Gemini's accessAbsolute and extern-"c"
findings were rejected: std.fs.accessAbsolute doesn't exist in Zig 0.16
(needs std.Io.Dir.accessAbsolute + an Io, which the build already uses),
and bare extern fn links fine for the SDL2 symbols.
@apotema

apotema commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Review triage (commit aa5f1a8)

Accepted & fixed:

  • Cursor — describe() omits empty slots: valid. Source.describe skipped free slots, but the raylib backend documents a fixed MAX_GAMEPADS-slot snapshot (one row per slot with a connected flag). Now emits every slot in order, connected=false + empty name for free slots.

Rejected (with reasons):

  • Gemini ×3 — replace std.Io.Threaded dir-check with std.fs.accessAbsolute(path, .{}): rejected — std.fs.accessAbsolute does not exist in Zig 0.16. The real API is std.Io.Dir.accessAbsolute(io, path, .{}), which requires an Io instance — which is exactly why the build uses an ad-hoc std.Io.Threaded. The suggested replacement would not compile.
  • Gemini — use extern "c" fn instead of extern fn for the SDL externs: declined as a non-blocking style nit. The build links the SDL2 symbols fine with bare extern fn on the targeted desktop platforms (macOS/Linux), and the "c = libc" rationale is imprecise (SDL isn't libc). Can revisit if/when a Windows-MSVC target is added.

(Build-time dirExists uses std.Io.Threaded deliberately for the 0.16 fs API; not a leak into runtime.)

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

Relocates the windowless SDL2-backed desktop gamepad implementation out of labelle-core and into labelle-assembler as a shared in-tree sub-package, then wires both the raylib and sokol desktop backends to source gamepad state/hotplug through that shared module (while keeping Android/iOS/tvOS paths intact and ensuring non-desktop targets pull no SDL symbols).

Changes:

  • Introduces backends/sdl_gamepad/ (module + tests) implementing a shared SDL2 desktop Source with canonical (raylib-compatible) mapping and hotplug event ring.
  • Routes desktop gamepad queries in backends/raylib and backends/sokol through the shared SDL source behind comptime gating.
  • Updates backend build scripts/zons to add the new sub-package dependency, unify core types onto it, and link SDL2 only for desktop targets; adds a sokol cross-compile gating probe step.

Reviewed changes

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

Show a summary per file
File Description
backends/sokol/src/input.zig Adds desktop SDL gamepad routing and per-frame pump via snapshotGamepadButtons().
backends/sokol/src/gamepad_gating_probe.zig Adds an object-only probe to validate SDL symbol gating under cross-compilation.
backends/sokol/build.zig.zon Adds labelle_sdl_gamepad path dependency and a pinned labelle_core for type unification.
backends/sokol/build.zig Imports/unifies sdl_gamepad, links SDL2 only on desktop, and adds a gating-obj build step.
backends/sdl_gamepad/src/sdl_gamepad.zig New shared SDL2-backed desktop gamepad source with mapping helpers and unit tests.
backends/sdl_gamepad/build.zig.zon Defines the new in-tree sub-package and its pinned labelle_core dependency.
backends/sdl_gamepad/build.zig Exposes the sdl_gamepad module, host tests, and a gating object step (no SDL link).
backends/raylib/templates/desktop.txt Pumps backend_input.newFrame() per frame and adds init/deinit calls for SDL gamepad source.
backends/raylib/src/input.zig Routes desktop gamepad state/hotplug through shared SDL source and adds init/newFrame/deinit.
backends/raylib/build.zig.zon Adds labelle_sdl_gamepad path dependency.
backends/raylib/build.zig Imports/unifies sdl_gamepad and links SDL2 only for desktop targets (including host tests).

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

Comment on lines 2 to 4
const std = @import("std");
const builtin = @import("builtin");
const rl = @import("raylib");
Comment on lines 307 to 309
/// Snapshot current gamepad button state so the next frame's
/// `isGamepadButtonPressed` can compute the rising edge. No-op off ios/tvos.
fn snapshotGamepadButtons() void {
apotema added 2 commits June 11, 2026 13:19
Generated raylib/sokol DESKTOP games now link -lSDL2 (the relocated
gamepad source), so the examples-integration runner needs SDL2 like the
build-and-test job already does. Staging fix (90a0b71) got the generated
build past 'deps/sdl_gamepad not found'; this gets it past the SDL2 link.
…271)

The generated raylib/sokol desktop build overrode the app's unified
labelle-core onto backend_input but NOT onto the transitive sdl_gamepad
sub-package that backend_input depends on. sdl_gamepad pins its own
labelle-core@v1.15.0 tarball and the generated game's zon does not
declare labelle_sdl_gamepad, so the module kept a distinct core instance.

When the app core differs from v1.15.0 (e.g. origin/main), input.zig and
sdl_gamepad carried two distinct GamepadEvent types and the []GamepadEvent
crossing the engine<->backend seam failed to type-check:
  expected type '[]gamepad.GamepadEvent', found '[]gamepad.GamepadEvent'

Reach the sdl_gamepad module through backend_input.import_table and
overrideImport the app core onto it under the 'labelle_core' (underscore)
key it declares. Guarded by the import_table lookup so it is a no-op on
non-desktop backends that do not wire sdl_gamepad in. The URL pin in
sdl_gamepad/build.zig.zon is kept so the sub-package still builds
standalone.
@apotema
apotema merged commit 19392a9 into main Jun 11, 2026
4 checks passed
@apotema
apotema deleted the feat/sdl-desktop-gamepad-source branch June 11, 2026 16:42
apotema added a commit that referenced this pull request Jul 1, 2026
* feat(sdl): add backend.manifest.zon — manifest-splice codegen (#386)

sdl extraction step (window conformed in #437). Opts the sdl DESKTOP build into
the manifest-splice path. Loop-style, desktop-only, NO params; EMPTY link beyond
the single blank the enum `.link_sdl` emitted (SDL2 is linked by the backend's
own build.zig). The backend_dep fragment carries the input core-diamond override
(underscore `labelle_core` key, #258). Byte-identical to the enum path
(diffed a generated baseline → 0 diff). zig build test green.

* feat(manifest): emit pack/feature manifest sidecar (#442)

Extend the generated catalog into the RFC §7 pack/feature manifest,
written to <game>/.labelle/manifest.json alongside flow_catalog.json.

Two-tier, realm-structured + sliceable:
- index (always loaded): contracts (events/enums) + a realm map
  (game root + each plugin) with owns / depends_on / exposes
  (commands vs queries, derived from FlowNode void-ness) / recipes.
- per-realm detail: game realm gets full component field schemas +
  save policy + event payloads (light AST pass over components/ and
  events/); plugin realms surface event + flow-node names (payloads
  already live in flow_catalog.json).

Everything except recipes is derived from already-scanned data, so it
can't drift. Additive: a failure is logged, never fatal (same contract
as the flow catalog).

Deferred and called out in the schema: recipes (empty, share the
scaffold source — cli #271), event emitted_by/subscribed_by cross-refs
(empty arrays), and component visibility (needs pack.labelle).

Part of #651, implements #442.

* fix(manifest): address review findings on pack/feature manifest sidecar (#442)

- parseStructFile: deinit the parsed Ast (per-file leak); propagate OOM
  from extractSavePolicy instead of swallowing it via `catch null`.
- parseStructDir: propagate OutOfMemory; degrade non-fatal read failures
  and no-match files to a name-only StructDecl (file-stem Pascal name,
  empty fields) so the manifest still lists what the registries import.
- parseStructDir: emit only the file-stem Pascal decl per file, so helper
  containers (`const Options`/`pub const Clip`) no longer leak as phantom
  components/events.
- contracts.events: realm-qualify entries (`engine.tick`,
  `box2d.collision_begin`, `game.<Event>`) to match the toolchain's
  dotted qualified form and avoid cross-realm name collisions.
- Emit a dedicated `engine` realm (index + detail) when engine lifecycle
  events are discovered, so `engine.<event>` contract entries resolve to a
  realm that actually appears.
- Game-realm exposes: script-qualify FlowNode commands/queries
  (`flows.hit_counter.spawn`) via the scriptModuleLabel dotted form so two
  scripts exposing the same bare name stay distinct.
- root.zig: use std.log.warn (not std.debug.print) for the manifest
  sidecar emission-failed warning.
- Tests: name-only fallback + helper-container exclusion (parseStructDir);
  engine realm emission + realm-qualified contracts + script-qualified
  exposes (writeManifestJson).

Claude-Session: https://claude.ai/code/session_017pW3ifKf9wgxNg4viy6okw
apotema added a commit that referenced this pull request Jul 1, 2026
…y work

Substantive (codex):
- Auto-register a scaffolded pack in project.labelle .plugins as
  .{ .name, .repo = "@packs/<name>" } so generate() (which discovers packs
  by iterating cfg.plugins) picks it up; falls back to a printed next-step
  when project.labelle is absent/unparseable/already has it. A pack is no
  longer left dead.
- Stop scaffolding packs/<name>/scripts/ — scanPack only scans
  components/events/prefabs/hooks, so pack scripts are silently ignored
  today (follow-up: wire pack-script scanning, then re-add the dir).
- Feature scripts now land under scripts/running/ (the default
  ProjectConfig.states) instead of scripts/playing/, which ScriptScanner
  silently drops in a default project.
- Feature component lookup goes through game.ecs_backend.getComponent,
  consistent with the ecs_backend.view call.
- add feature preflights BOTH target files before writing either, so a
  partial pre-existing scaffold no longer leaves a half-written result.

Quality (CodeRabbit + Gemini):
- Unknown feature kind now prints the usage/valid-kinds hint.
- Feature dir-creation errors are reported + abort instead of swallowed.
- toTypeName prefixes '_' when the name starts with a digit.
- writeExclusiveData / preflightAbsent return errors instead of calling
  std.process.exit, and emit diagnostics to stderr (repo convention) so
  they're unit-testable; callers map the error to the exit code.

Tests: digit-prefix, unknown-kind path, preflight all-or-nothing,
returning write helper, and pack auto-registration (empty + populated
.plugins). Updated existing scaffold tests for scripts/running and the
dropped pack scripts/ dir.
apotema added a commit that referenced this pull request Jul 1, 2026
* feat(#271): add `add pack` / `add feature` scaffold subcommand

Scaffolds the two authoring units the Packs RFC (§7) defines:

  labelle-assembler add pack <name>
  labelle-assembler add feature <kind> <name>   (kind: need|role|status)

`add pack` creates packs/<name>/ with the convention subdirs
(components/ events/ scripts/ prefabs/ hooks/, each with a .gitkeep) and a
`pack.labelle` (`.name`, `.manifest_version = 1`, scalar
`.convention_dirs = .copy_and_scan`). Refuses an existing dir.

`add feature <kind> <name>` scaffolds a feature-unit in the game root: a
`components/<name>.zig` (Saveable component) plus a
`scripts/playing/xx_<name>.zig` stub. Per kind:
  need   — value in [0,1] + a decay script that flags threshold crossings
           and shows the standard need_threshold_crossed emit (TODO)
  role   — role marker + per-frame behavior stub
  status — transient status flag + overlay-driver stub
Templates are minimal, AST-check-clean, and double as the recipe source
the pack manifest (#442) will surface. Refuses to overwrite either file.

Follows the `init` split: the CLI parses `add ...` and forwards it to this
subcommand, which owns the templating. Bumps PROTOCOL_VERSION to 4.

Part of #651.

* fix(#271): address add-scaffold review — make scaffold output actually work

Substantive (codex):
- Auto-register a scaffolded pack in project.labelle .plugins as
  .{ .name, .repo = "@packs/<name>" } so generate() (which discovers packs
  by iterating cfg.plugins) picks it up; falls back to a printed next-step
  when project.labelle is absent/unparseable/already has it. A pack is no
  longer left dead.
- Stop scaffolding packs/<name>/scripts/ — scanPack only scans
  components/events/prefabs/hooks, so pack scripts are silently ignored
  today (follow-up: wire pack-script scanning, then re-add the dir).
- Feature scripts now land under scripts/running/ (the default
  ProjectConfig.states) instead of scripts/playing/, which ScriptScanner
  silently drops in a default project.
- Feature component lookup goes through game.ecs_backend.getComponent,
  consistent with the ecs_backend.view call.
- add feature preflights BOTH target files before writing either, so a
  partial pre-existing scaffold no longer leaves a half-written result.

Quality (CodeRabbit + Gemini):
- Unknown feature kind now prints the usage/valid-kinds hint.
- Feature dir-creation errors are reported + abort instead of swallowed.
- toTypeName prefixes '_' when the name starts with a digit.
- writeExclusiveData / preflightAbsent return errors instead of calling
  std.process.exit, and emit diagnostics to stderr (repo convention) so
  they're unit-testable; callers map the error to the exit code.

Tests: digit-prefix, unknown-kind path, preflight all-or-nothing,
returning write helper, and pack auto-registration (empty + populated
.plugins). Updated existing scaffold tests for scripts/running and the
dropped pack scripts/ dir.
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