From f2cc02265c24ec2239a707d00705b1674d93086b Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 11:46:02 -0300 Subject: [PATCH 1/3] feat!: SDL2 provisioning leaves the core for the sdl2 provider (cli#471 S4) labelle-sdl now ships the opt-in `sdl2` CLI provider: an env hook provisions SDL2 on Windows and writes LABELLE_SDL2_LIB, a stage hook copies the DLLs, and `labelle sdl2 doctor|install`. The core keeps nothing SDL-specific. - Deleted: src/cli/sdl_provision.zig, wants_sdl2 and the SDL env auto-wiring (preInstall no longer takes a backend), SDL2.dll staging, the doctor's SDL rows and its `--fix` provisioning, and LABELLE_SDL2_LIB in the unreserved-env list. - `labelle doctor` prints one advice line (never a failure) when the build links SDL2 and .plugins has no `sdl2`. `--fix` now points at `labelle doctor --fix`. Forwarding --fix to provider doctors (D10) is not wired yet, and doctor.zig says so. The agnostic guard drops cli/sdl_provision.zig. BREAKING CHANGE: the CLI no longer downloads SDL2 or stages SDL2.dll. Add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB, or use `.gamepad = .none`. --- src/agnostic_guard_test.zig | 9 +- src/cli.zig | 1 - src/cli/config.zig | 14 +- src/cli/doctor.zig | 322 ++++++++---------------------- src/cli/help.zig | 2 +- src/cli/pipeline.zig | 8 +- src/cli/pipeline/build.zig | 23 +-- src/cli/pipeline/install.zig | 28 +-- src/cli/sdl_provision.zig | 365 ----------------------------------- test/provider_doctor_e2e.py | 2 +- 10 files changed, 103 insertions(+), 671 deletions(-) delete mode 100644 src/cli/sdl_provision.zig diff --git a/src/agnostic_guard_test.zig b/src/agnostic_guard_test.zig index 82e9688f..7b5cb105 100644 --- a/src/agnostic_guard_test.zig +++ b/src/agnostic_guard_test.zig @@ -111,7 +111,10 @@ const allowed_words = [_][]const u8{ "macos", "windows", "linux", "darwin", "win /// the target dir, and `upgrade all` delegates the backend pin to /// `labelle-assembler upgrade backend`), so `cli/upgrade.zig` came clean, /// and `cli/pipeline/generate.zig` once its comments stopped citing -/// backend and platform names: 14 entries. +/// backend and platform names: 14 entries. RFC cli#471 S4 moved SDL2 +/// provisioning, runtime DLL staging and the doctor rows to the opt-in +/// `sdl2` provider (labelle-sdl), deleting `cli/sdl_provision.zig`: +/// 13 entries. /// Shrink only: an entry whose file is clean fails the test until it is /// removed. Note the path scan: an entry /// under `cli/android/` or named `cli/ios.zig` stays dirty until the file is @@ -134,7 +137,6 @@ const allowed_files = [_][]const u8{ "cli/pipeline/run.zig", "cli/project_config.zig", "cli/provider_dispatch.zig", - "cli/sdl_provision.zig", }; const finding_note = "(platform/store/package names belong in providers; see docs/rfc-package-commands.md#enforcement)"; @@ -600,13 +602,12 @@ test "a platform in the path is a finding, and keeps an allowlist entry dirty" { // An allowlisted file whose path names a platform stays dirty with // clean contents: the entry is only stale once the file is moved or // renamed. - try scan.file("cli/sdl_provision.zig", "const x = 1;\n"); + try scan.file("cli/ios.zig", "const x = 1;\n"); try scan.file("cli\\ios.zig", ""); try std.testing.expectEqual(@as(usize, 7), scan.offenders.items.len); var stale: std.ArrayList([]const u8) = .empty; defer stale.deinit(gpa); try scan.stale(&stale); - try std.testing.expect(!containsString(stale.items, "cli/sdl_provision.zig")); try std.testing.expect(!containsString(stale.items, "cli/ios.zig")); try std.testing.expect(containsString(stale.items, "cli/project_config.zig")); } diff --git a/src/cli.zig b/src/cli.zig index 40c72686..fc70777e 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -53,7 +53,6 @@ const provider_contract = @import("cli/provider_contract.zig"); const provider_github = @import("cli/provider_github.zig"); const provider_targets = @import("cli/provider_targets.zig"); const doctor = @import("cli/doctor.zig"); -const sdl_provision = @import("cli/sdl_provision.zig"); // Argument parsing lives in cli/args.zig (extracted so neither file // exceeds ~1000 lines). Alias the decls main/dispatch reference so their diff --git a/src/cli/config.zig b/src/cli/config.zig index f8bbccba..79c6b540 100644 --- a/src/cli/config.zig +++ b/src/cli/config.zig @@ -274,10 +274,6 @@ pub const reserved_env = [_][]const u8{ /// with the reason it is not CLI-owned. Kept beside `reserved_env` so the /// guard can tell a deliberate decision from a forgotten name. pub const unreserved_labelle_env = [_][]const u8{ - // The SDL2 library directory. The CLI reads it only to find a user's - // install; a package that provisions SDL2 is meant to set it for the - // build (RFC cli#466 §9). - "LABELLE_SDL2_LIB", // Written into a bundle's launcher script for the game; never read by // the CLI. "LABELLE_DATA_DIR", @@ -297,17 +293,17 @@ test "provider env reserved names: a fixed CLI-owned table, case-folded under Wi try std.testing.expect(reservedEnvName(name, false)); try std.testing.expect(reservedEnvName(name, true)); } - // Not a prefix ban: the SDL2 library variable and any other name stay - // settable, and an env_file setting it parses. - for ([_][]const u8{ "LABELLE_SDL2_LIB", "LABELLE_ANYTHING", "TOOLCHAIN_ROOT" }) |name| { + // Not a prefix ban: a provider's own `LABELLE_*` variable and any other + // name stay settable, and an env_file setting one parses. + for ([_][]const u8{ "LABELLE_ANYTHING", "TOOLCHAIN_ROOT" }) |name| { try std.testing.expect(!reservedEnvName(name, false)); try std.testing.expect(!reservedEnvName(name, true)); } var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); var diag: @import("provider_env.zig").Diagnostic = .{}; - const file = try @import("provider_env.zig").parseFile(arena.allocator(), "{\"set\":[{\"name\":\"LABELLE_SDL2_LIB\",\"value\":\"/sdl/lib\"}]}", false, &diag); - try std.testing.expectEqualStrings("LABELLE_SDL2_LIB", file.set[0].name); + const file = try @import("provider_env.zig").parseFile(arena.allocator(), "{\"set\":[{\"name\":\"LABELLE_ANYTHING\",\"value\":\"/opt/lib\"}]}", false, &diag); + try std.testing.expectEqualStrings("LABELLE_ANYTHING", file.set[0].name); // `Path` is PATH under Windows rules only. try std.testing.expect(reservedEnvName("Path", true)); try std.testing.expect(!reservedEnvName("Path", false)); diff --git a/src/cli/doctor.zig b/src/cli/doctor.zig index 393d02aa..fdec3a57 100644 --- a/src/cli/doctor.zig +++ b/src/cli/doctor.zig @@ -5,18 +5,20 @@ //! then runs the doctor of every pinned provider whose manifest declares one //! (the same run as `labelle doctor`; see `provider_doctor.zig`), //! and exits non-zero if the core or any provider fails. `--core-only` skips -//! the provider part. Almost everything a -//! labelle game needs is fetched + compiled by Zig automatically (raylib, -//! sokol, cimgui, glfw, wgpu-native, the labelle packages). The one genuine -//! manual system dependency is **SDL2** — used by the raylib/sokol backends -//! for the desktop gamepad source, and by the `sdl` backend as the renderer -//! (which additionally needs the headers + SDL2_mixer). When it is missing the -//! build otherwise fails deep in a Zig linker dump ("unable to find dynamic -//! system library 'SDL2'"); this command surfaces it up front instead. +//! the provider part. The core checks only what it owns: the managed Zig and +//! Python. Everything else a labelle game needs is fetched + compiled by Zig, +//! or is a provider's to check and provision. //! -//! `--fix` auto-provisions SDL2 into `~/.labelle/sdl2/` on Windows (see -//! `sdl_provision.zig`); `build`/`run` then auto-wire the cached install -//! into the child environment so it works without manual env setup. +//! SDL2 is one such system library: the `sdl` renderer and the gamepad +//! source of other desktop backends link it. Its provisioning, runtime DLL +//! staging and doctor rows left the core for the opt-in `sdl2` provider +//! (labelle-sdl; RFC cli#471 S4, D2). When the project's build links SDL2 +//! and `.plugins` does not list that provider, doctor prints a one-line +//! hint instead (`sdl2Hint`). +//! +//! `--fix` has nothing to fix in the core any more; a provider's doctor +//! fixes its own (`labelle doctor --fix`). Forwarding `--fix` to +//! the provider doctors (RFC cli#471 D10) is not implemented yet. const std = @import("std"); const builtin = @import("builtin"); @@ -24,7 +26,6 @@ const config = @import("config.zig"); const assembler_proc = @import("assembler_proc.zig"); const assembler_describe = @import("assembler_describe.zig"); const provider_targets = @import("provider_targets.zig"); -const sdl_provision = @import("sdl_provision.zig"); const zig_toolchain = @import("zig_toolchain.zig"); const zig_cache = @import("zig_cache.zig"); const python_provision = @import("python_provision.zig"); @@ -163,8 +164,7 @@ pub fn cmdDoctor(allocator: std.mem.Allocator, cmd_args: []const []const u8) !vo const python_check = checkPython(arena); // `--json`: emit the studio's capability report from the toolchain - // checks and the provider doctors, and stop — no human report, no SDL - // provisioning. The python + // checks and the provider doctors, and stop — no human report. The python // item is fixable via `labelle install python` (cli#291); zig stays a // non-fixable status row (the gate treats non-fixable as satisfied, so // it renders their status without offering an install button). @@ -184,56 +184,28 @@ pub fn cmdDoctor(allocator: std.mem.Allocator, cmd_args: []const []const u8) !vo } // Which backend the project builds with, as the assembler resolves it - // (`describe`, cli#471 D4: the CLI reads no `.backend` of its own), and - // so which parts of SDL2 it pulls in (`sdl_provision.Needs`, the one - // place the backend names are spelled). Only inside a project: outside - // one, or when `describe` cannot answer, the backend is unknown and - // doctor never claims an all-clear (`sdlNeeds`): every SDL2 part is - // checked, the ones it cannot be sure of as warnings. + // (`describe`, cli#471 D4: the CLI reads no `.backend` of its own). Only + // inside a project: outside one, or when `describe` cannot answer, it is + // unknown. It scopes nothing but the report header and the SDL2 hint. const backend: ?[]const u8 = if (cfg.found) describeBackend(arena, scope.dir) else null; - const needs = sdlNeeds(backend, cfg.gamepad_off); - const needs_sdl_render = needs.render; - const needs_sdl_gamepad = needs.gamepad; - const needs_sdl = needs.any(); + + if (do_fix) { + std.debug.print("labelle doctor: the core has nothing to fix; a provider's doctor fixes its own (`labelle doctor --fix`).\n", .{}); + } var checks: std.ArrayList(Check) = .empty; try checks.append(arena, zig_check); try checks.append(arena, python_check); - if (needs_sdl) { - var lib = checkSdl2Lib(arena); - if (!lib.ok and do_fix) { - std.debug.print("\nlabelle doctor: provisioning SDL2...\n", .{}); - _ = sdl_provision.provisionSdl2(allocator); - lib = checkSdl2Lib(arena); // re-detect — the cache scan now finds it - } - lib.required = needs.libRequired(); - try checks.append(arena, lib); - if (builtin.os.tag == .windows) { - var dll = checkSdl2Dll(arena); - dll.required = dll.required and needs.libRequired(); - try checks.append(arena, dll); - } - if (needs_sdl_render) { - for ([_]Check{ checkSdl2Headers(arena), checkSdl2Mixer(arena) }) |c| { - var check = c; - check.required = !needs.unknown; - try checks.append(arena, check); - } - } - } else if (do_fix) { - std.debug.print("labelle doctor: nothing to fix — this backend needs no system libraries.\n", .{}); - } - // ── Report ────────────────────────────────────────────────────────── const backend_label = if (backend) |name| name else if (cfg.found) - "unknown (`labelle-assembler describe` gave no answer; SDL2 checks below are a superset)" + "unknown (`labelle-assembler describe` gave no answer)" else - "unknown (no project.labelle; SDL2 checks below are a superset)"; - const gamepad_label = if (needs_sdl_gamepad) "on" else if (needs_sdl) "off" else "n/a"; + "unknown (no project.labelle)"; + const gamepad_label = if (!cfg.found) "n/a" else if (cfg.gamepad_off) "off" else "on"; std.debug.print( \\ \\labelle doctor @@ -244,10 +216,6 @@ pub fn cmdDoctor(allocator: std.mem.Allocator, cmd_args: []const []const u8) !vo \\ , .{ scope.dir, backend_label, gamepad_label }); - if (!needs_sdl) { - std.debug.print(" This backend needs no manual system libraries — everything is fetched + built by Zig.\n", .{}); - } - var failures: u32 = 0; var warnings: u32 = 0; for (checks.items) |c| { @@ -267,10 +235,11 @@ pub fn cmdDoctor(allocator: std.mem.Allocator, cmd_args: []const []const u8) !vo std.debug.print("\n", .{}); if (failures == 0) { - std.debug.print(" All required desktop build dependencies are present.\n", .{}); + std.debug.print(" All required core dependencies are present.\n", .{}); } else { std.debug.print(" {d} required dependency(ies) missing — see FAIL lines above.\n", .{failures}); } + if (sdl2Hint(cfg, backend)) |hint| std.debug.print(" {s}\n", .{hint}); // The pinned providers' doctors, after the core checks and whatever they // found: a core failure does not hide a provider's report, and one @@ -335,47 +304,50 @@ const Cfg = struct { /// A `project.labelle` was read. found: bool = false, gamepad_off: bool = false, + /// `.plugins` lists the `sdl2` provider (labelle-sdl). + sdl2_provider: bool = false, }; -/// Which SDL2 parts doctor checks. A known backend: exactly what it pulls in -/// (`sdl_provision.Needs`). An unknown one (no project, or `describe` gave -/// no answer): everything — the library as required unless the gamepad is -/// opted out (the assembler's default backend links it for the gamepad), -/// and the renderer's headers + SDL2_mixer as warnings, so an `sdl` -/// renderer project is never told nothing is needed (Codex P2 on #517). -const SdlNeeds = struct { - render: bool, - gamepad: bool, - unknown: bool, - - fn any(self: SdlNeeds) bool { - return self.render or self.gamepad; - } +/// The one line doctor prints about SDL2 now that the core no longer +/// provisions it (cli#471 S4), or null. Shown when the project's desktop +/// build links SDL2 and `.plugins` lacks the `sdl2` provider that took the +/// provisioning over. "Links SDL2" is the rule the core used to provision +/// by: the `sdl` renderer always; raylib, sokol and bgfx for their gamepad +/// source unless `.gamepad = .none`. An unknown backend (`describe` gave no +/// answer) is judged by the gamepad alone, as the assembler's default +/// backend links SDL2 for it. Outside a project there is nothing to judge. +/// Advice, never a failure: SDL2 may be installed by other means. +fn sdl2Hint(cfg: Cfg, backend: ?[]const u8) ?[]const u8 { + if (!cfg.found or cfg.sdl2_provider or !linksSdl2(backend, cfg.gamepad_off)) return null; + return "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB, or use `.gamepad = .none`."; +} - fn libRequired(self: SdlNeeds) bool { - return !self.unknown or self.gamepad; +fn linksSdl2(backend: ?[]const u8, gamepad_off: bool) bool { + const name = backend orelse return !gamepad_off; + if (std.mem.eql(u8, name, "sdl")) return true; + for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |pad| { + if (std.mem.eql(u8, name, pad)) return !gamepad_off; } -}; - -fn sdlNeeds(backend: ?[]const u8, gamepad_off: bool) SdlNeeds { - const name = backend orelse return .{ .render = true, .gamepad = !gamepad_off, .unknown = true }; - const n = sdl_provision.Needs.of(name, gamepad_off); - return .{ .render = n.render, .gamepad = n.gamepad, .unknown = false }; + return false; } -test "doctor: an unknown backend is never an all-clear (Codex P2 on #517)" { - // Unknown, gamepad on: the library required, the renderer parts as warnings. - const on = sdlNeeds(null, false); - try std.testing.expect(on.any() and on.render and on.gamepad and on.libRequired()); - // Unknown, `.gamepad = .none`: still checked (it may be the sdl renderer), - // but nothing is a hard failure. - const off = sdlNeeds(null, true); - try std.testing.expect(off.any() and off.render and !off.gamepad and !off.libRequired()); - // Known backends: exactly their needs, all required. - const sdl = sdlNeeds("sdl", true); - try std.testing.expect(sdl.render and !sdl.unknown and sdl.libRequired()); - try std.testing.expect(!sdlNeeds("null", false).any()); - try std.testing.expect(sdlNeeds("bgfx", false).gamepad); +test "doctor: the SDL2 hint follows the old rule and goes quiet with the provider (cli#471 S4)" { + const plain: Cfg = .{ .found = true }; + // The renderer always, even with the gamepad opted out. + try std.testing.expect(sdl2Hint(plain, "sdl") != null); + try std.testing.expect(sdl2Hint(.{ .found = true, .gamepad_off = true }, "sdl") != null); + // The gamepad backends unless opted out; any other backend never. + for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |name| { + try std.testing.expect(sdl2Hint(plain, name) != null); + try std.testing.expect(sdl2Hint(.{ .found = true, .gamepad_off = true }, name) == null); + } + for ([_][]const u8{ "null", "wgpu", "acme" }) |name| try std.testing.expect(sdl2Hint(plain, name) == null); + // Unknown backend: the gamepad decides. + try std.testing.expect(sdl2Hint(plain, null) != null); + try std.testing.expect(sdl2Hint(.{ .found = true, .gamepad_off = true }, null) == null); + // The provider listed, or no project: no hint. + try std.testing.expect(sdl2Hint(.{ .found = true, .sdl2_provider = true }, "sdl") == null); + try std.testing.expect(sdl2Hint(.{}, "sdl") == null); } /// The backend package's name for the core target, from `labelle-assembler @@ -387,18 +359,24 @@ fn describeBackend(arena: std.mem.Allocator, project_dir: []const u8) ?[]const u return d.backend.name; } -/// Read the gamepad opt-out straight out of `project.labelle` text. A full -/// ZON parse isn't worth a dependency here; the field is a simple -/// `.field = .value` form. +/// The gamepad opt-out and whether the `sdl2` provider is listed, from +/// `project.labelle`. Parsed when it parses; otherwise read out of the text +/// (doctor is best-effort and still reports on a config the build would +/// reject). fn readProjectConfig(arena: std.mem.Allocator, project_dir: []const u8) Cfg { const io = config.globalIo(); const path = std.fs.path.join(arena, &.{ project_dir, "project.labelle" }) catch return .{}; const content = std.Io.Dir.cwd().readFileAlloc(io, path, arena, .limited(1 << 20)) catch return .{}; - var cfg: Cfg = .{ .found = true }; - cfg.gamepad_off = std.mem.indexOf(u8, content, ".gamepad = .none") != null or - std.mem.indexOf(u8, content, ".gamepad=.none") != null; - return cfg; + if (config.readProjectConfigQuiet(arena, project_dir)) |parsed| { + return .{ .found = true, .gamepad_off = parsed.gamepad == .none, .sdl2_provider = parsed.hasPlugin("sdl2") }; + } else |_| {} + return .{ + .found = true, + .gamepad_off = std.mem.indexOf(u8, content, ".gamepad = .none") != null or + std.mem.indexOf(u8, content, ".gamepad=.none") != null, + .sdl2_provider = std.mem.indexOf(u8, content, "\"sdl2\"") != null, + }; } // ── Individual checks ─────────────────────────────────────────────────── @@ -478,152 +456,6 @@ fn checkPython(arena: std.mem.Allocator) Check { }; } -fn checkSdl2Lib(arena: std.mem.Allocator) Check { - const name = "SDL2 library (gamepad + sdl backend)"; - switch (builtin.os.tag) { - .windows => { - if (envOwned(arena, "LABELLE_SDL2_LIB")) |dir| { - const probe = std.fs.path.join(arena, &.{ dir, "libSDL2.dll.a" }) catch dir; - if (fileExists(probe)) return ok(name, dir); - } - if (findCachedSdl2Lib(arena)) |dir| return ok(name, dir); - return .{ .name = name, .ok = false, .hint = "SDL2 (MinGW dev libs) not found. Download SDL2-devel--mingw, then set LABELLE_SDL2_LIB to its x86_64-w64-mingw32\\lib dir (and put SDL2.dll on PATH). Or set `.gamepad = .none` in project.labelle if you don't need gamepad input. (`labelle doctor --fix` will automate this soon.)" }; - }, - .linux => { - if (runOk(arena, &.{ "pkg-config", "--exists", "sdl2" })) return ok(name, "pkg-config: sdl2"); - for ([_][]const u8{ "/usr/lib/x86_64-linux-gnu/libSDL2.so", "/usr/lib/libSDL2.so", "/usr/lib64/libSDL2.so", "/usr/local/lib/libSDL2.so" }) |p| { - if (fileExists(p)) return ok(name, p); - } - return .{ .name = name, .ok = false, .hint = "SDL2 not found. Install it: `sudo apt install libsdl2-dev` (Debian/Ubuntu) or `sudo dnf install SDL2-devel` (Fedora). Or set `.gamepad = .none`." }; - }, - .macos => { - for ([_][]const u8{ "/opt/homebrew/lib/libSDL2.dylib", "/usr/local/lib/libSDL2.dylib" }) |p| { - if (fileExists(p)) return ok(name, p); - } - return .{ .name = name, .ok = false, .hint = "SDL2 not found. Install it: `brew install sdl2`. Or set `.gamepad = .none`." }; - }, - else => return .{ .name = name, .ok = false, .hint = "Unsupported desktop OS for SDL2 detection." }, - } -} - -fn checkSdl2Dll(arena: std.mem.Allocator) Check { - const name = "SDL2.dll for runtime"; - if (onPath(arena, "SDL2.dll")) |p| return ok(name, p); - // The provisioner places SDL2.dll in the cache lib dir; accept that so a - // freshly `--fix`ed setup reports green. (Auto-wiring PATH for `run` is a - // later phase; until then add this dir to PATH for standalone runs.) - if (findCachedSdl2Lib(arena)) |libdir| { - const dll = std.fs.path.join(arena, &.{ libdir, "SDL2.dll" }) catch libdir; - if (fileExists(dll)) { - return .{ .name = name, .ok = true, .detail = std.fmt.allocPrint(arena, "{s} (labelle SDL2 cache — add this dir to PATH for runtime)", .{libdir}) catch dll }; - } - } - return .{ .name = name, .ok = false, .required = false, .hint = "SDL2.dll is needed at runtime. Add the SDL2 `bin` dir to PATH, or run `labelle doctor --fix`." }; -} - -fn checkSdl2Headers(arena: std.mem.Allocator) Check { - const name = "SDL2 headers (sdl backend)"; - switch (builtin.os.tag) { - .windows => { - if (envOwned(arena, "LABELLE_SDL2_LIB")) |dir| { - const inc = std.fs.path.join(arena, &.{ dir, "..", "include", "SDL2", "SDL.h" }) catch dir; - if (fileExists(inc)) return ok(name, inc); - } - // The --fix-provisioned MinGW package ships headers beside the - // lib — accept the cache here the same way checkSdl2Lib does, - // so a fixed setup passes the headers check too. - if (findCachedSdl2Lib(arena)) |libdir| { - const inc = std.fs.path.join(arena, &.{ libdir, "..", "include", "SDL2", "SDL.h" }) catch libdir; - if (fileExists(inc)) return ok(name, inc); - } - return fail(name, "SDL2 headers not found. The `sdl` render backend needs the SDL2 dev headers (SDL2/SDL.h) from the MinGW dev package."); - }, - .linux => { - if (runOk(arena, &.{ "pkg-config", "--cflags", "sdl2" })) return ok(name, "pkg-config: sdl2 cflags"); - if (fileExists("/usr/include/SDL2/SDL.h")) return ok(name, "/usr/include/SDL2/SDL.h"); - return fail(name, "SDL2 headers not found. `sudo apt install libsdl2-dev` / `sudo dnf install SDL2-devel`."); - }, - .macos => { - for ([_][]const u8{ "/opt/homebrew/include/SDL2/SDL.h", "/usr/local/include/SDL2/SDL.h" }) |p| { - if (fileExists(p)) return ok(name, p); - } - return fail(name, "SDL2 headers not found. `brew install sdl2`."); - }, - else => return fail(name, "Unsupported OS."), - } -} - -fn checkSdl2Mixer(arena: std.mem.Allocator) Check { - const name = "SDL2_mixer (sdl backend audio)"; - switch (builtin.os.tag) { - .windows => { - if (envOwned(arena, "LABELLE_SDL2_LIB")) |dir| { - const probe = std.fs.path.join(arena, &.{ dir, "libSDL2_mixer.dll.a" }) catch dir; - if (fileExists(probe)) return ok(name, probe); - } - return fail(name, "SDL2_mixer not found. The `sdl` backend's audio needs SDL2_mixer-devel (MinGW). Download SDL2_mixer-devel--mingw alongside SDL2."); - }, - .linux => { - if (runOk(arena, &.{ "pkg-config", "--exists", "SDL2_mixer" })) return ok(name, "pkg-config: SDL2_mixer"); - return fail(name, "SDL2_mixer not found. `sudo apt install libsdl2-mixer-dev` / `sudo dnf install SDL2_mixer-devel`."); - }, - .macos => { - for ([_][]const u8{ "/opt/homebrew/lib/libSDL2_mixer.dylib", "/usr/local/lib/libSDL2_mixer.dylib" }) |p| { - if (fileExists(p)) return ok(name, p); - } - return fail(name, "SDL2_mixer not found. `brew install sdl2_mixer`."); - }, - else => return fail(name, "Unsupported OS."), - } -} - -// ── Helpers ───────────────────────────────────────────────────────────── - -fn ok(name: []const u8, detail: []const u8) Check { - return .{ .name = name, .ok = true, .detail = detail }; -} - -fn fail(name: []const u8, hint: []const u8) Check { - return .{ .name = name, .ok = false, .hint = hint }; -} - -fn fileExists(path: []const u8) bool { - std.Io.Dir.cwd().access(config.globalIo(), path, .{}) catch return false; - return true; -} - -fn envOwned(arena: std.mem.Allocator, key: []const u8) ?[]u8 { - if (config.globalEnviron().getAlloc(arena, key)) |v| return v else |_| return null; -} - -/// Run a command and report whether it exited 0. Used for `pkg-config` probes. -fn runOk(arena: std.mem.Allocator, argv: []const []const u8) bool { - const res = std.process.run(arena, config.globalIo(), .{ .argv = argv }) catch return false; - return switch (res.term) { - .exited => |c| c == 0, - else => false, - }; -} - -/// First PATH entry containing `filename`, or null. -fn onPath(arena: std.mem.Allocator, filename: []const u8) ?[]const u8 { - const path = envOwned(arena, "PATH") orelse return null; - var it = std.mem.tokenizeScalar(u8, path, std.fs.path.delimiter); - while (it.next()) |dir| { - const full = std.fs.path.join(arena, &.{ dir, filename }) catch continue; - if (fileExists(full)) return full; - } - return null; -} - -/// Cached SDL2 lib dir, any version. Delegates to the provisioner's scan -/// so detection here and the build/run env wiring share one acceptance -/// rule — doctor must never report a cache green that autoWireEnv then -/// ignores. -fn findCachedSdl2Lib(arena: std.mem.Allocator) ?[]const u8 { - return sdl_provision.findCachedLibDir(arena); -} - // ── Tests ─────────────────────────────────────────────────────────────── test "doctor: a Zig override is verified, not assumed: missing, wrong version, right version" { diff --git a/src/cli/help.zig b/src/cli/help.zig index fe515a28..c5324fab 100644 --- a/src/cli/help.zig +++ b/src/cli/help.zig @@ -33,7 +33,7 @@ pub fn printHelp() void { \\ plugins [dir] List attached plugins with version, license, and author \\ providers resolve [providers.json] [--accept] [--offline] Preview or pin GitHub providers for this project \\ providers fetch [--offline] Inside a project, download the provider archives its labelle.providers.lock pins (only verified bytes are cached, each archive atomically; no registry, no lock change); `--offline` only verifies the cache - \\ doctor [dir] [--fix] [--json] [--core-only] [--zig ] Check build requirements (Zig, Python and the system libraries the backend needs), then, inside a project, run each pinned provider's `doctor` command (as `labelle doctor`, by namespace; exit non-zero if any check fails); `--fix` provisions, `--json` prints one capability report on stdout: the core's capabilities plus each provider doctor's (run with `--json`, its stdout captured); `--core-only` skips the providers, in either mode; `--json` exits 0 even when checks fail (the verdict is in the document), `--zig ` (or `--zig=`) checks and builds the provider doctors with that compiler, as `labelle build` takes it (`LABELLE_ZIG` still wins) + \\ doctor [dir] [--fix] [--json] [--core-only] [--zig ] Check the core's build requirements (Zig, Python; a system library is its provider's to check), then, inside a project, run each pinned provider's `doctor` command (as `labelle doctor`, by namespace; exit non-zero if any check fails); `--fix` has nothing to fix in the core (a provider's doctor fixes its own: `labelle doctor --fix`), `--json` prints one capability report on stdout: the core's capabilities plus each provider doctor's (run with `--json`, its stdout captured); `--core-only` skips the providers, in either mode; `--json` exits 0 even when checks fail (the verdict is in the document), `--zig ` (or `--zig=`) checks and builds the provider doctors with that compiler, as `labelle build` takes it (`LABELLE_ZIG` still wins) \\ help Show this help \\ version Show CLI version \\ diff --git a/src/cli/pipeline.zig b/src/cli/pipeline.zig index 052d5320..8c074bc0 100644 --- a/src/cli/pipeline.zig +++ b/src/cli/pipeline.zig @@ -258,9 +258,9 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { r.beginPhase(.resolve, "resolving toolchain + packages"); } - // Version compatibility, the pre-build hooks (#355) and the SDL2 env - // wiring (`install.preInstall`), scoped by the backend `describe` named. - const wants_sdl2 = try install.preInstall(allocator, project_dir, parsed, &parsed_args, backend_label); + // Version compatibility and the pre-build hooks (#355, + // `install.preInstall`). + try install.preInstall(allocator, project_dir, parsed, &parsed_args); // Issue #217: the CLI is a thin driver over the standalone // labelle-assembler binary. Resolved once (LABELLE_ASSEMBLER env var @@ -465,7 +465,7 @@ pub fn run(allocator: std.mem.Allocator, parsed_args: ParsedArgs) !u8 { // Provider hooks on `build` around the core build (`build.run`). const build_out = try provider_hooks.stepOutputDir(hook_arena, target_dir, .build, target.name, null); - if (try build.run(&cx, build_out, zig_args.items, zig_env_ptr, wants_sdl2)) |code| return code; + if (try build.run(&cx, build_out, zig_args.items, zig_env_ptr)) |code| return code; // `labelle bundle` (cli#359): the exe is built; wrap it // (`build.bundleStep`). diff --git a/src/cli/pipeline/build.zig b/src/cli/pipeline/build.zig index 5f44017b..21854036 100644 --- a/src/cli/pipeline/build.zig +++ b/src/cli/pipeline/build.zig @@ -1,17 +1,16 @@ //! The build and bundle stages: the `build` hook phases around the core -//! build (docker or host `zig build`, SDL2 DLL staging and the `labelle -//! build` packaging finalisation), and the `bundle` step. +//! build (docker or host `zig build` and the `labelle build` packaging +//! finalisation), and the `bundle` step. const std = @import("std"); const docker = @import("../docker.zig"); const runner = @import("../runner.zig"); -const sdl_provision = @import("../sdl_provision.zig"); const bundle = @import("../bundle.zig"); const linux_desktop = @import("../linux_desktop.zig"); const provider_hooks = @import("../provider_hooks.zig"); const Context = @import("context.zig").Context; /// Provider hooks on `build` (contract §6) wrap the whole core build — -/// docker or host `zig build` plus the runtime DLL staging — with +/// docker or host `zig build` plus the packaging finalisation — with /// `output_dir` = the target's `zig-out/`. A `replace` hook stands in for /// all of it. Hooks report under the `compile` phase. /// @@ -21,7 +20,6 @@ pub fn run( build_out: []const u8, zig_args: []const []const u8, zig_env_ptr: ?*const std.process.Environ.Map, - wants_sdl2: bool, ) !?u8 { const allocator = cx.allocator; const command = cx.parsed_args.command; @@ -101,21 +99,6 @@ pub fn run( } std.debug.print(" build ok\n", .{}); - // Stage the runtime SDL2.dll next to the freshly-built desktop exe. A - // gamepad/SDL2 build's exe fails process creation with a bare - // `FileNotFound` when SDL2.dll isn't in its own directory (cli#285): the - // Windows loader resolves implicitly-linked DLLs from the exe dir first, - // and neither the PATH prepend from autoWireEnv nor a user-set - // LABELLE_SDL2_LIB puts the DLL there. Docker builds are skipped — their - // exe is built for the container's OS, so a host SDL2.dll is irrelevant. - if (!parsed_args.docker and parsed.platform == .desktop and wants_sdl2) { - const bin_dir = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "bin" }); - defer allocator.free(bin_dir); - // `compile_env`: the SDL2 the build linked, including a hook's - // `LABELLE_SDL2_LIB` contribution (contract §2). - sdl_provision.stageSdl2DllBesideExe(allocator, bin_dir, compile_env); - } - // `labelle build` finalization — everything that turns the compiled // tree into the command's final artifact. It sits INSIDE the core // build so the `after build` hooks below see the finished artifact diff --git a/src/cli/pipeline/install.zig b/src/cli/pipeline/install.zig index 2a2f3330..c111bce1 100644 --- a/src/cli/pipeline/install.zig +++ b/src/cli/pipeline/install.zig @@ -14,7 +14,6 @@ const prebuild = @import("../prebuild.zig"); const project_lock = @import("../project_lock.zig"); const material_toolchain = @import("../material_toolchain.zig"); const progress = @import("../progress.zig"); -const sdl_provision = @import("../sdl_provision.zig"); const args_mod = @import("../args.zig"); const provider_dispatch = @import("../provider_dispatch.zig"); const provider_github = @import("../provider_github.zig"); @@ -24,9 +23,10 @@ const confirmTarget = @import("args_resolve.zig").confirmTarget; const HookPlans = @import("context.zig").HookPlans; const ParsedArgs = args_mod.ParsedArgs; -/// Version compatibility, the `.prebuild` steps and the SDL2 env wiring: -/// everything between the progress feed's start and the assembler -/// resolution. Returns whether the build wants SDL2 (`wants_sdl2`). +/// Version compatibility and the `.prebuild` steps: everything between the +/// progress feed's start and the assembler resolution. A desktop build's +/// system libraries are its providers' business (the `env` hooks), not the +/// core's (cli#471 S4). /// The managed-Python PATH wiring the `.prebuild` steps get (see /// `preInstall`): only when steps will actually run, so a project without /// `.prebuild` — or `LABELLE_NO_PREBUILD=1` — stays inert. Shared with a @@ -38,7 +38,7 @@ pub fn wirePrebuildPython(allocator: std.mem.Allocator, steps: []const prebuild. } } -pub fn preInstall(allocator: std.mem.Allocator, project_dir: []const u8, parsed: project_config.ProjectConfig, parsed_args: *const ParsedArgs, backend: []const u8) !bool { +pub fn preInstall(allocator: std.mem.Allocator, project_dir: []const u8, parsed: project_config.ProjectConfig, parsed_args: *const ParsedArgs) !void { // Validate version compatibility compatibility.validateCompatibility(parsed); @@ -77,9 +77,8 @@ pub fn preInstall(allocator: std.mem.Allocator, project_dir: []const u8, parsed: // Nothing else a hook could reasonably need is wired later. The managed // Zig toolchain is spawned by absolute path and never joins PATH at all; // a provider's toolchain reaches the build through its hooks' environment - // contributions; and `sdl_provision.autoWireEnv` (just below) sets a - // Windows link/runtime variable consumed by `zig build`, not a tool a - // generator spawns. + // contributions (a system library too: the core provisions none, + // cli#471 S4). wirePrebuildPython(allocator, parsed.prebuild); // Inside a hook or prebuild step of a command holding this project's @@ -91,19 +90,6 @@ pub fn preInstall(allocator: std.mem.Allocator, project_dir: []const u8, parsed: try prebuild.runAll(allocator, project_dir, parsed.prebuild, .{ .route_stdout_to_stderr = parsed_args.progress_mode == .json, }); - - // Auto-wire a cache-provisioned SDL2 (`labelle doctor --fix`) into the - // build/run environment so desktop games that need it link + run without - // the user setting LABELLE_SDL2_LIB by hand. No-op when SDL2 isn't in the - // cache or the user already set the var. Scoped like `labelle doctor`, - // by the backend package `describe` named (`backend`, cli#471 D4) and - // the gamepad opt-out: `sdl_provision.Needs` says which backends pull - // SDL2 in. - const wants_sdl2 = sdl_provision.Needs.of(backend, parsed.gamepad == .none).any(); - if (parsed.platform == .desktop and wants_sdl2) { - sdl_provision.autoWireEnv(allocator); - } - return wants_sdl2; } /// Cold-path stage order (cli#387 gap 3): the shader-compiler override gate diff --git a/src/cli/sdl_provision.zig b/src/cli/sdl_provision.zig deleted file mode 100644 index 4b405c85..00000000 --- a/src/cli/sdl_provision.zig +++ /dev/null @@ -1,365 +0,0 @@ -//! SDL2 auto-provisioning for `labelle doctor --fix`. -//! -//! SDL2 is the one manual system dependency a labelle desktop game needs (the -//! raylib/sokol gamepad source + the sdl render backend). On Windows there is -//! no system package manager, so this module downloads the official MinGW dev -//! package into `~/.labelle/sdl2/` and arranges it exactly the way Zig's -//! linker wants (import lib + the DLL placed in the lib search dir, static -//! archives removed so the dynamic candidate wins). On Linux/macOS a system -//! install needs privileges we won't assume, so we print the one-liner. -//! -//! The cache layout produced here is the one `doctor.zig:findCachedSdl2Lib` -//! scans, and that a later phase wires into the build/run env automatically. - -const std = @import("std"); -const builtin = @import("builtin"); -const config = @import("config.zig"); -const util = @import("util.zig"); -const asm_cache = @import("asm_cache.zig"); - -/// SDL2 release to fetch. Matches the version verified against the toolkit's -/// Windows backends. -pub const SDL2_VERSION = "2.30.11"; - -/// Which parts of SDL2 a backend pulls in, by the package NAME -/// `labelle-assembler describe` reports (cli#471 D4: the CLI mirrors no -/// backend enum, so this is the one place the names are spelled, until the -/// `sdl2` provider takes SDL provisioning out of the core, RFC cli#471 S4). -/// Mirrors the assembler's `deps_linker.stagesSdlGamepad`: the `sdl` -/// renderer always links SDL2 (and needs its headers + SDL2_mixer), and -/// raylib/sokol/bgfx link it for the shared desktop gamepad source unless -/// the project sets `.gamepad = .none` (cli#285 / cli#286). Any other -/// backend, including a third-party package, needs none. -pub const Needs = struct { - render: bool = false, - gamepad: bool = false, - - pub fn of(backend: []const u8, gamepad_off: bool) Needs { - const render = std.mem.eql(u8, backend, "sdl"); - const pad_source = render or for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |name| { - if (std.mem.eql(u8, backend, name)) break true; - } else false; - return .{ .render = render, .gamepad = pad_source and !gamepad_off }; - } - - pub fn any(self: Needs) bool { - return self.render or self.gamepad; - } -}; - -test "Needs.of: the renderer always, the gamepad backends unless opted out, nothing else" { - try std.testing.expectEqual(Needs{ .render = true, .gamepad = true }, Needs.of("sdl", false)); - try std.testing.expectEqual(Needs{ .render = true, .gamepad = false }, Needs.of("sdl", true)); - for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |name| { - try std.testing.expectEqual(Needs{ .gamepad = true }, Needs.of(name, false)); - try std.testing.expect(!Needs.of(name, true).any()); - } - for ([_][]const u8{ "null", "wgpu", "acme" }) |name| try std.testing.expect(!Needs.of(name, false).any()); -} - -pub const Result = enum { - /// SDL2 is available in the cache (freshly provisioned or already there). - ready, - /// Printed package-manager guidance (Linux/macOS) — user action needed. - guided, - /// Download/extract failed. - failed, -}; - -/// Resolve the cache lib dir SDL2 is (or will be) provisioned into: -/// `~/.labelle/sdl2/SDL2-/x86_64-w64-mingw32/lib`. Caller owns the slice. -pub fn cachedLibDir(allocator: std.mem.Allocator) ![]u8 { - const cache_root = try asm_cache.getCacheRoot(allocator); - defer allocator.free(cache_root); - return std.fs.path.join(allocator, &.{ cache_root, "sdl2", "SDL2-" ++ SDL2_VERSION, "x86_64-w64-mingw32", "lib" }); -} - -/// First cached SDL2 lib dir containing the import lib, ANY version — -/// scans `~/.labelle/sdl2/*/x86_64-w64-mingw32/lib`. This is the single -/// acceptance rule shared by `labelle doctor`'s detection and -/// `autoWireEnv`, so doctor never reports a cache green that build/run -/// then ignore. (`cachedLibDir` above stays pinned — it's the -/// provisioning *target*, not the detection rule.) Arena-allocated. -pub fn findCachedLibDir(a: std.mem.Allocator) ?[]const u8 { - const io = config.globalIo(); - const root = asm_cache.getCacheRoot(a) catch return null; - const sdl_root = join(a, &.{ root, "sdl2" }) orelse return null; - var dir = std.Io.Dir.cwd().openDir(io, sdl_root, .{ .iterate = true }) catch return null; - defer dir.close(io); - var it = dir.iterate(); - while (it.next(io) catch return null) |entry| { - if (entry.kind != .directory) continue; - const lib = join(a, &.{ sdl_root, entry.name, "x86_64-w64-mingw32", "lib" }) orelse continue; - const probe = join(a, &.{ lib, "libSDL2.dll.a" }) orelse continue; - if (util.fileExists(probe)) return lib; - } - return null; -} - -/// Ensure SDL2 is available, downloading it on Windows if needed. -pub fn provisionSdl2(gpa: std.mem.Allocator) Result { - switch (builtin.os.tag) { - .windows => return provisionWindows(gpa), - .linux => { - std.debug.print( - \\ - \\ SDL2 must be installed via your system package manager: - \\ sudo apt install libsdl2-dev libsdl2-mixer-dev # Debian/Ubuntu - \\ sudo dnf install SDL2-devel SDL2_mixer-devel # Fedora - \\ sudo pacman -S sdl2 sdl2_mixer # Arch - \\ - , .{}); - return .guided; - }, - .macos => { - std.debug.print( - \\ - \\ Install SDL2 via Homebrew: - \\ brew install sdl2 sdl2_mixer - \\ - , .{}); - return .guided; - }, - else => return .failed, - } -} - -/// If SDL2 was provisioned into the cache (by `doctor --fix`) and the user -/// hasn't set `LABELLE_SDL2_LIB` themselves, inject it — plus prepend the dir -/// (which holds SDL2.dll) to PATH for runtime — into THIS process's -/// environment, so the `zig build` / game children spawned afterwards inherit -/// it. No-op off Windows, or when the cache is absent, or when the user -/// already set `LABELLE_SDL2_LIB`. This is what makes a desktop build/run -/// "just work" after a one-time `labelle doctor --fix`. -pub fn autoWireEnv(gpa: std.mem.Allocator) void { - if (builtin.os.tag != .windows) return; - var arena_inst = std.heap.ArenaAllocator.init(gpa); - defer arena_inst.deinit(); - const a = arena_inst.allocator(); - - // Accept any cached version — same rule as doctor's detection. - const lib_dir = findCachedLibDir(a) orelse return; // nothing provisioned - - const env = config.globalEnviron(); - - // LABELLE_SDL2_LIB — respect a user-provided value; otherwise point at the cache. - const existing = env.getAlloc(a, "LABELLE_SDL2_LIB") catch null; - if (existing == null or existing.?.len == 0) { - setEnvW(a, "LABELLE_SDL2_LIB", lib_dir); - std.debug.print("labelle: using cached SDL2 ({s})\n", .{lib_dir}); - } - - // PATH — prepend the cache lib dir (contains SDL2.dll) for runtime, - // once. Exact segment comparison (not substring) so an unrelated - // entry that merely contains this dir's text can't suppress it. - const old_path = (env.getAlloc(a, "PATH") catch null) orelse ""; - var on_path = false; - var seg_it = std.mem.splitScalar(u8, old_path, ';'); - while (seg_it.next()) |seg| { - if (util.windowsPathEql(seg, lib_dir)) { - on_path = true; - break; - } - } - if (!on_path) { - const new_path = std.fmt.allocPrint(a, "{s};{s}", .{ lib_dir, old_path }) catch return; - setEnvW(a, "PATH", new_path); - } -} - -/// Stage the runtime `SDL2.dll` next to a freshly-built desktop game exe so -/// the build is launchable in place. On Windows the OS resolves a process's -/// implicitly-linked DLLs from the exe's OWN directory first; the build -/// installs the exe into `.labelle//zig-out/bin/` but nothing puts -/// `SDL2.dll` there, so `labelle run` (and a hand-launched `zig-out/bin` exe) -/// fails at process creation with a bare `error: FileNotFound` (cli#285). -/// -/// This complements `autoWireEnv`, which only *prepends* the SDL2 cache dir to -/// PATH — PATH is consulted by the loader AFTER the exe's own dir, and a -/// user-provided `LABELLE_SDL2_LIB` is not on PATH at all, so neither -/// reliably covers the launched exe. Copying the DLL beside the exe does. -/// -/// No-op off Windows, when no `SDL2.dll` can be located, or when it is already -/// staged. `bin_dir` is the exe's output dir (`.labelle//zig-out/bin`). -/// -/// `build_env` is the environment the compile ran with: the inherited one -/// plus the provider hooks' contributions (contract §2 `env_file`). A hook -/// that provisions SDL2 sets `LABELLE_SDL2_LIB` there, so the DLL is looked -/// up where the build linked it from, not only in this process's own -/// environment. Null means the inherited environment. -pub fn stageSdl2DllBesideExe(gpa: std.mem.Allocator, bin_dir: []const u8, build_env: ?*const std.process.Environ.Map) void { - if (builtin.os.tag != .windows) return; - var arena_inst = std.heap.ArenaAllocator.init(gpa); - defer arena_inst.deinit(); - const a = arena_inst.allocator(); - const io = config.globalIo(); - - const dst = join(a, &.{ bin_dir, "SDL2.dll" }) orelse return; - if (util.fileExists(dst)) return; // already staged — leave it in place - - const src = locateSdl2Dll(a, sdl2LibDir(a, build_env)) orelse return; // nothing to stage - copyFileVia(a, io, src, dst); - if (util.fileExists(dst)) { - std.debug.print("labelle: staged SDL2.dll next to the game exe ({s})\n", .{dst}); - } else { - // Located a source DLL but the copy did not land (permissions, AV - // lock, ...). Surface it so this failure mode is diagnosable rather - // than silently indistinguishable from "nothing to stage". - std.debug.print("labelle: found SDL2.dll at {s} but failed to copy it to {s}\n", .{ src, dst }); - } -} - -/// The `LABELLE_SDL2_LIB` the build saw: from `build_env` (which already -/// holds the inherited environment with the hooks' contributions merged on -/// top, so a contribution wins) when given, else this process's own -/// environment. Arena-allocated or borrowed from `build_env`. -pub fn sdl2LibDir(a: std.mem.Allocator, build_env: ?*const std.process.Environ.Map) ?[]const u8 { - if (build_env) |map| return map.get("LABELLE_SDL2_LIB"); - return config.globalEnviron().getAlloc(a, "LABELLE_SDL2_LIB") catch null; -} - -/// Locate a runtime `SDL2.dll`, mirroring the linker's own SDL2 resolution: -/// 1. ``/SDL2.dll (the provisioner drops the DLL in lib/) -/// 2. ``/../bin/SDL2.dll (upstream MinGW package layout) -/// 3. the labelle SDL2 cache lib dir (`findCachedLibDir`, holds SDL2.dll) -/// where `` is `LABELLE_SDL2_LIB` as the build saw it (`sdl2LibDir`). -/// Arena-allocated; returns null when none is present. -fn locateSdl2Dll(a: std.mem.Allocator, lib_dir: ?[]const u8) ?[]const u8 { - if (lib_dir) |lib| { - if (lib.len > 0) { - if (join(a, &.{ lib, "SDL2.dll" })) |p| { - if (util.fileExists(p)) return p; - } - if (join(a, &.{ lib, "..", "bin", "SDL2.dll" })) |p| { - if (util.fileExists(p)) return p; - } - } - } - if (findCachedLibDir(a)) |lib| { - if (join(a, &.{ lib, "SDL2.dll" })) |p| { - if (util.fileExists(p)) return p; - } - } - return null; -} - -extern "kernel32" fn SetEnvironmentVariableW(name: [*:0]const u16, value: ?[*:0]const u16) callconv(.winapi) i32; - -/// Set an environment variable on the current process (Windows). Children -/// spawned with an inherited environment block pick up the new value. -fn setEnvW(a: std.mem.Allocator, name: []const u8, value: []const u8) void { - const name_w = std.unicode.utf8ToUtf16LeAllocZ(a, name) catch return; - const value_w = std.unicode.utf8ToUtf16LeAllocZ(a, value) catch return; - _ = SetEnvironmentVariableW(name_w.ptr, value_w.ptr); -} - -fn provisionWindows(gpa: std.mem.Allocator) Result { - var arena_inst = std.heap.ArenaAllocator.init(gpa); - defer arena_inst.deinit(); - const a = arena_inst.allocator(); - const io = config.globalIo(); - - const cache_root = asm_cache.getCacheRoot(a) catch return .failed; - const sdl_base = join(a, &.{ cache_root, "sdl2" }) orelse return .failed; - const extracted = join(a, &.{ sdl_base, "SDL2-" ++ SDL2_VERSION }) orelse return .failed; - const mingw = join(a, &.{ extracted, "x86_64-w64-mingw32" }) orelse return .failed; - const lib_dir = join(a, &.{ mingw, "lib" }) orelse return .failed; - const importlib = join(a, &.{ lib_dir, "libSDL2.dll.a" }) orelse return .failed; - const dll_in_lib = join(a, &.{ lib_dir, "SDL2.dll" }) orelse return .failed; - - if (util.fileExists(importlib) and util.fileExists(dll_in_lib)) { - std.debug.print(" SDL2 already provisioned: {s}\n", .{lib_dir}); - return .ready; - } - - std.Io.Dir.cwd().createDirPath(io, sdl_base) catch return .failed; - - const tarball = join(a, &.{ sdl_base, "SDL2-devel-mingw.tar.gz" }) orelse return .failed; - const url = "https://github.com/libsdl-org/SDL/releases/download/release-" ++ - SDL2_VERSION ++ "/SDL2-devel-" ++ SDL2_VERSION ++ "-mingw.tar.gz"; - - std.debug.print(" downloading SDL2 {s} (MinGW dev libs)...\n {s}\n", .{ SDL2_VERSION, url }); - if (!runOk(a, &.{ "curl", "-fSL", "-o", tarball, url })) { - std.debug.print(" download failed (is curl on PATH?).\n", .{}); - return .failed; - } - - std.debug.print(" extracting...\n", .{}); - if (!runOk(a, &.{ "tar", "-xzf", tarball, "-C", sdl_base })) { - std.debug.print(" extract failed (is tar on PATH?).\n", .{}); - return .failed; - } - - // Arrange for Zig's dynamic linker: the resolver looks for SDL2.dll in the - // library search dir (not the MinGW `libSDL2.dll.a`), and would otherwise - // fall back to the static `libSDL2.a` (which drags in many Win32 libs). - // Copy the DLL into lib/ and drop the static archives. - const bin_dll = join(a, &.{ mingw, "bin", "SDL2.dll" }) orelse return .failed; - copyFileVia(a, io, bin_dll, dll_in_lib); - deleteIfPresent(io, join(a, &.{ lib_dir, "libSDL2.a" })); - deleteIfPresent(io, join(a, &.{ lib_dir, "libSDL2_test.a" })); - std.Io.Dir.cwd().deleteFile(io, tarball) catch {}; - - if (util.fileExists(importlib) and util.fileExists(dll_in_lib)) { - std.debug.print(" SDL2 ready: {s}\n", .{lib_dir}); - return .ready; - } - std.debug.print(" provisioning incomplete (unexpected package layout).\n", .{}); - return .failed; -} - -// ── Helpers ───────────────────────────────────────────────────────────── - -fn join(a: std.mem.Allocator, parts: []const []const u8) ?[]u8 { - return std.fs.path.join(a, parts) catch null; -} - -fn runOk(a: std.mem.Allocator, argv: []const []const u8) bool { - const res = util.runCmd(a, argv) catch return false; - return switch (res.term) { - .exited => |c| c == 0, - else => false, - }; -} - -/// Copy a file by reading then writing (avoids depending on a copyFile API -/// shape). SDL2.dll is a few MB; the 64 MiB limit is comfortable headroom. -fn copyFileVia(a: std.mem.Allocator, io: std.Io, src: []const u8, dst: []const u8) void { - const data = std.Io.Dir.cwd().readFileAlloc(io, src, a, .limited(64 << 20)) catch return; - std.Io.Dir.cwd().writeFile(io, .{ .sub_path = dst, .data = data }) catch {}; -} - -fn deleteIfPresent(io: std.Io, path: ?[]const u8) void { - if (path) |p| std.Io.Dir.cwd().deleteFile(io, p) catch {}; -} - -test "SDL2 DLL staging reads LABELLE_SDL2_LIB from the build's merged environment" { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); - defer arena.deinit(); - const a = arena.allocator(); - const io = config.globalIo(); - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - // A contributed SDL2 install: lib/ holds the DLL. - try tmp.dir.createDirPath(io, "contributed/lib"); - try tmp.dir.writeFile(io, .{ .sub_path = "contributed/lib/SDL2.dll", .data = "dll" }); - const lib = try tmp.dir.realPathFileAlloc(io, "contributed/lib", a); - // The build env holds the inherited environment with the contribution - // merged on top: that value is the one used. - var build_env = std.process.Environ.Map.init(a); - try build_env.put("LABELLE_SDL2_LIB", lib); - try std.testing.expectEqualStrings(lib, sdl2LibDir(a, &build_env).?); - const found = locateSdl2Dll(a, sdl2LibDir(a, &build_env)).?; - try std.testing.expectEqualStrings(try std.fs.path.join(a, &.{ lib, "SDL2.dll" }), found); - // The upstream layout: lib/../bin/SDL2.dll. - try tmp.dir.createDirPath(io, "upstream/lib"); - try tmp.dir.createDirPath(io, "upstream/bin"); - try tmp.dir.writeFile(io, .{ .sub_path = "upstream/bin/SDL2.dll", .data = "dll" }); - const upstream = try tmp.dir.realPathFileAlloc(io, "upstream/lib", a); - try build_env.put("LABELLE_SDL2_LIB", upstream); - try std.testing.expect(std.mem.endsWith(u8, locateSdl2Dll(a, sdl2LibDir(a, &build_env)).?, "SDL2.dll")); - // A build env without the variable: nothing from it (the inherited - // environment is already folded into the map, so it is not consulted). - var bare = std.process.Environ.Map.init(a); - try std.testing.expect(sdl2LibDir(a, &bare) == null); -} diff --git a/test/provider_doctor_e2e.py b/test/provider_doctor_e2e.py index 4e892b0e..c6c087d7 100644 --- a/test/provider_doctor_e2e.py +++ b/test/provider_doctor_e2e.py @@ -88,7 +88,7 @@ def invocations(package): return [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] def core_ok(result): - return "All required desktop build dependencies are present." in result.stderr + return "All required core dependencies are present." in result.stderr # One provider fails: both doctors still run, in namespace order, and # the aggregate fails whatever the core checks found. From fba736b696feabe81790d8470b9f319ce1d2c671 Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 13:11:17 -0300 Subject: [PATCH 2/3] fix(s4): the contract says staging is provider-owned; no gamepad advice for sdl - docs/provider-contract-v1.md no longer promises that the core stages SDL2.dll from the contributed LABELLE_SDL2_LIB. The core only merges the env contribution, and staging a runtime library is a provider's after build hook (the sdl2 provider's stage hook, for example). - The doctor's SDL2 advice doesn't offer `.gamepad = .none` for the sdl backend, whose renderer always links SDL2. It still does for raylib/sokol/bgfx and for an unknown backend. --- docs/provider-contract-v1.md | 2 +- src/cli/doctor.zig | 36 +++++++++++++++++++++++++++--------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/provider-contract-v1.md b/docs/provider-contract-v1.md index 43b1e632..9eb4abc5 100644 --- a/docs/provider-contract-v1.md +++ b/docs/provider-contract-v1.md @@ -122,7 +122,7 @@ A `before generate`, `after generate` or `before build` hook receives `env_file` **Freshness.** The environment is built for each build from the inherited environment plus the contributions of the hooks that ran in that build; a watched rebuild starts over. A hook a replan removed leaves nothing behind. A watched rebuild that fails, at any stage, keeps the environment of the last successful build (the one being served, and the one the session's `after run` hooks get); only a rebuild that succeeds replaces it. -**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build ` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The runtime `SDL2.dll` the core stages beside a desktop exe is looked up through `LABELLE_SDL2_LIB` as the compile saw it, so a contributed value is honoured. +**Scope.** The merged environment reaches every later zig invocation and hook process of the same command: the generation-time fingerprint pass (`zig build --list-steps`, which configures the generated build and so is the first toolchain consumer: a `before generate` contribution reaches it, an `after generate` one does not), the core `zig build` compile, and every later hook and replacement (`after generate`, `before`/`replace`/`after build`, `bundle` and `run` hooks). It never reaches the build of a provider's own tool (`zig build ` in the provider's package), which runs on the plain inherited environment plus the CLI's cache variables, so a provider cannot change how other providers' tools are built. The game the core `labelle run` launches keeps its own environment (the inherited one plus the run options); a provider that must reach the game uses a `run` replacement. The core stages no runtime library beside a built exe: it only merges the contribution into the environment above. Staging one (a DLL next to a desktop exe, say) is a provider's job, in an `after build` hook, which sees the merged environment as the compile did; the `sdl2` provider's `stage` hook copies `SDL2.dll` this way (cli#471 S4). **No silent bypass.** A build path that cannot carry a provider's contribution or its optimize default refuses before any hook, generation or build, naming the hook or package. `--docker` builds in a container the contributions never reach, so a `build`, `run` or `bundle` (any command that reaches the container build) whose plan has a hook that may contribute (one in a contributing slot whose provider negotiates `1.3.0` or newer) is refused; `labelle generate --docker` is not, since it stops after generation and its host-side fingerprint pass gets the contributions, and neither is a build the target owner replaces, since its `replace build` hook gets them like every hook: `--docker doesn't carry provider environment contributions; build without --docker`. (`--docker` does pass the effective optimize mode as `-Doptimize`.) The legacy `labelle ios` subcommand runs its own `zig build` after generation, with neither, so it is refused when the plan has such a hook or the target's owner declares an optimize default; `labelle build --platform=ios` carries both. diff --git a/src/cli/doctor.zig b/src/cli/doctor.zig index fdec3a57..ba591402 100644 --- a/src/cli/doctor.zig +++ b/src/cli/doctor.zig @@ -316,19 +316,29 @@ const Cfg = struct { /// source unless `.gamepad = .none`. An unknown backend (`describe` gave no /// answer) is judged by the gamepad alone, as the assembler's default /// backend links SDL2 for it. Outside a project there is nothing to judge. -/// Advice, never a failure: SDL2 may be installed by other means. +/// Advice, never a failure: SDL2 may be installed by other means. The +/// `.gamepad = .none` way out is offered only where SDL2 comes in for the +/// gamepad alone: the `sdl` renderer links it whatever the gamepad says. fn sdl2Hint(cfg: Cfg, backend: ?[]const u8) ?[]const u8 { - if (!cfg.found or cfg.sdl2_provider or !linksSdl2(backend, cfg.gamepad_off)) return null; - return "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB, or use `.gamepad = .none`."; + if (!cfg.found or cfg.sdl2_provider) return null; + return switch (sdl2Use(backend, cfg.gamepad_off)) { + .none => null, + .renderer => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, or set LABELLE_SDL2_LIB.", + .gamepad => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB, or use `.gamepad = .none`.", + }; } -fn linksSdl2(backend: ?[]const u8, gamepad_off: bool) bool { - const name = backend orelse return !gamepad_off; - if (std.mem.eql(u8, name, "sdl")) return true; - for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |pad| { - if (std.mem.eql(u8, name, pad)) return !gamepad_off; +/// Why a desktop build of `backend` links SDL2, if it does. +const Sdl2Use = enum { none, renderer, gamepad }; + +fn sdl2Use(backend: ?[]const u8, gamepad_off: bool) Sdl2Use { + const pad: Sdl2Use = if (gamepad_off) .none else .gamepad; + const name = backend orelse return pad; + if (std.mem.eql(u8, name, "sdl")) return .renderer; + for ([_][]const u8{ "raylib", "sokol", "bgfx" }) |pad_backend| { + if (std.mem.eql(u8, name, pad_backend)) return pad; } - return false; + return .none; } test "doctor: the SDL2 hint follows the old rule and goes quiet with the provider (cli#471 S4)" { @@ -345,6 +355,14 @@ test "doctor: the SDL2 hint follows the old rule and goes quiet with the provide // Unknown backend: the gamepad decides. try std.testing.expect(sdl2Hint(plain, null) != null); try std.testing.expect(sdl2Hint(.{ .found = true, .gamepad_off = true }, null) == null); + // Only the gamepad cases offer the gamepad opt-out: the renderer links + // SDL2 whatever the gamepad says. + const gamepad_way_out = "`.gamepad = .none`"; + try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "sdl").?, gamepad_way_out) == null); + try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(.{ .found = true, .gamepad_off = true }, "sdl").?, gamepad_way_out) == null); + try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "sdl").?, "set LABELLE_") != null); + try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "raylib").?, gamepad_way_out) != null); + try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, null).?, gamepad_way_out) != null); // The provider listed, or no project: no hint. try std.testing.expect(sdl2Hint(.{ .found = true, .sdl2_provider = true }, "sdl") == null); try std.testing.expect(sdl2Hint(.{}, "sdl") == null); From 64b8a7cc0d9083f516f558c2a6df831fe3cd7b75 Mon Sep 17 00:00:00 2001 From: Daniel Murta Date: Tue, 29 Sep 2026 13:31:07 -0300 Subject: [PATCH 3/3] fix(doctor): LABELLE_SDL2_LIB alone doesn't make a Windows game run The SDL2 advice now says that with LABELLE_SDL2_LIB, SDL2.dll must also be on PATH or beside the exe on Windows. The loader never reads that variable, and the core no longer copies the DLL. The sdl2 provider stays the first recommendation. --- src/cli/doctor.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/cli/doctor.zig b/src/cli/doctor.zig index ba591402..ae0d605d 100644 --- a/src/cli/doctor.zig +++ b/src/cli/doctor.zig @@ -319,12 +319,15 @@ const Cfg = struct { /// Advice, never a failure: SDL2 may be installed by other means. The /// `.gamepad = .none` way out is offered only where SDL2 comes in for the /// gamepad alone: the `sdl` renderer links it whatever the gamepad says. +/// `LABELLE_SDL2_LIB` only lets the build find SDL2; the Windows loader +/// never reads it, and the core no longer stages the DLL, so that way out +/// also names the runtime DLL (the provider's `stage` hook copies it). fn sdl2Hint(cfg: Cfg, backend: ?[]const u8) ?[]const u8 { if (!cfg.found or cfg.sdl2_provider) return null; return switch (sdl2Use(backend, cfg.gamepad_off)) { .none => null, - .renderer => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, or set LABELLE_SDL2_LIB.", - .gamepad => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB, or use `.gamepad = .none`.", + .renderer => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, or set LABELLE_SDL2_LIB and, on Windows, put SDL2.dll on PATH or beside the exe.", + .gamepad => "SDL2: this build links SDL2, which the CLI no longer provisions: add the `sdl2` provider (labelle-sdl) to .plugins, set LABELLE_SDL2_LIB and, on Windows, put SDL2.dll on PATH or beside the exe, or use `.gamepad = .none`.", }; } @@ -360,7 +363,15 @@ test "doctor: the SDL2 hint follows the old rule and goes quiet with the provide const gamepad_way_out = "`.gamepad = .none`"; try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "sdl").?, gamepad_way_out) == null); try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(.{ .found = true, .gamepad_off = true }, "sdl").?, gamepad_way_out) == null); - try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "sdl").?, "set LABELLE_") != null); + // The provider comes first; the variable alone is not enough on Windows, + // so both wordings name the runtime DLL. + for ([_]?[]const u8{ "sdl", "raylib", null }) |name| { + const hint = sdl2Hint(plain, name).?; + const provider_at = std.mem.indexOf(u8, hint, "`sdl2` provider").?; + const variable_at = std.mem.indexOf(u8, hint, "set LABELLE_").?; + try std.testing.expect(provider_at < variable_at); + try std.testing.expect(std.mem.indexOf(u8, hint, "SDL2.dll on PATH or beside the exe") != null); + } try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, "raylib").?, gamepad_way_out) != null); try std.testing.expect(std.mem.indexOf(u8, sdl2Hint(plain, null).?, gamepad_way_out) != null); // The provider listed, or no project: no hint.