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