Merge pull request #1246 from 1Jamie/fix/gen2-battle-rng-static-damage

This commit is contained in:
bryanthaboi
2026-08-14 05:57:51 -04:00
committed by GitHub
20 changed files with 1524 additions and 151 deletions
+5 -1
View File
@@ -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/
@@ -75,3 +76,6 @@ mobile/ios/bundle_id.local
/dist/native/
/dist/win/
/.bazinga/
# Local options / preferences
/options.lua*
+2 -1
View File
@@ -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.
+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.
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
+70
View File
@@ -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
+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
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
**Enablement is per game.** The overlay
+3
View File
@@ -415,6 +415,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"
+6
View File
@@ -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"
+6
View File
@@ -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" "$@"
+4
View File
@@ -116,6 +116,8 @@ local physfsMountFn = nil
local function resolveMount()
if physfsMountFn ~= nil then return physfsMountFn end
physfsMountFn = false
if Platform.isUWP() then return physfsMountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsMountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsMountFn end
pcall(ffi.cdef,
@@ -159,6 +161,8 @@ local physfsUnmountFn = nil
local function resolveUnmount()
if physfsUnmountFn ~= nil then return physfsUnmountFn end
physfsUnmountFn = false
if Platform.isUWP() then return physfsUnmountFn end
if love and love.filesystem and love.filesystem._mounts then return physfsUnmountFn end
local ok, ffi = pcall(require, "ffi")
if not ok then return physfsUnmountFn end
pcall(ffi.cdef, "int PHYSFS_unmount(const char *oldDir);")
+673 -52
View File
@@ -568,10 +568,10 @@ local function modStatusColor(status)
return Strings("Incompatible"), PAL.yellow
end
-- MODS panel scope row: which game the list is answering for. Drawn from
-- GameVersion.ORDER so a new game needs nothing here.
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
local function buildModScopeRow(imp, x, y, w, m)
local GameVersion = require("src.core.GameVersion")
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
@@ -584,16 +584,63 @@ local function buildModScopeRow(imp, x, y, w, m)
{ id = version, label = GameVersion.info(version).label }
end
end
if #options < 2 then return 0 end
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local profiles, activeProf = LauncherMods.getProfiles()
local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
local profW = Kit.textWidth("micro", profLabel) + math.floor(20 * m.s)
local gearW = h
local gearX = x + w - gearW
local profX = gearX - profW - math.floor(4 * m.s)
-- Tapping main profile button cycles to the next profile (styles match iconButton)
Kit._audit("control", profX, y, profW, h, "mod-scope-profile")
local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h)
local hot = focused or Kit.hover(profX, y, profW, h)
Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1)
Theme.strokeRounded(profX, y, profW, h, PAL.line,
hot and Theme.A.focus or Theme.A.hairline, 1)
Kit.textCenterBold("micro", profLabel, profX,
y + (h - Kit.textHeight("micro")) / 2, profW,
hot and PAL.inverse or PAL.heading)
if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then
local nextIdx = 1
for i, p in ipairs(profiles) do
if p.name == activeProf then
nextIdx = (i % #profiles) + 1
break
end
end
local nextProf = profiles[nextIdx] and profiles[nextIdx].name
if nextProf then
queueAction(imp, "mod-scope-profile", function()
LauncherMods.applyProfile(nextProf)
if imp._refreshMods then imp:_refreshMods() end
end)
end
end
-- Tapping gear button opens the Profile Manager modal
imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png"))
iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function()
imp._profilesPopup = true
end)
if #options >= 2 then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if cx + cw <= profX - gap then
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
end
cx = cx + cw + gap
end
end
cx = cx + cw + gap
end
return h + math.floor(8 * m.s)
end
@@ -1380,33 +1427,31 @@ end
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2, size * 0.16))
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x, y + size * 0.55,
x + size * 0.35, y + size * 0.85,
x + size * 0.95, y + size * 0.15)
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow; the letter keeps an unchecked
-- box legible without relying on colour alone.
-- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id)
local color = cartColor(game)
local focused = Kit.focusable(id, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
if love.graphics then
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
if checked then
Theme.fillRounded(x, y, size, size, color, 1)
drawCheck(x, y, size, PAL.inverse)
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or 0.9, 1.5)
drawCheck(x, y, size, color)
else
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
y + (size - Kit.textHeight("micro")) / 2, size, color)
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hairline, 1)
end
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hover, 1)
end
return Kit.press(x, y, size, size) or Kit._activateId == id
end
@@ -1418,28 +1463,79 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local gap = m.gap
local cy = y
-- header: just the action cluster, right-aligned. No "Mods" headline (the
-- active tab already says it) and no enabled count (the toggles show it).
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
-- header: progressive action cluster. Surfaces primary/frequent actions
-- (Import, Updates, Sort) directly on the bar across screen sizes, placing
-- bulk actions (Enable all / Disable all) into More... on compact viewports.
local bh = m.btnH
local importLabel = imp:_modsImportButtonLabel()
local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if #mods > 0 then
local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
action = function() imp:_setAllMods(false) end })
local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
action = function() imp:_setAllMods(true) end })
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
btn(imp, place(sw), cy, sw, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
local disableW = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s)
local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s)
local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s)
local fullReq = importW + disableW + enableW + checkFullW + sortW + math.floor(30 * m.s)
local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s)
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
if fullReq <= w then
-- Tier 1 (Desktop / Wide): Show all 5 full-text buttons
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(checkShortW), cy, checkShortW, bh, "mods-check-updates", Strings("Updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
else
-- Tier 3 (Ultra-Compact Mobile): Surface Import, Sort + More...
local importShortLabel = Strings("Import")
local importShortW = Kit.textWidth("small", importShortLabel) + math.floor(20 * m.s)
local miniReq = importShortW + sortW + moreW + math.floor(18 * m.s)
local useImportW = (miniReq <= w) and importShortW or importW
btn(imp, place(useImportW), cy, useImportW, bh, "mods-import", (miniReq <= w) and importShortLabel or importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
end
else
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
end
cy = cy + bh + math.floor(8 * m.s)
@@ -1534,17 +1630,28 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = "mod-row-" .. mod.id
-- The whole row is the control: it opens the per-mod actions popup
-- (update / versions / delete moved there). Only the enable toggle
-- stays inline, because flipping a mod on and off is the everyday act.
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
Kit.card(x, ry, w, rowH, hot)
if isFullyDisabled then
Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius())
Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius())
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(4 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The
@@ -1582,7 +1689,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
Kit.text("button", nameShown, px, ly, PAL.heading)
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
@@ -2164,6 +2272,219 @@ local function buildVersionsModal(imp, m)
action = function() imp._modVersions = nil end })
end
-- Modal for per-profile actions (Duplicate, Rename, Delete) for compact / mobile / RG device compatibility
local function buildSingleProfileActionsModal(imp, m)
local pName = imp._singleProfileActions and imp._singleProfileActions.name
if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local canDelete = (#profiles > 1)
local armed = deleteArmed(imp, "profile", pName, nil)
local btns = {
{
label = Strings("Duplicate profile"),
kind = "accent",
action = function()
LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil
end
},
{
label = Strings("Rename profile"),
font = "small",
action = function()
imp._singleProfileActions = nil
imp._profileRenamePrompt = { oldName = pName, text = pName }
imp:_armTextInput(pName)
end
},
}
if canDelete then
btns[#btns + 1] = {
label = DELETE_LABEL(armed),
kind = armed and "warn" or "danger",
keepArm = true,
action = function()
imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil
end)
end
}
end
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", pName, pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-" .. i, b.label, {
kind = b.kind,
font = "small",
keepArm = b.keepArm,
action = function()
b.action()
if imp._refreshMods then imp:_refreshMods() end
end
})
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-close",
Strings("Close"), {
font = "small",
action = function() imp._singleProfileActions = nil end })
end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(40 * m.s))
local n = #profiles
local maxVisible = 4
local listH = math.min(maxVisible, math.max(1, n)) * (rowH + gap) - gap
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ m.btnH + gap + listH + math.floor(12 * m.s) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Mod Profiles"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
-- New Profile button
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-new-top",
Strings("+ Create New Profile"), {
kind = "accent", font = "small",
action = function()
imp._profileSavePrompt = { text = "PROFILE " .. tostring(#profiles + 1) }
imp:_armTextInput(imp._profileSavePrompt.text)
end,
})
cy = cy + m.btnH + gap
-- Scrollable Profile Rows
local scrollMax = math.max(0, n * (rowH + gap) - gap - listH)
local scroll = clamp(imp._profScrollOffset or 0, 0, scrollMax)
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(36 * m.s), 0, scrollMax)
Kit.wheelY = 0
end
imp._profScrollOffset = scroll
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
for i, p in ipairs(profiles) do
local ry = cy + (i - 1) * (rowH + gap) - scroll
if ry + rowH >= cy and ry <= cy + listH then
local isCur = (p.name == active)
local rowKey = "prof-row-" .. i
Kit.card(px + pad, ry, pw - 2 * pad, rowH, isCur)
local rx = px + pad + math.floor(12 * m.s)
local editBtnW = math.floor(64 * m.s)
local swBtnW = isCur and 0 or math.floor(64 * m.s)
local rightClusterW = editBtnW + swBtnW + (isCur and 0 or math.floor(4 * m.s))
local nameW = math.max(math.floor(80 * m.s), pw - 2 * pad - 2 * math.floor(12 * m.s) - rightClusterW - math.floor(50 * m.s))
local nameText = Kit.ellipsize("small", p.name, nameW)
Kit.text("small", nameText, rx, ry + (rowH - Kit.textHeight("small")) / 2, isCur and PAL.heading or PAL.muted)
if isCur then
Kit.tag(rx + Kit.textWidth("small", nameText) + math.floor(6 * m.s),
ry + (rowH - Kit.textHeight("micro")) / 2,
Kit.textWidth("micro", Strings("Active")) + math.floor(8 * m.s),
Kit.textHeight("micro"), Strings("Active"), PAL.green)
end
-- Right side controls: [Switch] (if not active) + [Edit]
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(4 * m.s))
-- Edit button (opens per-profile action sheet)
btn(imp, place(editBtnW), ry + math.floor(4 * m.s), editBtnW, rowH - math.floor(8 * m.s), "prof-ed-" .. i,
Strings("Edit"), {
font = "micro",
action = function()
imp._singleProfileActions = { name = p.name }
end,
})
-- Switch button (if not active)
if not isCur then
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), {
kind = "good", font = "micro",
action = function()
LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end
end,
})
end
end
end
Kit.popClip()
cy = cy + listH + math.floor(12 * m.s)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-close",
Strings("Close"), {
font = "small",
action = function() imp._profilesPopup = nil end })
end
-- Modal for MODS tab header actions on mobile / compact displays
local function buildModHeaderActionsModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(380 * m.s)
local gap = math.floor(8 * m.s)
local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
}
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("More Mod Actions"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
kind = b.kind or "ghost", font = "small",
action = function()
imp._modHeaderActionsPopup = nil
b.action()
end
})
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-close",
Strings("Close"), { font = "small",
action = function() imp._modHeaderActionsPopup = nil end })
end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both).
local function buildSortModal(imp, m)
@@ -2299,11 +2620,13 @@ local function buildModActionsModal(imp, m)
end
if not mod then imp._modActions = nil return end
local hasGit = mod.github and mod.github ~= ""
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
local hasDeps = depSpecs and #depSpecs > 0
local info = hasGit and imp:_modUpdateInfo(mod.id)
local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s)
local nBtns = (hasGit and 2 or 0) + 2
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
@@ -2339,6 +2662,20 @@ local function buildModActionsModal(imp, m)
action = function() imp:_modGithubAction(id, "versions") end })
cy = cy + m.btnH + gap
end
if hasDeps then
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-deps",
Strings("Check dependencies"), {
kind = "accent", font = "small",
action = function()
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(mod.manifest or mod)
if depCheck then
imp._modDepResolver = depCheck
end
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
local armed = deleteArmed(imp, "mod", id, nil)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
DELETE_LABEL(armed), {
@@ -2655,6 +2992,238 @@ local function buildSettingsModal(imp, m)
Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings"))
end
local function buildDepResolverModal(imp, m)
local res = imp._modDepResolver
if not res then return end
local pad = math.floor(18 * m.s)
local w = math.floor(540 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s))
local rowH = math.floor(74 * m.s)
local gap = math.floor(8 * m.s)
local warnH = math.floor(38 * m.s)
local n = #(res.deps or {})
local anyUnsatisfied = false
for _, d in ipairs(res.deps or {}) do
if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end
end
local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0
-- Calculate content height dynamically so modal auto-fits small lists snuggly
local headerH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(10 * m.s)
local warnTotalH = warnH + math.floor(12 * m.s)
local listMaxH = math.floor(240 * m.s)
local itemsH = math.min(totalContentH > 0 and totalContentH or rowH, listMaxH)
local footerH = math.floor(10 * m.s) + m.btnH
local wantedH = pad + headerH + warnTotalH + itemsH + footerH + pad
local h = math.floor(math.min(m.H - 2 * m.pad, math.max(260 * m.s, wantedH)))
local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad
-- Title
local titleText = Strings("Dependency Resolver: ") .. tostring(res.targetMod.name or res.targetMod.id)
Kit.text("button", Kit.ellipsize("button", titleText, pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
-- Subtitle / intro
local subText = Strings("This mod requires additional dependencies or has conflicts:")
Kit.text("small", subText, px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
-- Security Disclaimer Banner Callout Card
Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius())
Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius())
local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.")
Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow)
cy = cy + warnH + math.floor(12 * m.s)
-- List area bounds
local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
local scrollMax = math.max(0, totalContentH - listH)
-- Mouse wheel scroll handling matching upstream pattern
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
imp._depScrollOffset = clamp((imp._depScrollOffset or 0) - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
imp._depScrollOffset = 0
else
imp._depScrollOffset = clamp(imp._depScrollOffset or 0, 0, scrollMax)
end
-- Pump active in-flight pulls
if imp._pumpDepPulls then
imp:_pumpDepPulls()
end
-- Clipped vertical scroll container
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
local startY = cy - (imp._depScrollOffset or 0)
for i = 1, n do
local dep = res.deps[i]
local ry = startY + (i - 1) * (rowH + gap)
-- Cull rows completely outside the list viewport rectangle
if ry + rowH >= cy and ry <= cy + listH then
-- Item Card Fill & Stroke (matching launcher card interiors & radius)
local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH)
Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal")
local ix = px + pad + math.floor(12 * m.s)
local innerW = pw - 2 * pad - math.floor(24 * m.s)
-- Dep title & range
local depHeader = tostring(dep.name or dep.id)
if dep.range and dep.range ~= "" then
depHeader = depHeader .. " (" .. dep.range .. ")"
end
Kit.text("small", Kit.ellipsize("small", depHeader, innerW - math.floor(210 * m.s)),
ix, ry + math.floor(8 * m.s), PAL.heading)
-- Status Badge & Subtext
local statusText, statusCol
if dep.status == "satisfied" then
statusText = Strings("Installed & Compatible (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.green
elseif dep.status == "incompatible" then
statusText = Strings("Incompatible (installed v%s, needs %s)", tostring(dep.installedVersion or "?"), tostring(dep.range or ""))
statusCol = PAL.yellow
elseif dep.status == "conflict" then
statusText = Strings("Incompatible mod enabled (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.red
elseif dep.status == "disabled" then
statusText = Strings("Disabled (conflict resolved)")
statusCol = PAL.green
else
statusText = Strings("Missing")
statusCol = PAL.red
end
Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol)
-- Repo source line or Conflict reason
local repoLine
if dep.status == "conflict" or dep.kind == "conflict" then
repoLine = Strings("Listed as incompatible with ") .. tostring(res.targetMod.name or res.targetMod.id)
elseif dep.github then
repoLine = Strings("Source: github.com/") .. dep.github
else
repoLine = Strings("Source: Unknown (no repo listed)")
end
Kit.text("micro", repoLine, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(4 * m.s), PAL.muted)
-- Action buttons right cluster (vertically centered inside card)
local ly = ry + math.floor((rowH - chipH) / 2)
local place = Layout.rightCluster(ix, innerW, math.floor(8 * m.s))
local pState = imp._depPullState and imp._depPullState[dep.id]
if pState and pState.stage ~= "done" and pState.stage ~= "error" then
local label = Strings("Pulling...")
if pState.stage == "fetching" then label = Strings("Fetching...")
elseif pState.stage == "downloading" then
if pState.progress and pState.progress > 0 then
label = Strings("Downloading %d%%", math.floor(pState.progress * 100))
else
label = Strings("Downloading...")
end
elseif pState.stage == "installing" then label = Strings("Installing...")
end
Kit.chip(place(Kit.textWidth("small", label) + math.floor(16 * m.s)), ly,
Kit.textWidth("small", label) + math.floor(16 * m.s), chipH, label, true, PAL.yellow, "dep-pulling-" .. i)
elseif dep.status == "conflict" then
local btnLabel = Strings("Disable mod")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small",
action = function()
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope)
dep.status = "disabled"
if imp._refreshMods then imp:_refreshMods() end
end,
})
elseif dep.status == "disabled" then
local chipLabel = Strings("Disabled")
local cw = Kit.textWidth("small", chipLabel) + math.floor(16 * m.s)
Kit.chip(place(cw), ly, cw, chipH, chipLabel, true, PAL.green, "dep-dischip-" .. i)
else
-- Pull / Update button if github repo is known and not satisfied
if dep.github and dep.status ~= "satisfied" then
local btnLabel = dep.status == "incompatible" and Strings("Update") or Strings("Pull from GitHub")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-pull-" .. i, btnLabel, {
kind = "accent", font = "small",
action = function()
if imp._startDepPull then
imp:_startDepPull(dep)
end
end,
})
end
-- Open Source link button if safeUrl is present
if dep.safeUrl then
local bw = Kit.textWidth("small", Strings("View Source")) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-view-" .. i, Strings("View Source"), {
font = "small",
action = function()
if love and love.system and love.system.openURL then
love.system.openURL(dep.safeUrl)
end
end,
})
end
end
end
end
Kit.popClip()
-- Scrollbar indicator if scrollMax > 0
if scrollMax > 0 then
local barW = math.floor(4 * m.s)
local barX = px + pw - pad - barW
local thumbH = math.max(math.floor(20 * m.s), math.floor(listH * (listH / totalContentH)))
local thumbY = cy + (listH - thumbH) * ((imp._depScrollOffset or 0) / scrollMax)
Theme.fill(barX, cy, barW, listH, PAL.bg, 0.4)
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
end
cy = cy + listH + math.floor(10 * m.s)
-- Bottom Action Buttons
if anyUnsatisfied then
local btnW = math.floor((pw - 2 * pad - math.floor(10 * m.s)) / 2)
btn(imp, px + pad, cy, btnW, m.btnH, "depresolver-pullall", Strings("Pull All Available"), {
kind = "accent", font = "small",
action = function()
for _, dep in ipairs(res.deps or {}) do
if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then
imp:_startDepPull(dep)
end
end
end,
})
btn(imp, px + pad + btnW + math.floor(10 * m.s), cy, btnW, m.btnH, "depresolver-close", Strings("Done"), {
font = "small",
action = function()
imp._modDepResolver = nil
end,
})
else
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "depresolver-close", Strings("Done"), {
kind = "accent", font = "small",
action = function()
imp._modDepResolver = nil
end,
})
end
end
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
-- panels build: immediate mode hit-tests each control as it draws, so the
-- panels underneath a modal must run with Kit.blockClicks already raised or
@@ -2663,12 +3232,60 @@ end
local function modalUp(imp)
return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._findDetails or imp._modVersions or imp._sortPopup
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._indexManage or imp._modActions
or imp._findEntry or imp._gameManage) ~= nil
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
local function buildModals(imp, m)
if imp._profileRenamePrompt then
buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"),
hint = Strings("Enter a new name for this profile:"),
text = imp._profileRenamePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileRenamePrompt and imp._profileRenamePrompt.text
local old = imp._profileRenamePrompt and imp._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._profileSavePrompt then
buildPrompt(imp, m, {
key = "profsave", title = Strings("Save mod profile"),
hint = Strings("Enter a name for this mod profile:"),
text = imp._profileSavePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileSavePrompt and imp._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
imp._profileSavePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileSavePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._settingsText then
local st = imp._settingsText
buildPrompt(imp, m, {
@@ -2733,10 +3350,14 @@ local function buildModals(imp, m)
return true
end
if imp._modVersions then buildVersionsModal(imp, m) return true end
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
-- The lighter popups come after the deep ones on purpose: opening
-- Versions or Details from inside an actions popup draws the deeper modal
-- while the popup's own state stays set, so closing the deep one drops
-- you back where you were.
if imp._singleProfileActions then buildSingleProfileActionsModal(imp, m) return true end
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
+127 -1
View File
@@ -1649,7 +1649,7 @@ end
function RomImporter:_installMod(source)
if self.workState == "working" then return end
self.tab = "mods"
local ok, installed, res = pcall(function()
local ok, installed, res, manifest = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source)
end)
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
if installed then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
local LauncherMods = require("src.mods.LauncherMods")
local checkTarget = manifest
if not checkTarget and type(res) == "string" then
checkTarget = { id = res }
end
if checkTarget and LauncherMods.checkDependencies then
local depCheck = LauncherMods.checkDependencies(checkTarget)
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
else
self.modNotice = { ok = false, text = tostring(res) }
end
@@ -2716,6 +2727,43 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if self._profileSavePrompt then
if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileSavePrompt and self._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
self._profileSavePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileSavePrompt = nil
self:_disarmTextInput()
end
return
end
if self._profileRenamePrompt then
if key == "backspace" then
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
self._profileRenamePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileRenamePrompt = nil
self:_disarmTextInput()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -2885,6 +2933,14 @@ function RomImporter:_commitRename()
end
function RomImporter:textinput(text)
if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._profileRenamePrompt then
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -3371,6 +3427,76 @@ function RomImporter:_pumpModInstall()
else
self.modNotice = { ok = true, text = text }
end
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
self._depPullState = self._depPullState or {}
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
self._depPullState[dep.id] = {
dep = dep,
stage = "fetching",
fetchHandle = hFetch,
}
end
-- Pump all in-flight dependency pulls
function RomImporter:_pumpDepPulls()
if not self._depPullState then return end
local ModUpdate = require("src.mods.ModUpdate")
local LauncherMods = require("src.mods.LauncherMods")
for depId, state in pairs(self._depPullState) do
if state.stage == "fetching" then
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
if done then
if err or not releases or #releases == 0 then
state.stage = "error"
state.err = err or "No downloadable releases found on GitHub"
else
local rel = releases[1]
if not rel or not rel.zip or not rel.zip.url then
state.stage = "error"
state.err = "Latest release has no downloadable .zip asset"
else
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
state.stage = "downloading"
state.targetVersion = rel.version
end
end
end
elseif state.stage == "downloading" then
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
state.progress = progress
if done then
if err or not localPath then
state.stage = "error"
state.err = err or "Download failed"
else
state.stage = "installing"
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
if okInst then
state.stage = "done"
pcall(self._refreshMods, self)
if self._modDepResolver and self._modDepResolver.targetMod then
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
self._modDepResolver = updated
end
else
state.stage = "error"
state.err = tostring(versionRes or "Installation failed")
end
end
end
end
end
end
function RomImporter:_confirmModUpdate(modId, release)
+334 -19
View File
@@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
local discover -- forward declaration for helper functions above line 343
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
@@ -85,26 +87,190 @@ 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"
end
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
if not depId or depId == "" then return nil end
-- 1. Check spec hint if parentManifest dependencySpecs carries it
if parentManifest and parentManifest.dependencySpecs then
for _, spec in ipairs(parentManifest.dependencySpecs) do
if spec.id == depId and spec.github then
return spec.github
end
end
end
-- 2. Check parentManifest raw dependency_sources
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
local src = parentManifest.raw.dependency_sources[depId]
if src then
local ok, clean = pcall(Manifest.parseGithub, src)
if ok and clean then return clean end
end
end
-- 3. Check installed dependency manifest
if installedDep and installedDep.github then
return installedDep.github
end
-- 4. Check ModIndex entries if available
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
for _, src in ipairs(ModIndex.sources() or {}) do
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
if cached and type(cached.index) == "table" then
for _, entry in ipairs(cached.index) do
if type(entry) == "table" and entry.id == depId and entry.github then
local ok, clean = pcall(Manifest.parseGithub, entry.github)
if ok and clean then return clean end
end
end
end
end
end
return nil
end
-- Inspect manifest dependencies and conflicts against installed mods.
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
if not manifest then
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
end
local SaveData = require("src.core.SaveData")
local manifests = installedManifests or discover()
local installedMap = {}
for _, m in ipairs(manifests) do
installedMap[m.id] = m
end
local depsResult = {}
local hasIssues = false
-- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do
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
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
end
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
local conflictIdsSeen = {}
local scope = SaveData.modScope and SaveData.modScope(version) or version
local isEnabled = function(modId)
if not options then return true end
local dec = SaveData.modEnabled(options, modId, scope)
if dec ~= nil then return dec == true end
local m = installedMap[modId]
return m and not m.experimental
end
-- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id
local installedOther = installedMap[conflictId]
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = conflictId,
name = installedOther.name or conflictId,
status = "conflict",
kind = "conflict",
installedVersion = installedOther.version or "?",
github = nil,
safeUrl = nil,
}
end
end
end
-- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then
for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do
if spec.id == manifest.id then
conflictIdsSeen[other.id] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = other.id,
name = other.name or other.id,
status = "conflict",
kind = "conflict",
installedVersion = other.version or "?",
github = nil,
safeUrl = nil,
}
break
end
end
end
end
end
return {
hasIssues = hasIssues,
targetMod = {
id = manifest.id,
name = manifest.name or manifest.id,
version = manifest.version or "?",
},
deps = depsResult,
}
end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
@@ -165,6 +331,8 @@ function LauncherMods.deriveList(manifests, options, version)
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
dependencySpecs = m.dependencySpecs,
manifest = m,
-- what game this mod is for, and whether it will run on the one the
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
@@ -252,7 +420,7 @@ end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
discover = function()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
@@ -386,6 +554,7 @@ function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -409,6 +578,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
end
end
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -674,9 +844,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
return result, res2, res3
end
function LauncherMods._installZipInner(source, opts)
@@ -786,7 +956,7 @@ function LauncherMods._installZipInner(source, opts)
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
return true, manifest.id, manifest
end
-- Install (or replace) a mod from a GitHub release zip URL.
@@ -912,4 +1082,149 @@ function LauncherMods.uninstall(id)
return true
end
-- ----------------------------------------------------------- Mod Profiles (#593)
local ModProfile = require("src.mods.ModProfile")
function LauncherMods.getProfiles(options)
options = options or SaveData.loadOptions()
local manifests = discover()
ModProfile.ensureFirst(options, manifests, options.modOptions)
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
if p.name == profileName then targetProfile = p; break end
end
if not targetProfile then return false end
ModProfile.restoreVersions(targetProfile, options)
options.activeProfile = profileName
SaveData.saveOptions(options)
return true
end
function LauncherMods.saveProfile(profileName, options)
options = options or SaveData.loadOptions()
local manifests = discover()
options.modProfiles = options.modProfiles or {}
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = profileName
local existingIdx
for i, p in ipairs(options.modProfiles) do
if p.name == profileName then existingIdx = i; break end
end
if existingIdx then
options.modProfiles[existingIdx] = snap
else
options.modProfiles[#options.modProfiles + 1] = snap
end
options.activeProfile = profileName
SaveData.saveOptions(options)
return snap
end
local function copyTable(tbl)
if type(tbl) ~= "table" then return tbl end
local copy = {}
for k, v in pairs(tbl) do
copy[k] = type(v) == "table" and copyTable(v) or v
end
return copy
end
function LauncherMods.syncActiveProfile(options)
options = options or SaveData.loadOptions()
local activeName = options.activeProfile or "PROFILE 1"
local profiles = options.modProfiles or {}
local manifests = discover()
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = activeName
local found = false
for i, p in ipairs(profiles) do
if p.name == activeName then
profiles[i] = snap
found = true
break
end
end
if not found then
profiles[#profiles + 1] = snap
end
options.modProfiles = profiles
options.activeProfile = activeName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.duplicateProfile(sourceName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local sourceProfile
for _, p in ipairs(profiles) do
if p.name == sourceName then sourceProfile = p; break end
end
if not sourceProfile then return nil end
local baseName = sourceName .. " (Copy)"
local newName = baseName
local n = 1
local taken = {}
for _, p in ipairs(profiles) do taken[p.name] = true end
while taken[newName] do
n = n + 1
newName = sourceName .. " (" .. n .. ")"
end
local snap = {
name = newName,
enabled = copyTable(sourceProfile.enabled),
options = copyTable(sourceProfile.options),
slots = copyTable(sourceProfile.slots),
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
}
profiles[#profiles + 1] = snap
options.modProfiles = profiles
options.activeProfile = newName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.renameProfile(oldName, newName, options)
options = options or SaveData.loadOptions()
if not newName or newName == "" then return false end
local profiles = options.modProfiles or {}
for i, p in ipairs(profiles) do
if p.name == oldName then
p.name = newName
if options.activeProfile == oldName then
options.activeProfile = newName
end
SaveData.saveOptions(options)
return true
end
end
return false
end
function LauncherMods.deleteProfile(profileName, options)
options = options or SaveData.loadOptions()
options.modProfiles = options.modProfiles or {}
local newProfiles = {}
for _, p in ipairs(options.modProfiles) do
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
end
options.modProfiles = newProfiles
if options.activeProfile == profileName then
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
LauncherMods.applyProfile(fallback, options)
else
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+57 -39
View File
@@ -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()
+65 -22
View File
@@ -31,26 +31,6 @@ local function violation(strict, id, message)
Logger.warn("[%s] %s", tostring(id), message)
end
-- "id" or "id@<range>"; a malformed id or range fails for every api level
-- because there is no sane fallback reading for it
local function parseSpecs(list, field)
local specs = {}
for _, entry in ipairs(list) do
assert(type(entry) == "string" and entry ~= "",
field .. " entries must be non-empty strings")
local id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
specs[#specs + 1] = { id = id, range = range }
end
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
@@ -73,6 +53,69 @@ function Manifest.parseGithub(value)
return owner .. "/" .. repo
end
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
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, 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
entry = main
ghHint = hashRepo
end
id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
else
error(field .. " entries must be non-empty strings or tables")
end
assert(id, ("malformed %s entry"):format(field))
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
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
if okGh and cleanGh then parsedGh = cleanGh end
end
specs[#specs + 1] = {
id = id,
range = range,
github = parsedGh,
games = parsedGames,
game_version = gameVersion,
}
end
return specs
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
@@ -248,8 +291,8 @@ function Manifest.validate(raw, path)
optional_dependencies = array(raw.optional_dependencies),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
+9 -3
View File
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
if type(k) == "number" then
if type(v) == "table" and type(v.id) == "string" then
fn(v.id, v.range or v.version, v.github or v.repo)
elseif type(v) == "string" then
local main, hashRepo = v:match("^([^#]+)#(.*)$")
if main then v = main end
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range, hashRepo)
end
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
+35
View File
@@ -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
@@ -8,11 +8,16 @@ local function quote(value)
return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end
local function execOk(cmd)
local status = os.execute(cmd)
return status == 0 or status == true
end
local function full(path) return root .. "/" .. path end
local fs = {}
function fs.createDirectory(path)
return os.execute("mkdir -p " .. quote(full(path))) == 0
return execOk("mkdir -p " .. quote(full(path)))
end
function fs.write(path, body)
local parent = path:match("^(.*)/[^/]+$")
@@ -34,7 +39,7 @@ function fs.remove(path)
return true
end
function fs.getInfo(path)
if os.execute("test -d " .. quote(full(path))) == 0 then
if execOk("test -d " .. quote(full(path))) then
return { type = "directory" }
end
local handle = io.open(full(path), "rb")
+74 -3
View File
@@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"),
check(not pcall(Manifest.validate, {
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
github = "ftp://example.com/x",
}), "a bad github field fails manifest validation")
}), "an unsupported github URL fails validation")
-- ------- dependency github repo spec hints & dependency resolver
local depGh = Manifest.validate({
id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
dependency_sources = { helper = "Acme/Helper" },
})
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
"dependency spec hash hint parses github owner/repo")
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
"dependency spec hash hint without range parses github owner/repo")
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(depGh)
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
check(#depCheck.deps == 2, "dependency check lists all specs")
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")
local v1 = Manifest.validate({
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
@@ -519,8 +537,61 @@ local emptyLoader = Loader.new({ fs = memfs({}) })
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
check(#emptyLoader.order == 0, "no mods means an empty load order")
check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil,
"no-mod load leaves data untouched")
-- ------- dependency resolver conflict detection test
local LauncherMods = require("src.mods.LauncherMods")
local testTargetManifest = Manifest.validate({
id = "new_mod",
name = "New Mod",
version = "1.0.0",
entry = "main.lua",
incompatible = { "colorlib" },
}, "mods/new_mod")
local installedColorlib = Manifest.validate({
id = "colorlib",
name = "Color Lib",
version = "1.0.0",
entry = "main.lua",
}, "mods/colorlib")
-- ------- 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)
+12
View File
@@ -1162,6 +1162,18 @@ seedOpts.modProfiles = {}
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
local LauncherMods = require("src.mods.LauncherMods")
local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } }
local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts)
check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)",
"duplicateProfile creates P1 (Copy) and activates it")
check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true,
"renameProfile renames active profile")
check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename")
check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile")
check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains")
check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile")
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))
+11 -4
View File
@@ -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