From dfeacc36b06892d116864e9b72f609ce3e54d4f0 Mon Sep 17 00:00:00 2001 From: 1jamie Date: Thu, 13 Aug 2026 18:18:59 -0500 Subject: [PATCH] - adding ability to specify gen specific deps in manifest system. - fixed issue with wayland users drag and drop causing CTD --- .gitignore | 3 +- CONTRIBUTING-mods.md | 3 +- docs/mod-api-gen2-compat.md | 9 +-- docs/modding.md | 70 +++++++++++++++++++ docs/preparing-your-mod-for-gen2.md | 19 ++++++ scripts/build.sh | 3 + scripts/linux-arm64/build_appimage.sh | 6 ++ scripts/run.sh | 6 ++ src/mods/LauncherMods.lua | 84 ++++++++++++----------- src/mods/Loader.lua | 96 ++++++++++++++++----------- src/mods/Manifest.lua | 26 +++++++- src/mods/ModTargets.lua | 35 ++++++++++ tests/mod_manifest_tests.lua | 45 +++++++++++-- tools/modkit.py | 15 +++-- 14 files changed, 324 insertions(+), 96 deletions(-) diff --git a/.gitignore b/.gitignore index cbf162dd..69581d85 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,9 @@ data/generated/ assets/generated/ -# LÖVE packages +# LÖVE packages & archives *.love +*.zip # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) save/ diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index ccd55ea1..86d5a57f 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -245,7 +245,8 @@ real Gold boot. ### 5. `mod.card` The manifest is the *engine's* contract: identity, load order, dependencies, -permissions, profile. The card is the *human-facing* one: who made this, +permissions, profile (see [Manifest specification](docs/modding.md#manifest-specification-manifestjson)). +The card is the *human-facing* one: who made this, what it changes, what it does not do yet. It is never read by the loader's merge — only by tooling and the manager's detail pane — so an absent or malformed card can never break a load. diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index fbb61d6e..ca93ea76 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -118,10 +118,11 @@ Gen 2 games no longer loads on Red, Blue or Yellow. Say `["all"]` or list both generations if you want both. Two riders. **A hard dependency that does not run here takes the dependent down -with it**, as a skip rather than a failure and carrying the dependency's own -wording (`depends on X, which does not run here (For Blue, not Red)`), so the -whole chain has to cover the same games. And **the claim is yours, not the last -word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod +with it** (unless scoped to specific games, e.g. +`dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a +failure and carrying the dependency's own wording (`depends on X, which does not +run here (For Blue, not Red)`), so the whole chain has to cover the same games. +And **the claim is yours, not the last word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod whose author never opted in, which is the only route for a mod written before the field existed. The override is per game -- `options.modsGen2[id]` is a `{ [version] = true }` table, so forcing a mod onto Red does not force it onto diff --git a/docs/modding.md b/docs/modding.md index e3a45fa2..d7021a64 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua luajit tools/gen_registry_docs.lua ../gen1recomp.wiki ``` +## Manifest specification (`manifest.json`) + +Every mod contains a root `manifest.json` defining its metadata, supported games, and dependencies for the engine loader. + +```json +{ + "id": "my_mod", + "name": "My Cool Mod", + "version": "1.0.0", + "api": 2, + "entry": "main.lua", + "profile": "content", + "category": "GAMEPLAY", + "games": ["gen1", "gen2"], + "game_version": ">=0.0.0-dev <2.0.0", + "priority": 100, + "dependencies": [ + "helper_lib@^1.0.0", + { "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" } + ], + "optional_dependencies": [ + "gen1_modern_ui" + ], + "conflicts": [], + "permissions": ["engine_internals"], + "description": "A brief description of the mod.", + "github": "author/my_mod" +} +``` + +### Manifest Fields + +| Field | Type | Description | +| --- | --- | --- | +| `id` | `string` | Unique identifier (lowercase alphanumeric, underscores, hyphens). | +| `name` | `string` | Human-readable title shown in launcher and manager. | +| `version` | `string` | Semantic version string (e.g. `"1.0.0"`). | +| `api` | `integer` | Mod API level (`2` for current standard, `1` for legacy). | +| `entry` | `string` | Entry Lua file path relative to mod root (usually `"main.lua"`). | +| `profile` | `string` | Mod profile: `"content"`, `"overhaul"`, or `"total_conversion"`. | +| `category` | `string` | Categorization chip (e.g. `"GAMEPLAY"`, `"CONTENT"`, `"UI"`, `"AUDIO"`). | +| `games` | `array` | Supported game versions: `["gen1"]`, `["gen2"]`, `["red"]`, `["blue"]`, `["yellow"]`, `["gold"]`, or `["all"]`. | +| `game_version`| `string` | Semver range of required engine version (e.g. `">=0.0.0-dev <2.0.0"`). | +| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). | +| `dependencies` | `array` | Hard required dependencies. A mod will not load if a required dependency is missing or disabled for the active game. | +| `optional_dependencies` | `array` | Soft dependencies. Guarantees that if the target mod is present and active, it loads *before* this mod without blocking load if absent. | +| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | +| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | +| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | + +### Declaring Dependencies & Scoping + +Dependencies in `dependencies` and `optional_dependencies` can be declared in several formats: + +1. **Simple string**: `"mod_id"` +2. **Version-pinned string**: `"mod_id@^1.2.0"` +3. **Repository-hinted string**: `"mod_id#owner/repo"` or `"mod_id@^1.2.0#owner/repo"` +4. **Structured object**: + ```json + { + "id": "mod_id", + "range": "^1.2.0", + "games": ["gen2"], + "github": "owner/repo" + } + ``` + +#### Version-Scoped Dependencies +When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency can specify `"games": ["gen2"]` to indicate it is only required when booting Gen 2. When booting Gen 1, the engine will ignore the dependency, preventing unnecessary boot blocks on games that do not need it. + ## Mods and Gold (Gen 2) The mod API is one API across both generations, but Gold runs its own battle diff --git a/docs/preparing-your-mod-for-gen2.md b/docs/preparing-your-mod-for-gen2.md index c64ae33b..eb6500cf 100644 --- a/docs/preparing-your-mod-for-gen2.md +++ b/docs/preparing-your-mod-for-gen2.md @@ -258,6 +258,25 @@ row on the detail screen. The launcher's dependency verdict asks the same question of your dependencies: a mod whose hard dependency does not run on the selected game reads `Needs (not for Gold)` rather than `Ready`. +### Scoping dependencies per game / generation + +For mods targeting multiple generations (`"games": ["gen1", "gen2"]`), a hard +dependency can be scoped to specific games so that it is only enforced when +booting those games: + +```json +"dependencies": [ + { "id": "pokegear_cards", "games": ["gen2"], "range": "^1.0.0", "github": "1jamie/pokegear_cards" } +] +``` + +When booting a Gen 1 game (Red, Blue, Yellow), the engine loader sees that +`pokegear_cards` is scoped to `"gen2"` and will not skip or block the parent mod +on Gen 1. When booting Gen 2 (Gold), `pokegear_cards` is strictly required. + +For conditional integrations where the dependency is optional across the board, +`optional_dependencies` remains the standard pattern. + ### One limit worth knowing **Enablement is per game.** The overlay diff --git a/scripts/build.sh b/scripts/build.sh index 5c6f8bc5..761fc375 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -400,6 +400,9 @@ EOF grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ || fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" + sed -i '' 's|^exec "\$APPDIR/bin/love"|if [ -n "$WAYLAND_DISPLAY" ] \&\& [ -z "$SDL_VIDEODRIVER" ]; then export SDL_VIDEODRIVER=x11; fi\ +exec "$APPDIR/bin/love"|' "$appdir/AppRun" + # Match the upstream image's compression (gzip, 128K blocks) so the # bundled runtime can read it. local sfs_out="$WORK/game.squashfs" diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index ac74892e..7a1da335 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then fi export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH" +# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors; +# default to X11/XWayland when available to ensure rock-solid drag-drop stability. +if [ -n "\$WAYLAND_DISPLAY" ] && [ -z "\$SDL_VIDEODRIVER" ]; then + export SDL_VIDEODRIVER=x11 +fi + exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@" EOF chmod +x "$APPDIR/AppRun" diff --git a/scripts/run.sh b/scripts/run.sh index f0110d21..49276927 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -35,4 +35,10 @@ find_love() { LOVE_BIN="$(find_love)" \ || fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" +# SDL2 on Wayland crashes during desktop drag-and-drop in certain compositors; +# default to X11/XWayland when available to ensure rock-solid drag-drop stability. +if [ -n "${WAYLAND_DISPLAY:-}" ] && [ -z "${SDL_VIDEODRIVER:-}" ]; then + export SDL_VIDEODRIVER=x11 +fi + exec "$LOVE_BIN" "$ROOT" "$@" diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index d0d47bc9..4f7f2ba8 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -87,21 +87,23 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor) -- resolveToggle would cascade-enable a merely-disabled dep rather than flag -- it, so the disabled case is judged straight off the manifest here. for _, spec in ipairs(m.dependencySpecs or {}) do - local dep = mods[spec.id] - if not dep then - return "warn", "Needs " .. spec.id .. " (not installed)" - elseif not enabledSet[spec.id] then - return "warn", "Needs " .. spec.id .. " (disabled)" - -- installed and on, but not for THIS game: the loader skips the - -- dependency and the skip is contagious (Loader:_enforceDependencies), - -- so a mod that runs everywhere still does not run here - elseif version - and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then - return "warn", "Needs " .. spec.id .. " (not for " - .. ModTargets.gameLabel(version) .. ")" - elseif spec.range - and not Semver.satisfies(dep.version, spec.range) then - return "warn", "Needs " .. spec.id .. " " .. spec.range + if ModTargets.specApplies(spec, version) then + local dep = mods[spec.id] + if not dep then + return "warn", "Needs " .. spec.id .. " (not installed)" + elseif not enabledSet[spec.id] then + return "warn", "Needs " .. spec.id .. " (disabled)" + -- installed and on, but not for THIS game: the loader skips the + -- dependency and the skip is contagious (Loader:_enforceDependencies), + -- so a mod that runs everywhere still does not run here + elseif version + and not ModTargets.runsHere(dep, version, nil, forcedFor(spec.id)) then + return "warn", "Needs " .. spec.id .. " (not for " + .. ModTargets.gameLabel(version) .. ")" + elseif spec.range + and not Semver.satisfies(dep.version, spec.range) then + return "warn", "Needs " .. spec.id .. " " .. spec.range + end end end return "ok", "Ready" @@ -168,33 +170,35 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan -- 1. Hard Dependencies (dependencySpecs) if type(manifest.dependencySpecs) == "table" then for _, spec in ipairs(manifest.dependencySpecs) do - local depId = spec.id - local range = spec.range - local installedDep = installedMap[depId] - local status = "satisfied" - local installedVersion = installedDep and installedDep.version or nil + if not version or ModTargets.specApplies(spec, version) then + local depId = spec.id + local range = spec.range + local installedDep = installedMap[depId] + local status = "satisfied" + local installedVersion = installedDep and installedDep.version or nil - if not installedDep then - status = "missing" - hasIssues = true - elseif range and not Semver.satisfies(installedDep.version, range) then - status = "incompatible" - hasIssues = true + if not installedDep then + status = "missing" + hasIssues = true + elseif range and not Semver.satisfies(installedDep.version, range) then + status = "incompatible" + hasIssues = true + end + + local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) + local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil + + depsResult[#depsResult + 1] = { + id = depId, + name = (installedDep and installedDep.name) or depId, + range = range, + status = status, + kind = "dependency", + installedVersion = installedVersion, + github = ghRepo, + safeUrl = safeUrl, + } end - - local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep) - local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil - - depsResult[#depsResult + 1] = { - id = depId, - name = (installedDep and installedDep.name) or depId, - range = range, - status = status, - kind = "dependency", - installedVersion = installedVersion, - github = ghRepo, - safeUrl = safeUrl, - } end end diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index f1b1561c..e36d127d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -561,42 +561,46 @@ end -- hard dependencies must exist, be enabled, have survived, and satisfy their -- range; run to a fixpoint so failures propagate to dependents transitively function Loader:_enforceDependencies() + local targetVersion = self:_targetVersion() + local generation = self.generation local changed = true while changed do changed = false for _, id in ipairs(orderedIds(self.mods, isActive)) do local mod = self.mods[id] for _, spec in ipairs(mod.manifest.dependencySpecs) do - local dep = self.mods[spec.id] - local reason, skip - if not dep then - reason = "missing dependency: " .. spec.id - elseif not dep.enabled then - reason = ("dependency %s is disabled"):format(spec.id) - elseif dep.state == "wrong_generation" then - -- the gate's skip is contagious as a SKIP, not as a failure: the - -- dependency has no bug to report and neither does this mod, so - -- nothing here lands on the boot error list - skip = true - -- carry the dependency's own reason: it names the game or the - -- missing gen2compat, and a guess here would name the wrong one - reason = ("depends on %s, which does not run here (%s)") - :format(spec.id, dep.skipReason or "not made for this game") - elseif dep.failed then - reason = ("dependency %s failed to load"):format(spec.id) - elseif spec.range - and not Semver.satisfies(dep.manifest.version, spec.range) then - reason = ("needs %s@%s, found %s") - :format(spec.id, spec.range, dep.manifest.version) - end - if reason then - if skip then - self:_skip(mod, "wrong_generation", reason) - else - self:_fail(mod, "blocked_dependency", reason) + if ModTargets.specApplies(spec, targetVersion, generation) then + local dep = self.mods[spec.id] + local reason, skip + if not dep then + reason = "missing dependency: " .. spec.id + elseif not dep.enabled then + reason = ("dependency %s is disabled"):format(spec.id) + elseif dep.state == "wrong_generation" then + -- the gate's skip is contagious as a SKIP, not as a failure: the + -- dependency has no bug to report and neither does this mod, so + -- nothing here lands on the boot error list + skip = true + -- carry the dependency's own reason: it names the game or the + -- missing gen2compat, and a guess here would name the wrong one + reason = ("depends on %s, which does not run here (%s)") + :format(spec.id, dep.skipReason or "not made for this game") + elseif dep.failed then + reason = ("dependency %s failed to load"):format(spec.id) + elseif spec.range + and not Semver.satisfies(dep.manifest.version, spec.range) then + reason = ("needs %s@%s, found %s") + :format(spec.id, spec.range, dep.manifest.version) + end + if reason then + if skip then + self:_skip(mod, "wrong_generation", reason) + else + self:_fail(mod, "blocked_dependency", reason) + end + changed = true + break end - changed = true - break end end end @@ -606,6 +610,8 @@ end -- Tarjan SCC over the hard-dependency graph: only a cycle's own members -- fail, so an unrelated mod beside a cycle still loads function Loader:_failCycles() + local targetVersion = self:_targetVersion() + local generation = self.generation local mods = self.mods local counter, stack, onStack, index, low = 0, {}, {}, {}, {} local cycles = {} @@ -616,14 +622,16 @@ function Loader:_failCycles() onStack[id] = true local selfEdge = false for _, spec in ipairs(mods[id].manifest.dependencySpecs) do - local dep = mods[spec.id] - if spec.id == id then selfEdge = true end - if dep and isActive(dep) and spec.id ~= id then - if not index[spec.id] then - connect(spec.id) - if low[spec.id] < low[id] then low[id] = low[spec.id] end - elseif onStack[spec.id] and index[spec.id] < low[id] then - low[id] = index[spec.id] + if ModTargets.specApplies(spec, targetVersion, generation) then + local dep = mods[spec.id] + if spec.id == id then selfEdge = true end + if dep and isActive(dep) and spec.id ~= id then + if not index[spec.id] then + connect(spec.id) + if low[spec.id] < low[id] then low[id] = low[spec.id] end + elseif onStack[spec.id] and index[spec.id] < low[id] then + low[id] = index[spec.id] + end end end end @@ -674,6 +682,8 @@ end -- Kahn over the surviving graph with the ready set kept in (priority, id) -- order, so dependencies come first and the rest matches the v1 contract function Loader:_order() + local targetVersion = self:_targetVersion() + local generation = self.generation local pending, indegree, dependents = {}, {}, {} for _, id in ipairs(orderedIds(self.mods, isActive)) do pending[id], indegree[id] = true, 0 @@ -686,9 +696,17 @@ function Loader:_order() dependents[depId][#dependents[depId] + 1] = id indegree[id] = indegree[id] + 1 end - for _, spec in ipairs(manifest.dependencySpecs) do edge(spec.id) end + for _, spec in ipairs(manifest.dependencySpecs) do + if ModTargets.specApplies(spec, targetVersion, generation) then + edge(spec.id) + end + end -- optional dependencies order without requiring anything - for _, spec in ipairs(manifest.optionalSpecs) do edge(spec.id) end + for _, spec in ipairs(manifest.optionalSpecs) do + if ModTargets.specApplies(spec, targetVersion, generation) then + edge(spec.id) + end + end end local ordered = {} local function nextId() diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index e142bff2..3e601c5d 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -58,11 +58,13 @@ local function parseSpecs(list, field, sources) local specs = {} sources = type(sources) == "table" and sources or {} for _, entry in ipairs(list) do - local id, range, ghHint + local id, range, ghHint, gamesRaw, gameVersion if type(entry) == "table" then id = entry.id range = entry.range or entry.version ghHint = entry.github or entry.repo + gamesRaw = entry.games or entry.game + gameVersion = entry.game_version elseif type(entry) == "string" and entry ~= "" then local main, hashRepo = entry:match("^([^#]+)#(.*)$") if main then @@ -82,6 +84,20 @@ local function parseSpecs(list, field, sources) local ok, err = Semver.validRange(range) assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err))) + if gameVersion then + local okV, errV = Semver.validRange(gameVersion) + assert(okV, ("malformed %s game_version range in %q: %s"):format(field, tostring(entry), tostring(errV))) + end + + local parsedGames = nil + if gamesRaw ~= nil then + if type(gamesRaw) == "string" then gamesRaw = { gamesRaw } end + assert(type(gamesRaw) == "table", "dependency games must be a string or table") + local normalized, unknown = ModTargets.normalize(gamesRaw) + assert(#unknown == 0, ("unknown game in %s: %s"):format(field, table.concat(unknown, ", "))) + parsedGames = normalized + end + local parsedGh = nil local rawGh = ghHint or sources[id] if rawGh then @@ -89,7 +105,13 @@ local function parseSpecs(list, field, sources) if okGh and cleanGh then parsedGh = cleanGh end end - specs[#specs + 1] = { id = id, range = range, github = parsedGh } + specs[#specs + 1] = { + id = id, + range = range, + github = parsedGh, + games = parsedGames, + game_version = gameVersion, + } end return specs end diff --git a/src/mods/ModTargets.lua b/src/mods/ModTargets.lua index 58c7d05e..b367c0cb 100644 --- a/src/mods/ModTargets.lua +++ b/src/mods/ModTargets.lua @@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version) ModTargets.gameLabel(version)) end +-- Does a dependency spec apply to this game / version? +-- If spec.games is provided, it must match version / generation. +-- If spec.game_version is provided, the engine version must satisfy it. +function ModTargets.specApplies(spec, version, generation) + if not spec then return false end + if type(spec.games) == "table" and #spec.games > 0 then + if version or generation then + local match = false + for _, id in ipairs(spec.games) do + if version and id == version then + match = true + break + end + if generation and GameVersion.generation(id) == generation then + match = true + break + end + end + if not match then return false end + end + end + if spec.game_version then + local ok = pcall(function() + local Semver = require("src.mods.Semver") + local Version = require("src.core.Version") + if Version and Version.engine and Version.engine:match("^0%.0%.0%-") == nil then + return Semver.satisfies(Version.engine, spec.game_version) + end + return true + end) + if not ok then return false end + end + return true +end + return ModTargets diff --git a/tests/mod_manifest_tests.lua b/tests/mod_manifest_tests.lua index cf2e5283..9b0d9948 100644 --- a/tests/mod_manifest_tests.lua +++ b/tests/mod_manifest_tests.lua @@ -552,11 +552,46 @@ local installedColorlib = Manifest.validate({ version = "1.0.0", entry = "main.lua", }, "mods/colorlib") -local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib }) -check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues") -check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict", - "incompatible mod is flagged as status conflict") -check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict") +-- ------- scoped dependency tests +local Json = require("src.link.Json") +local scopedDepManifest = Manifest.validate({ + id = "dual_gen_mod", + name = "Dual Gen Mod", + version = "1.0.0", + entry = "main.lua", + games = { "gen1", "gen2" }, + dependencies = { + { id = "gen2_only_dep", games = { "gen2" }, version = "^1.0.0" } + }, +}, "mods/dual_gen_mod") +check(#scopedDepManifest.dependencySpecs == 1, "scoped dependency parsed") +check(scopedDepManifest.dependencySpecs[1].games ~= nil, "dependency carries games list") + +local dualGenFiles = { + ["mods/dual_gen_mod/manifest.json"] = Json.encode({ + id = "dual_gen_mod", + name = "Dual Gen Mod", + version = "1.0.0", + entry = "main.lua", + games = { "gen1", "gen2" }, + dependencies = { + { id = "gen2_only_dep", games = { "gen2" } } + }, + }), + ["mods/dual_gen_mod/main.lua"] = [[ +return function(mod) + mod.content.pokemon:register("DUAL_MON", { hp = 100 }) +end +]], +} +local gen1Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 1 }) +check(gen1Loader:load({}) == true, "dual gen mod loads on Gen 1 when Gen 2 dep is absent") +check(gen1Loader.content.pokemon:get("DUAL_MON") ~= nil, "dual gen mod executed on Gen 1") + +local gen2Loader = Loader.new({ fs = memfs(dualGenFiles), generation = 2 }) +check(gen2Loader:load({}) == false, "loader returns false on Gen 2 when missing required Gen 2 dep") +check(gen2Loader.content.pokemon:get("DUAL_MON") == nil, "dual gen mod is blocked on Gen 2 when missing required Gen 2 dep") +check(#gen2Loader:status().errors > 0, "missing dependency error logged on Gen 2") Runtime.install(savedEvents, savedHooks) diff --git a/tools/modkit.py b/tools/modkit.py index b52138e2..42fc5acd 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named): "manifest.json")) deps = manifest.get("dependencies") or [] for dep in deps if isinstance(deps, list) else []: - if not isinstance(dep, str): + dep_id = dep if isinstance(dep, str) else dep.get("id") if isinstance(dep, dict) else None + if not dep_id: continue - found = named.get(dep) or find_mod_by_id(repo, mod_dir, dep) + if isinstance(dep, dict) and "games" in dep: + g_list = dep.get("games") + if isinstance(g_list, str): + g_list = [g_list] + if isinstance(g_list, list) and not any(g in ["gen2", "gold", "silver", "crystal", "all"] for g in g_list): + continue + found = named.get(dep_id) or find_mod_by_id(repo, mod_dir, dep_id) if found is None: notes.append("unresolved: dependency %s is not installed beside " - "this mod, so its games list could not be read" % dep) + "this mod, so its games list could not be read" % dep_id) elif not declares_gen2(repo, found): findings.append(Finding( "MK401", "error", - f"depends on {dep}, which claims no Gen 2 game; the " + f"depends on {dep_id}, which claims no Gen 2 game; the " f"loader disables a mod whose dependency a Gen 2 boot skipped", "manifest.json")) return findings, notes