diff --git a/docs/provider-contract-v1.md b/docs/provider-contract-v1.md index 43b1e63..9eb4abc 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/agnostic_guard_test.zig b/src/agnostic_guard_test.zig index 82e9688..7b5cb10 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 40c7268..fc70777 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 f8bbccb..79c6b54 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 393d02a..ae0d605 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,79 @@ 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. 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 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`.", + }; +} - fn libRequired(self: SdlNeeds) bool { - return !self.unknown or self.gamepad; - } -}; +/// Why a desktop build of `backend` links SDL2, if it does. +const Sdl2Use = enum { none, renderer, gamepad }; -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 }; +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 .none; } -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); + // 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); + // 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. + 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 +388,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 +485,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 fe515a2..c5324fa 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 052d532..8c074bc 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 5f44017..2185403 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 2a2f333..c111bce 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 4b405c8..0000000 --- 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 4e892b0..c6c087d 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.