- adding ability to specify gen specific deps in manifest system.

- fixed issue with wayland users drag and drop causing CTD
This commit is contained in:
1jamie
2026-08-13 18:18:59 -05:00
parent 35d44efb8b
commit dfeacc36b0
14 changed files with 324 additions and 96 deletions
+2 -1
View File
@@ -3,8 +3,9 @@
data/generated/ data/generated/
assets/generated/ assets/generated/
# LÖVE packages # LÖVE packages & archives
*.love *.love
*.zip
# Local saves (LÖVE writes to its save dir, but keep the repo clean anyway) # Local saves (LÖVE writes to its save dir, but keep the repo clean anyway)
save/ save/
+2 -1
View File
@@ -245,7 +245,8 @@ real Gold boot.
### 5. `mod.card` ### 5. `mod.card`
The manifest is the *engine's* contract: identity, load order, dependencies, 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 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 merge — only by tooling and the manager's detail pane — so an absent or
malformed card can never break a load. malformed card can never break a load.
+5 -4
View File
@@ -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. generations if you want both.
Two riders. **A hard dependency that does not run here takes the dependent down 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 with it** (unless scoped to specific games, e.g.
wording (`depends on X, which does not run here (For Blue, not Red)`), so the `dependencies: [{ id = "x", games = ["gen2"] }]`), as a skip rather than a
whole chain has to cover the same games. And **the claim is yours, not the last failure and carrying the dependency's own wording (`depends on X, which does not
word**: it is the manager's `TRY HERE ANYWAY` row that lets a player run a mod 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 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 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 `{ [version] = true }` table, so forcing a mod onto Red does not force it onto
+70
View File
@@ -21,6 +21,76 @@ luajit tools/gen_registry_docs.lua
luajit tools/gen_registry_docs.lua ../gen1recomp.wiki 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) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
+19
View File
@@ -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 question of your dependencies: a mod whose hard dependency does not run on the
selected game reads `Needs <id> (not for Gold)` rather than `Ready`. selected game reads `Needs <id> (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 ### One limit worth knowing
**Enablement is per game.** The overlay **Enablement is per game.** The overlay
+3
View File
@@ -400,6 +400,9 @@ EOF
grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \ grep -q '^FUSE_PATH="\$APPDIR/game.love"$' "$appdir/AppRun" \
|| fail "failed to enable FUSE_PATH in AppRun (upstream AppRun changed?)" || 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 # Match the upstream image's compression (gzip, 128K blocks) so the
# bundled runtime can read it. # bundled runtime can read it.
local sfs_out="$WORK/game.squashfs" local sfs_out="$WORK/game.squashfs"
+6
View File
@@ -405,6 +405,12 @@ if [ -z "\$LUA_CPATH" ]; then
fi fi
export LUA_CPATH="\$APPDIR/lib/lua/5.1/?.so;\$LUA_CPATH" 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" "\$@" exec "\$APPDIR/bin/love" --fused "\$APPDIR/game.love" "\$@"
EOF EOF
chmod +x "$APPDIR/AppRun" chmod +x "$APPDIR/AppRun"
+6
View File
@@ -35,4 +35,10 @@ find_love() {
LOVE_BIN="$(find_love)" \ LOVE_BIN="$(find_love)" \
|| fail "LÖVE not found, run scripts/setup.sh (or install from https://love2d.org)" || 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" "$@" exec "$LOVE_BIN" "$ROOT" "$@"
+4
View File
@@ -87,6 +87,7 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
-- resolveToggle would cascade-enable a merely-disabled dep rather than flag -- resolveToggle would cascade-enable a merely-disabled dep rather than flag
-- it, so the disabled case is judged straight off the manifest here. -- it, so the disabled case is judged straight off the manifest here.
for _, spec in ipairs(m.dependencySpecs or {}) do for _, spec in ipairs(m.dependencySpecs or {}) do
if ModTargets.specApplies(spec, version) then
local dep = mods[spec.id] local dep = mods[spec.id]
if not dep then if not dep then
return "warn", "Needs " .. spec.id .. " (not installed)" return "warn", "Needs " .. spec.id .. " (not installed)"
@@ -104,6 +105,7 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
return "warn", "Needs " .. spec.id .. " " .. spec.range return "warn", "Needs " .. spec.id .. " " .. spec.range
end end
end end
end
return "ok", "Ready" return "ok", "Ready"
end end
@@ -168,6 +170,7 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
-- 1. Hard Dependencies (dependencySpecs) -- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do for _, spec in ipairs(manifest.dependencySpecs) do
if not version or ModTargets.specApplies(spec, version) then
local depId = spec.id local depId = spec.id
local range = spec.range local range = spec.range
local installedDep = installedMap[depId] local installedDep = installedMap[depId]
@@ -197,6 +200,7 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
} }
end end
end end
end
-- 2. Conflicts / Incompatible Mods (conflictSpecs) -- 2. Conflicts / Incompatible Mods (conflictSpecs)
local conflictIdsSeen = {} local conflictIdsSeen = {}
+20 -2
View File
@@ -561,12 +561,15 @@ end
-- hard dependencies must exist, be enabled, have survived, and satisfy their -- hard dependencies must exist, be enabled, have survived, and satisfy their
-- range; run to a fixpoint so failures propagate to dependents transitively -- range; run to a fixpoint so failures propagate to dependents transitively
function Loader:_enforceDependencies() function Loader:_enforceDependencies()
local targetVersion = self:_targetVersion()
local generation = self.generation
local changed = true local changed = true
while changed do while changed do
changed = false changed = false
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
local mod = self.mods[id] local mod = self.mods[id]
for _, spec in ipairs(mod.manifest.dependencySpecs) do for _, spec in ipairs(mod.manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = self.mods[spec.id] local dep = self.mods[spec.id]
local reason, skip local reason, skip
if not dep then if not dep then
@@ -601,11 +604,14 @@ function Loader:_enforceDependencies()
end end
end end
end end
end
end end
-- Tarjan SCC over the hard-dependency graph: only a cycle's own members -- Tarjan SCC over the hard-dependency graph: only a cycle's own members
-- fail, so an unrelated mod beside a cycle still loads -- fail, so an unrelated mod beside a cycle still loads
function Loader:_failCycles() function Loader:_failCycles()
local targetVersion = self:_targetVersion()
local generation = self.generation
local mods = self.mods local mods = self.mods
local counter, stack, onStack, index, low = 0, {}, {}, {}, {} local counter, stack, onStack, index, low = 0, {}, {}, {}, {}
local cycles = {} local cycles = {}
@@ -616,6 +622,7 @@ function Loader:_failCycles()
onStack[id] = true onStack[id] = true
local selfEdge = false local selfEdge = false
for _, spec in ipairs(mods[id].manifest.dependencySpecs) do for _, spec in ipairs(mods[id].manifest.dependencySpecs) do
if ModTargets.specApplies(spec, targetVersion, generation) then
local dep = mods[spec.id] local dep = mods[spec.id]
if spec.id == id then selfEdge = true end if spec.id == id then selfEdge = true end
if dep and isActive(dep) and spec.id ~= id then if dep and isActive(dep) and spec.id ~= id then
@@ -627,6 +634,7 @@ function Loader:_failCycles()
end end
end end
end end
end
if low[id] == index[id] then if low[id] == index[id] then
local component = {} local component = {}
repeat repeat
@@ -674,6 +682,8 @@ end
-- Kahn over the surviving graph with the ready set kept in (priority, id) -- 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 -- order, so dependencies come first and the rest matches the v1 contract
function Loader:_order() function Loader:_order()
local targetVersion = self:_targetVersion()
local generation = self.generation
local pending, indegree, dependents = {}, {}, {} local pending, indegree, dependents = {}, {}, {}
for _, id in ipairs(orderedIds(self.mods, isActive)) do for _, id in ipairs(orderedIds(self.mods, isActive)) do
pending[id], indegree[id] = true, 0 pending[id], indegree[id] = true, 0
@@ -686,9 +696,17 @@ function Loader:_order()
dependents[depId][#dependents[depId] + 1] = id dependents[depId][#dependents[depId] + 1] = id
indegree[id] = indegree[id] + 1 indegree[id] = indegree[id] + 1
end 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 -- 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 end
local ordered = {} local ordered = {}
local function nextId() local function nextId()
+24 -2
View File
@@ -58,11 +58,13 @@ local function parseSpecs(list, field, sources)
local specs = {} local specs = {}
sources = type(sources) == "table" and sources or {} sources = type(sources) == "table" and sources or {}
for _, entry in ipairs(list) do for _, entry in ipairs(list) do
local id, range, ghHint local id, range, ghHint, gamesRaw, gameVersion
if type(entry) == "table" then if type(entry) == "table" then
id = entry.id id = entry.id
range = entry.range or entry.version range = entry.range or entry.version
ghHint = entry.github or entry.repo ghHint = entry.github or entry.repo
gamesRaw = entry.games or entry.game
gameVersion = entry.game_version
elseif type(entry) == "string" and entry ~= "" then elseif type(entry) == "string" and entry ~= "" then
local main, hashRepo = entry:match("^([^#]+)#(.*)$") local main, hashRepo = entry:match("^([^#]+)#(.*)$")
if main then if main then
@@ -82,6 +84,20 @@ local function parseSpecs(list, field, sources)
local ok, err = Semver.validRange(range) local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err))) 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 parsedGh = nil
local rawGh = ghHint or sources[id] local rawGh = ghHint or sources[id]
if rawGh then if rawGh then
@@ -89,7 +105,13 @@ local function parseSpecs(list, field, sources)
if okGh and cleanGh then parsedGh = cleanGh end if okGh and cleanGh then parsedGh = cleanGh end
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 end
return specs return specs
end end
+35
View File
@@ -168,4 +168,39 @@ function ModTargets.detail(manifest, version)
ModTargets.gameLabel(version)) ModTargets.gameLabel(version))
end 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 return ModTargets
+40 -5
View File
@@ -552,11 +552,46 @@ local installedColorlib = Manifest.validate({
version = "1.0.0", version = "1.0.0",
entry = "main.lua", entry = "main.lua",
}, "mods/colorlib") }, "mods/colorlib")
local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib }) -- ------- scoped dependency tests
check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues") local Json = require("src.link.Json")
check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict", local scopedDepManifest = Manifest.validate({
"incompatible mod is flagged as status conflict") id = "dual_gen_mod",
check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict") 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) Runtime.install(savedEvents, savedHooks)
+11 -4
View File
@@ -2989,16 +2989,23 @@ def check_gen2_manifest(repo, mod_dir, manifest, named):
"manifest.json")) "manifest.json"))
deps = manifest.get("dependencies") or [] deps = manifest.get("dependencies") or []
for dep in deps if isinstance(deps, list) else []: 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 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: if found is None:
notes.append("unresolved: dependency %s is not installed beside " 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): elif not declares_gen2(repo, found):
findings.append(Finding( findings.append(Finding(
"MK401", "error", "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", f"loader disables a mod whose dependency a Gen 2 boot skipped",
"manifest.json")) "manifest.json"))
return findings, notes return findings, notes