Merge pull request #1300 from anxiousintrovert/feature/required-mod-imports

This commit is contained in:
bryanthaboi
2026-08-14 21:41:49 -04:00
committed by GitHub
18 changed files with 1359 additions and 14 deletions
+6
View File
@@ -79,3 +79,9 @@ mobile/ios/bundle_id.local
# Local options / preferences # Local options / preferences
/options.lua* /options.lua*
# User-owned ROMs imported for individual mods. Manifests declare the
# destinations, but source checkouts and packaged mods never ship the files.
/mods/*/baseroms/
/imports/baseroms/
/imports/baseroms-recovery/
+57
View File
@@ -44,6 +44,25 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
"optional_dependencies": [ "optional_dependencies": [
"gen1_modern_ui" "gen1_modern_ui"
], ],
"required_imports": [
{
"id": "stadium2",
"name": "Pokemon Stadium 2 ROM",
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
"file": "stadium2.z64",
"format": "n64",
"size": 67108864,
"md5": ["00000000000000000000000000000000"]
}
],
"optional_imports": [
{
"id": "bonus_source",
"name": "Optional bonus source",
"file": "bonus.bin",
"md5": "00000000000000000000000000000000"
}
],
"conflicts": [], "conflicts": [],
"permissions": ["engine_internals"], "permissions": ["engine_internals"],
"description": "A brief description of the mod.", "description": "A brief description of the mod.",
@@ -67,6 +86,8 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
| `priority` | `integer` | Load priority order (lower numbers load earlier; dependencies always precede dependents regardless of priority). | | `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. | | `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. | | `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. |
| `required_imports` | `array` | User-supplied files required by this mod. The launcher validates and copies each file into this mod's `baseroms/` directory; the mod does not load while one is missing. |
| `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. |
| `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | | `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
@@ -91,6 +112,42 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se
#### Version-Scoped Dependencies #### 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. 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.
### Required user-supplied files
`required_imports` and `optional_imports` keep copyrighted or otherwise user-owned source material
out of mod archives while giving every platform the same installation flow.
Each object requires a stable `id`, a display `name`, a destination `file`
(a filename, never a path), and one MD5 digest or an array of accepted MD5
digests. `format` is either `"raw"` (the default) or `"n64"`. An optional
`description` gives players dump or region guidance in the import panel.
`size` declares the exact canonical byte length; `max_size` declares a smaller
per-import ceiling when an exact size is not appropriate. Every import also
has an engine-enforced 128 MiB ceiling and is rejected before hashing when its
filesystem reports an invalid size.
For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical
big-endian `.z64` order, and then checks MD5. The canonical bytes are written
to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
mod: the launcher never scans or copies another mod's imported files merely
because its manifest names the same digest. Mods read the result with their existing scoped `mod:read` API, for
example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading.
MD5 here identifies a known dump because ROM databases commonly publish it;
it is not a security or authenticity guarantee. Do not paste the SHA-1 used by
Gen1Recomp's own game-ROM importer into an import's `md5` field. Mod archives
must not include anything beneath `baseroms/`. The engine records a validation
receipt keyed by file size and modification time so launcher refreshes and
later boots do not repeatedly hash an unchanged imported ROM.
New mobile code should call `love.system.pickFile("required_import")`. The
older iOS-only `"stadium"` picker kind remains temporarily for compatibility.
Android now returns `false` for unknown picker kinds instead of treating them
as game-ROM picks.
## Mods and Gold (Gen 2) ## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle The mod API is one API across both generations, but Gold runs its own battle
@@ -192,8 +192,14 @@ bool System::pickFile(const char *kind) const
dest = "picked_mod.zip"; dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0) else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav"; dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0) else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb"; dest = "picked_rom.gb";
// Unknown kinds used to fall through to the ROM destination. Refuse them
// so a newer Lua caller cannot silently route an unrelated file as a ROM.
else
return false;
} }
return love::android::showFilePicker(dest); return love::android::showFilePicker(dest);
#else #else
@@ -202,6 +208,15 @@ bool System::pickFile(const char *kind) const
#endif #endif
} }
const char *System::pickFileKinds() const
{
#ifdef LOVE_ANDROID
return "rom,mod,sav,required_import";
#else
return "";
#endif
}
bool System::createFile(const char *suggestedName) const bool System::createFile(const char *suggestedName) const
{ {
#ifdef LOVE_ANDROID #ifdef LOVE_ANDROID
@@ -112,10 +112,12 @@ public:
* love::android::showFilePicker and src/import/RomImporter.lua. * love::android::showFilePicker and src/import/RomImporter.lua.
* *
* @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb, * @param kind Optional pick kind: nullptr/"rom" -> picked_rom.gb,
* "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav. * "mod" -> picked_mod.zip, "sav"/"save" -> picked_save.sav,
* "required_import" -> picked_required_import.bin.
* @return Whether the picker was shown. * @return Whether the picker was shown.
**/ **/
virtual bool pickFile(const char *kind = nullptr) const; virtual bool pickFile(const char *kind = nullptr) const;
virtual const char *pickFileKinds() const;
/** /**
* Shows the platform's native "create / save a file" UI (Android SAF * Shows the platform's native "create / save a file" UI (Android SAF
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
return 1; return 1;
} }
int w_pickFileKinds(lua_State *L)
{
luax_pushstring(L, instance()->pickFileKinds());
return 1;
}
int w_createFile(lua_State *L) int w_createFile(lua_State *L)
{ {
const char *suggested = luaL_optstring(L, 1, nullptr); const char *suggested = luaL_optstring(L, 1, nullptr);
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL }, { "openURL", w_openURL },
{ "vibrate", w_vibrate }, { "vibrate", w_vibrate },
{ "pickFile", w_pickFile }, { "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile }, { "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps }, { "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp }, { "restartApp", w_restartApp },
+4 -2
View File
@@ -5,7 +5,7 @@
// without updating that patch (see mobile/ios/patch_love_src.py). // without updating that patch (see mobile/ios/patch_love_src.py).
// //
// Contract (mirrors love-android's GameActivity.showFilePicker): // Contract (mirrors love-android's GameActivity.showFilePicker):
// love.system.pickFile("rom"|"mod"|"sav") -> copies the user's pick into // love.system.pickFile("rom"|"mod"|"sav"|"required_import") -> copies the user's pick into
// the LÖVE save directory as picked_rom.gb / picked_mod.zip / // the LÖVE save directory as picked_rom.gb / picked_mod.zip /
// picked_save.sav; RomImporter's pending-file scan consumes it. // picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav // love.system.createFile(name) -> exports save dir's pending_export.sav
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
types = [.zip] types = [.zip]
case "sav": case "sav":
destName = "picked_save.sav" destName = "picked_save.sav"
case "required_import":
destName = "picked_required_import.bin"
// A Nintendo 64 cartridge, for mods that build assets out of one -- // A Nintendo 64 cartridge, for mods that build assets out of one --
// the voxel mod's Pokemon Stadium battle models are the caller this // the voxel mod's Pokemon Stadium battle models are the caller this
// was added for. Its own filename on purpose: an N64 ROM landing on // was added for. Its own filename on purpose: an N64 ROM landing on
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
// Kept beside the switch it describes, because the two drifting apart is // Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie. // the only way this can lie.
@objc public static func supportedPickerKinds() -> NSString { @objc public static func supportedPickerKinds() -> NSString {
return "rom,mod,sav,stadium" as NSString return "rom,mod,sav,stadium,required_import" as NSString
} }
@objc(presentExportWithName:saveDir:) @objc(presentExportWithName:saveDir:)
+2 -1
View File
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind); return gr_callBridge(L, "GRPickerBridge", "presentPickerWithKind:saveDir:", kind);
} }
// love.system.pickFileKinds() -> "rom,mod,sav,stadium", or nil off iOS. // love.system.pickFileKinds() -> the comma-separated kinds supported by the
// Swift bridge (including required_import), or nil off iOS.
// //
// So a caller can ask what this build's picker understands BEFORE opening it. // So a caller can ask what this build's picker understands BEFORE opening it.
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly // An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
+124 -2
View File
@@ -638,6 +638,7 @@ end
local function modStatusColor(status) local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end if status == "ok" then return Strings("Ready"), PAL.green end
if status == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red end if status == "conflict" then return Strings("Conflict"), PAL.red end
-- not a fault: the mod is intact, this is simply not a game it is for -- not a fault: the mod is intact, this is simply not a game it is for
-- (src/mods/ModTargets.lua) -- (src/mods/ModTargets.lua)
@@ -2767,11 +2768,14 @@ local function buildModActionsModal(imp, m)
local hasGit = mod.github and mod.github ~= "" local hasGit = mod.github and mod.github ~= ""
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs) local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
local hasDeps = depSpecs and #depSpecs > 0 local hasDeps = depSpecs and #depSpecs > 0
local imports = mod.imports or mod.requiredImports
local hasImports = imports and #imports > 0
local info = hasGit and imp:_modUpdateInfo(mod.id) local info = hasGit and imp:_modUpdateInfo(mod.id)
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s) local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s) local gap = math.floor(8 * m.s)
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2 local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0)
+ (hasImports and 1 or 0) + 2
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s) local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s) + Kit.textHeight("small") + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad + nBtns * (m.btnH + gap) - gap + pad
@@ -2821,6 +2825,19 @@ local function buildModActionsModal(imp, m)
end }) end })
cy = cy + m.btnH + gap cy = cy + m.btnH + gap
end end
if hasImports then
local missing = tonumber(mod.missingRequiredImports) or 0
local label = missing > 0
and Strings("Imported files (%d required)", missing)
or Strings("Imported files")
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-imports",
label, { kind = missing > 0 and "warn" or "accent", font = "small",
action = function()
imp._modImports = id
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
local armed = deleteArmed(imp, "mod", id, nil) local armed = deleteArmed(imp, "mod", id, nil)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del", btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
DELETE_LABEL(armed), { DELETE_LABEL(armed), {
@@ -2837,6 +2854,109 @@ local function buildModActionsModal(imp, m)
action = function() imp._modActions = nil end }) action = function() imp._modActions = nil end })
end end
-- Imported files declared by one installed mod. The engine picks, validates,
-- canonicalizes and copies; this surface never exposes a host path to mod code.
local function buildRequiredImportsModal(imp, m)
local mod
for _, candidate in ipairs(imp.mods or {}) do
if candidate.id == imp._modImports then mod = candidate break end
end
if not mod then imp._modImports = nil return end
local imports = mod.imports or mod.requiredImports or {}
local pad, gap = math.floor(18 * m.s), math.floor(8 * m.s)
local w = math.floor(540 * m.s)
local notice = imp.requiredImportNotice
if not notice or notice.modId ~= mod.id then notice = nil end
local noticeText
if notice then
local importName = notice.importId
for _, row in ipairs(imports) do
if row.id == notice.importId then importName = row.name break end
end
noticeText = Strings("%s rejected: %s", importName, notice.text)
end
local noticeW = w - 2 * pad
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
local rowH = math.max(math.floor(70 * m.s), m.btnH)
local perPage = math.min(4, math.max(1, #imports))
local pagerH = #imports > perPage and math.max(Kit.tapMin(), math.floor(30 * m.s)) or 0
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ noticeH + (noticeH > 0 and gap or 0)
+ perPage * rowH + math.max(0, perPage - 1) * gap
+ (pagerH > 0 and (gap + pagerH) or 0) + gap + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", mod.name, pw - 2 * pad),
px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
Kit.text("small", Strings("User-supplied files are validated by MD5 and copied into this mod only."),
px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(12 * m.s)
if noticeText then
cy = cy + Kit.textWrapped("small", noticeText, px + pad, cy,
pw - 2 * pad, PAL.red, 2) + gap
end
local pageKey = "required-imports-" .. mod.id
local cur = page(imp, pageKey)
local first, last, bounded = Kit.pageBounds(cur, #imports, perPage)
setPage(imp, pageKey, bounded)
for i = first, last do
local row = imports[i]
local importId = row.id
Kit.card(px + pad, cy, pw - 2 * pad, rowH, row.present and "muted" or false)
local innerX = px + pad + math.floor(12 * m.s)
local actionW = math.floor(108 * m.s)
local removeW = row.present and math.floor(86 * m.s) or 0
local actionX = px + pw - pad - math.floor(10 * m.s) - actionW
if removeW > 0 then actionX = actionX - removeW - math.floor(6 * m.s) end
local textW = actionX - innerX - math.floor(8 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
cy + math.floor(8 * m.s), PAL.heading)
local stateY = cy + math.floor(8 * m.s) + Kit.textHeight("small")
+ math.floor(3 * m.s)
if row.description and row.description ~= "" then
Kit.text("micro", Kit.ellipsize("micro", row.description, textW),
innerX, stateY, PAL.muted)
stateY = stateY + Kit.textHeight("micro") + math.floor(2 * m.s)
end
local state = row.present and Strings("Ready - %s", row.file)
or (row.error and Strings("Invalid file - choose again")
or (row.required and Strings("Required - %s", row.file)
or Strings("Optional - %s", row.file)))
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
row.present and PAL.green or (row.required and PAL.yellow or PAL.muted))
btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
"req-pick-" .. mod.id .. "-" .. importId,
row.present and Strings("Replace") or Strings("Choose file"), {
kind = row.present and "ghost" or "accent", font = "small",
action = function() imp:chooseRequiredImport(mod.id, importId) end })
if row.present then
local deleteId = mod.id .. ":" .. importId
local armed = deleteArmed(imp, "required-import", deleteId, nil)
btn(imp, actionX + actionW + math.floor(6 * m.s),
cy + (rowH - m.btnH) / 2, removeW, m.btnH,
"req-remove-" .. mod.id .. "-" .. row.id, DELETE_LABEL(armed), {
kind = "danger", font = "small", keepArm = true,
action = function()
imp:pressDelete("required-import", deleteId, nil, function()
imp:_removeRequiredImport(mod.id, importId)
end)
end })
end
cy = cy + rowH + gap
end
if pagerH > 0 then
local newPage = Kit.pager(px + pad, cy, pw - 2 * pad, bounded,
#imports, perPage, pageKey)
setPage(imp, pageKey, newPage)
cy = cy + pagerH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "req-close", Strings("Close"), {
font = "small", action = function() imp._modImports = nil end })
end
-- Per-mod popup for FIND MODS: the row is a plain click, and Install / -- Per-mod popup for FIND MODS: the row is a plain click, and Install /
-- Details / Source live here instead of crowding every row. -- Details / Source live here instead of crowding every row.
local function buildFindEntryModal(imp, m) local function buildFindEntryModal(imp, m)
@@ -3377,7 +3497,8 @@ local function modalUp(imp)
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes or imp._appPatchNotes
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._modScopePopup or imp._indexManage or imp._modActions or imp._filterPopup or imp._modScopePopup or imp._indexManage
or imp._modActions or imp._modImports
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end end
@@ -3509,6 +3630,7 @@ local function buildModals(imp, m)
end end
if imp._modVersions then buildVersionsModal(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 if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
if imp._modImports then buildRequiredImportsModal(imp, m) return true end
-- The lighter popups come after the deep ones on purpose: opening -- The lighter popups come after the deep ones on purpose: opening
-- Versions or Details from inside an actions popup draws the deeper modal -- 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 -- while the popup's own state stays set, so closing the deep one drops
+242 -5
View File
@@ -344,6 +344,14 @@ local function readExternalPath(path)
return data return data
end end
local function externalFileSize(path)
local file = io.open(path, "rb")
if not file then return nil end
local size = file:seek("end")
file:close()
return size
end
local function readDroppedFile(file) local function readDroppedFile(file)
local ok, openError = file:open("r") local ok, openError = file:open("r")
if not ok then return nil, openError end if not ok then return nil, openError end
@@ -981,6 +989,25 @@ local function findPendingSav(preferAny, skip)
return nil return nil
end end
local function pickerHasKind(kind)
local fn = love.system.pickFileKinds
if type(fn) ~= "function" then return false end
local ok, kinds = pcall(fn)
if not ok or type(kinds) ~= "string" then return false end
for token in kinds:gmatch("[^,%s]+") do
if token == kind then return true end
end
return false
end
local function findPendingRequiredImport()
local names = { "picked_required_import.bin", "picked_stadium.z64" }
for _, name in ipairs(names) do
if love.filesystem.getInfo(name, "file") then return name end
end
return nil
end
-- Retire an Android pick once it has been through the installer / importer, -- Retire an Android pick once it has been through the installer / importer,
-- whether or not it worked: a pick left on disk wins the scans above forever, -- whether or not it worked: a pick left on disk wins the scans above forever,
-- so the next tap re-runs the same failing file and the picker never reopens -- so the next tap re-runs the same failing file and the picker never reopens
@@ -1112,6 +1139,39 @@ local function chooseSav()
return nil return nil
end end
-- Generic user-supplied dependency picker. Keep the native prompt entirely
-- engine-owned: manifest labels are untrusted and must never enter shell
-- command templates. The LÖVE modal already shows the specific import name.
local function chooseRequiredFile()
local prompt = shellSafe(Strings("Choose required mod file"))
local platform = love.system.getOS()
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s")' 2>/dev/null]])
:format(prompt))
elseif platform == "Windows" then
local script = table.concat({
"Add-Type -AssemblyName System.Windows.Forms;",
"$d=New-Object System.Windows.Forms.OpenFileDialog;",
"$d.Title='" .. prompt .. "';",
"$d.Filter='All files (*.*)|*.*';",
"if($d.ShowDialog() -eq 'OK'){",
"$t=Join-Path $env:TEMP 'pokeport_required_import.bin';",
"Copy-Item -LiteralPath $d.FileName -Destination $t -Force;",
"[Console]::OutputEncoding=[Text.Encoding]::UTF8;",
"[Console]::Write($t)}",
})
return commandOutput(
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" 2>/dev/null]]):format(prompt))
if path then return path end
return commandOutput([[kdialog --getopenfilename "$HOME" 2>/dev/null]])
end
return nil
end
-- The self-updater only surfaces on the real distributed build: a fused, -- The self-updater only surfaces on the real distributed build: a fused,
-- interactive launcher with no scripted-run override. A dev / source checkout -- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver / -- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1230,7 +1290,9 @@ function RomImporter.new(onComplete, opts)
-- (refreshed lazily on first draw and after any toggle/install/delete); -- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped -- modScroll is the current paged list's inner scroll offset (px, clamped
-- in draw); modNotice is the last install/delete result { ok, text }. -- in draw); modNotice is the last install/delete result { ok, text }.
mods = nil, modScroll = 0, modNotice = nil, -- requiredImportNotice stays inside the imported-files modal so validation
-- failures are visible beside the file picker that caused them.
mods = nil, modScroll = 0, modNotice = nil, requiredImportNotice = nil,
-- Which game the MODS panel is answering for (a GameVersion id, nil = -- Which game the MODS panel is answering for (a GameVersion id, nil =
-- every game). Rows resolve their enable-state and their "runs here" -- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua). -- verdict against it (src/mods/ModTargets.lua).
@@ -1420,7 +1482,13 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose " local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: " .. "it with the Files (Documents) app, or copy it into: "
.. love.filesystem.getSaveDirectory() .. love.filesystem.getSaveDirectory()
if pickError:find("picked_mod", 1, true) then if pickError:find("picked_required_import", 1, true)
or pickError:find("picked_stadium", 1, true) then
self.modNotice = { ok = false, text = text }
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
elseif pickError:find("picked_mod", 1, true) then
self.modNotice = { ok = false, text = text } self.modNotice = { ok = false, text = text }
elseif pickError:find("picked_save", 1, true) then elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget() local version = self.androidPendingVersion or self:_savedropTarget()
@@ -1431,6 +1499,20 @@ function RomImporter:focus(f)
end end
return return
end end
local requiredName = findPendingRequiredImport()
if requiredName then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingKind = nil
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
local imported = modId and importId
and self:_importRequiredSource(modId, importId, requiredName)
consumePick(self, requiredName, requiredName, imported)
if not modId or not importId then
self.modNotice = { ok = false,
text = "A picked dependency file had no pending mod request and was discarded." }
end
return
end
local modName = findPendingMod(false, self.pickSkip) local modName = findPendingMod(false, self.pickSkip)
if modName then if modName then
self:_installMod(modName) self:_installMod(modName)
@@ -1738,6 +1820,152 @@ function RomImporter:chooseMod()
if path then self:_installMod(path) end if path then self:_installMod(path) end
end end
local function requiredManifest(self, modId)
for _, row in ipairs(self.mods or {}) do
if row.id == modId then return row.manifest, row end
end
return nil
end
local function requiredSpec(manifest, importId)
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
if candidate.id == importId then return candidate end
end
return nil
end
local function requiredImportNotice(self, modId, importId, text)
self.requiredImportNotice = {
modId = modId,
importId = importId,
text = tostring(text),
}
end
function RomImporter:_importRequiredData(modId, importId, data)
local manifest = requiredManifest(self, modId)
if not manifest then
self.modNotice = { ok = false, text = "Required import failed: mod not found." }
return nil
end
local ok, result = require("src.mods.RequiredImports")
.importData(manifest, importId, data)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Imported " .. tostring(importId)
.. " for " .. tostring(manifest.name or manifest.id) .. "." }
self:_refreshMods()
return true
end
-- Keep validation feedback on the imported-files page. A general Mods-page
-- notice is hidden by this modal and made MD5 failures especially easy to miss.
requiredImportNotice(self, modId, importId, result)
self.modNotice = nil
return nil
end
function RomImporter:_importRequiredSource(modId, importId, source)
local manifest = requiredManifest(self, modId)
local spec = manifest and requiredSpec(manifest, importId)
if not spec then
requiredImportNotice(self, modId, importId, "Import declaration was not found.")
self.modNotice = nil
return nil
end
local info = love.filesystem.getInfo(source, "file")
local size = info and info.size or externalFileSize(source)
local sizeErr = require("src.mods.RequiredImports").sizeError(spec, size, false)
if sizeErr then
requiredImportNotice(self, modId, importId, sizeErr)
self.modNotice = nil
return nil
end
local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end
if not data then
requiredImportNotice(self, modId, importId, "Could not read the selected file.")
self.modNotice = nil
return nil
end
return self:_importRequiredData(modId, importId, data)
end
function RomImporter:_removeRequiredImport(modId, importId)
local manifest = requiredManifest(self, modId)
if not manifest then return end
local ok, err = require("src.mods.RequiredImports").remove(manifest, importId)
if ok then
self.requiredImportNotice = nil
self.modNotice = { ok = true, text = "Deleted " .. tostring(importId) .. "." }
self:_refreshMods()
else
requiredImportNotice(self, modId, importId, err)
self.modNotice = nil
end
end
-- Select and validate one manifest-declared file. NX has no host picker, so
-- its equivalent is an engine-owned imports/baseroms inbox that can be filled
-- over MTP; every other native/mobile picker lands on the same validation path.
function RomImporter:chooseRequiredImport(modId, importId)
if self.workState == "working" then return end
local manifest = requiredManifest(self, modId)
if not manifest then return end
local spec = requiredSpec(manifest, importId)
if not spec then return end
if self.isNX then
local inbox = "imports/baseroms"
love.filesystem.createDirectory(inbox)
local lastError
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local info = love.filesystem.getInfo(path, "file")
local sizeErr = info and require("src.mods.RequiredImports")
.sizeError(spec, info.size, false)
local data = not sizeErr and love.filesystem.read(path) or nil
if data and self:_importRequiredData(modId, importId, data) then return end
if sizeErr then lastError = sizeErr
elseif self.requiredImportNotice
and self.requiredImportNotice.modId == modId
and self.requiredImportNotice.importId == importId then
lastError = self.requiredImportNotice.text
end
end
end
requiredImportNotice(self, modId, importId, lastError
or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
self.modNotice = nil
return
end
if self.nativePicker then
if self.mobileFileBridge and not pickerHasKind("required_import") then
requiredImportNotice(self, modId, importId,
"This app build cannot pick required mod files yet. Update the app and try again.")
self.modNotice = nil
return
end
self.pickerPendingKind = "required_import"
self.pickerPendingModId = modId
self.pickerPendingImportId = importId
if not pickFile("required_import") then
self.pickerPendingKind = nil
self.pickerPendingModId = nil
self.pickerPendingImportId = nil
requiredImportNotice(self, modId, importId, "Could not open the file picker.")
self.modNotice = nil
elseif self.android then
self.pickPending = true
self.pickTimer = 0
end
return
end
local path = chooseRequiredFile()
if path then self:_importRequiredSource(modId, importId, path) end
end
-- Which game a dropped .sav imports into: a .sav has no version signature of -- Which game a dropped .sav imports into: a .sav has no version signature of
-- its own, so it lands on the active game tab. When a non-game tab (mods) is -- its own, so it lands on the active game tab. When a non-game tab (mods) is
-- showing, default to red -- the always-present first game -- rather than -- showing, default to red -- the always-present first game -- rather than
@@ -2045,7 +2273,8 @@ function RomImporter:_pollPickedFiles(dt)
if not found then if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
local n = name:lower() local n = name:lower()
if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav" then if n:match("%.gbc?$") or n == "picked_mod.zip" or n == "picked_save.sav"
or n == "picked_required_import.bin" or n == "picked_stadium.z64" then
found = true found = true
break break
end end
@@ -2174,7 +2403,12 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion local version = self.pickerPendingVersion
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingVersion = nil self.pickerPendingVersion = nil
if kind == "mod" then if kind == "required_import" then
local modId, importId = self.pickerPendingModId, self.pickerPendingImportId
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
if modId and importId then self:_importRequiredSource(modId, importId, path) end
if Platform.isUWP() then os.remove(path) end
elseif kind == "mod" then
self:_installMod(path) self:_installMod(path)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path) os.remove(path)
@@ -2196,7 +2430,10 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion or self:_savedropTarget() local version = self.pickerPendingVersion or self:_savedropTarget()
self.pickerPendingKind = nil self.pickerPendingKind = nil
self.pickerPendingVersion = nil self.pickerPendingVersion = nil
if kind == "mod" then if kind == "required_import" then
self.modNotice = { ok = false, text = errorText }
self.pickerPendingModId, self.pickerPendingImportId = nil, nil
elseif kind == "mod" then
self.modNotice = { ok = false, text = errorText } self.modNotice = { ok = false, text = errorText }
elseif kind == "sav" then elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText } self.saveNotice[version] = { ok = false, text = errorText }
+83 -1
View File
@@ -38,6 +38,7 @@ local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local RequiredImports = require("src.mods.RequiredImports")
local LauncherMods = {} local LauncherMods = {}
@@ -459,13 +460,38 @@ function LauncherMods.list(version)
local ok, result = pcall(function() local ok, result = pcall(function()
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
local manifests = discover() local manifests = discover()
-- Imports are private player grants. Never scan or copy another mod's
-- baseroms: a matching public digest is not permission to share the file.
local importState = {}
for _, manifest in ipairs(manifests) do
local rows, missing, missingOptional = RequiredImports.inspect(manifest)
importState[manifest.id] = { rows = rows, missing = missing,
missingOptional = missingOptional }
end
-- The first build containing game-specific switches turns the old shared -- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here -- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration. -- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options) SaveData.saveOptions(options)
end end
return LauncherMods.deriveList(manifests, options, version) local rows = LauncherMods.deriveList(manifests, options, version)
for _, row in ipairs(rows) do
local state = importState[row.id]
or { rows = {}, missing = 0, missingOptional = 0 }
local imports, missing = state.rows, state.missing
row.requiredImports, row.missingRequiredImports = imports, missing
row.imports = imports
row.missingOptionalImports = state.missingOptional or 0
if missing > 0 and row.status == "ok" then
row.status = "needs_import"
local first
for _, import in ipairs(imports) do
if not import.present then first = import break end
end
row.statusDetail = "Needs import: " .. (first and first.name or "required file")
end
end
return rows
end) end)
if not ok then if not ok then
-- a single bad options/mod file must not blank the launcher -- a single bad options/mod file must not blank the launcher
@@ -677,6 +703,26 @@ local function copyTree(src, dst)
return true return true
end end
-- User-supplied baseroms are install state, not package content. Snapshot
-- them before replacing a mod tree so an update cannot make the player select
-- the same cartridge again (or destroy their only reusable copy if the new
-- archive later fails to copy).
local function snapshotTree(path, into, relative)
local fs = love.filesystem
into, relative = into or {}, relative or ""
local info = fs.getInfo(path)
if not info then return into end
if info.type == "directory" then
for _, name in ipairs(fs.getDirectoryItems(path) or {}) do
local rel = relative == "" and name or (relative .. "/" .. name)
snapshotTree(path .. "/" .. name, into, rel)
end
elseif relative ~= "" and into[relative] == nil then
into[relative] = fs.read(path)
end
return into
end
-- Delete an installed mod subtree. Enumeration stays on love.filesystem (the -- Delete an installed mod subtree. Enumeration stays on love.filesystem (the
-- portable game folder is on its read path), but the deletes go through -- portable game folder is on its read path), but the deletes go through
-- CacheFs so a portable install's real files actually go away instead of -- CacheFs so a portable install's real files actually go away instead of
@@ -919,14 +965,28 @@ function LauncherMods._installZipInner(source, opts)
return nil, ("zip is for '%s', expected '%s'") return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId) :format(manifest.id, opts.expectId)
end end
local packagedBaseroms = root .. "/baseroms"
if fs.getInfo(packagedBaseroms, "directory")
and #(fs.getDirectoryItems(packagedBaseroms) or {}) > 0 then
cleanup()
return nil, "mod archives must not include user-supplied baseroms/ files"
end
local dest = "mods/" .. manifest.id local dest = "mods/" .. manifest.id
local baseromRecovery = "imports/baseroms-recovery/" .. manifest.id
local existing, installedSomewhere = sameIdTrees(fs, manifest.id) local existing, installedSomewhere = sameIdTrees(fs, manifest.id)
if installedSomewhere and not opts.replace then if installedSomewhere and not opts.replace then
cleanup() cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed" return nil, "a mod named '" .. manifest.id .. "' is already installed"
end end
local preservedBaseroms = {}
-- A previous failed update may have staged the user's files outside mods/ so
-- discovery cannot mistake recovery debris for an installed mod.
snapshotTree(baseromRecovery, preservedBaseroms)
if #existing > 0 then if #existing > 0 then
for _, path in ipairs(existing) do
snapshotTree(path .. "/baseroms", preservedBaseroms)
end
-- drop every old tree before copy -- mods/<id> and any same-id folder -- drop every old tree before copy -- mods/<id> and any same-id folder
-- under another name, or the survivor keeps winning discover()'s -- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with -- first-id-wins race after the "successful" update (#801). A tree with
@@ -950,6 +1010,28 @@ function LauncherMods._installZipInner(source, opts)
CacheFs.prefix = "" CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest) local copied, copyErr = copyTree(root, dest)
if not copied then removeTree(dest) end if not copied then removeTree(dest) end
local preserveErr
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then
local restored, restoreErr = CacheFs.write(dest .. "/baseroms/" .. rel, bytes)
if not restored and not preserveErr then
preserveErr = "could not preserve baseroms/" .. rel .. ": "
.. tostring(restoreErr)
end
end
end
if preserveErr then
-- Do not report a successful update that discarded user-owned input, and
-- do not leave a manifest-less baseroms tree that resembles an install.
removeTree(dest)
removeTree(baseromRecovery)
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
end
copied, copyErr = nil, preserveErr
elseif copied then
removeTree(baseromRecovery)
end
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not copied then if not copied then
cleanup() cleanup()
+19 -1
View File
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
local Data = require("src.core.Data") local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local RequiredImports = require("src.mods.RequiredImports")
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI") local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime") local DateTime = require("src.core.DateTime")
@@ -565,7 +566,24 @@ function Loader:_validate()
elseif manifest.assets_transforms elseif manifest.assets_transforms
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
reason = "assets_transforms file missing: " .. manifest.assets_transforms reason = "assets_transforms file missing: " .. manifest.assets_transforms
elseif manifest.game_version and not devEngine() then end
if not reason and #(manifest.required_imports or {}) > 0 then
for _, import in ipairs(manifest.required_imports) do
local path = mod.path .. "/baseroms/" .. import.file
if not self:_exists(path) then
reason = "required import missing: " .. import.name
break
end
local valid, importErr = RequiredImports.validateStored(
manifest, import, self.fs)
if not valid then
reason = "required import invalid: " .. import.name
.. " (" .. tostring(importErr) .. ")"
break
end
end
end
if not reason and manifest.game_version and not devEngine() then
local ok, err = Semver.satisfies(Version.engine, manifest.game_version) local ok, err = Semver.satisfies(Version.engine, manifest.game_version)
if not ok then if not ok then
reason = ("needs game version %s, engine is %s") reason = ("needs game version %s, engine is %s")
+100 -1
View File
@@ -132,13 +132,97 @@ local function mergeConflictLists(conflicts, incompatible)
return out return out
end end
local scrubUtf8 -- shared by required-import labels and top-level strings
-- User-supplied files a mod needs beside its own source. The launcher owns
-- the picker and the copy; the mod only reads the resulting
-- <mod>/baseroms/<file> through its existing scoped mod:read surface. MD5 is
-- deliberately the manifest vocabulary here: ROM preservation databases and
-- the mods consuming these files commonly identify dumps by MD5, and the
-- digest is an identity check rather than a security boundary.
local function parseImports(value, field, required)
field = field or "required_imports"
local out, ids, files = {}, {}, {}
for _, entry in ipairs(array(value)) do
assert(type(entry) == "table", field .. " entries must be objects")
local id = entry.id
assert(type(id) == "string" and id:match("^[%w_%-]+$"),
field .. " id must contain only letters, numbers, _ or -")
assert(not ids[id], "duplicate " .. field .. " id: " .. id)
ids[id] = true
local file = entry.file
assert(type(file) == "string" and file ~= "",
field .. " file is required")
file = SafePath.require(file, field .. " file")
assert(not file:find("/", 1, true),
field .. " file must be a filename inside baseroms")
assert(file:sub(1, 1) ~= ".",
field .. " file must not use a hidden metadata filename")
assert(not files[file], "duplicate " .. field .. " file: " .. file)
files[file] = true
local hashes = entry.md5
if type(hashes) == "string" then hashes = { hashes } end
assert(type(hashes) == "table" and #hashes > 0,
field .. " md5 must be a hash or non-empty array")
local accepted, seen = {}, {}
for _, digest in ipairs(hashes) do
assert(type(digest) == "string" and digest:match("^[%x]+$")
and #digest == 32, field .. " md5 values must be 32 hex characters")
digest = digest:lower()
if not seen[digest] then
seen[digest] = true
accepted[#accepted + 1] = digest
end
end
local format = entry.format or "raw"
assert(format == "raw" or format == "n64",
field .. " format must be raw or n64")
local name = entry.name or id
assert(type(name) == "string" and name ~= "",
field .. " name must be a non-empty string")
local description = entry.description or entry.hint
if description ~= nil then
assert(type(description) == "string" and description ~= "",
field .. " description must be a non-empty string")
end
local size = entry.size
local maxSize = entry.max_size
local function validateSize(value, label)
if value == nil then return end
assert(type(value) == "number" and value > 0 and value % 1 == 0,
field .. " " .. label .. " must be a positive integer")
assert(value <= 128 * 1024 * 1024,
field .. " " .. label .. " exceeds the 128 MiB hard limit")
end
validateSize(size, "size")
validateSize(maxSize, "max_size")
assert(not (size and maxSize) or size <= maxSize,
field .. " size must not exceed max_size")
out[#out + 1] = {
id = id,
name = scrubUtf8(name),
description = scrubUtf8(description),
file = file,
md5 = accepted,
format = format,
size = size,
max_size = maxSize,
required = required ~= false,
}
end
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs, -- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises -- surrogates, > U+10FFFF) and a leading BOM. LÖVE's text renderer raises
-- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a -- "Invalid UTF-8" from love.graphics.print/printf, so any manifest string a
-- panel may draw must be scrubbed here -- the one place every mod manifest -- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS -- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card. -- panel instead of misrendering one card.
local function scrubUtf8(s) scrubUtf8 = function(s)
if type(s) ~= "string" then return s end if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "") s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s local out, i, n = {}, 1, #s
@@ -290,6 +374,19 @@ function Manifest.validate(raw, path)
local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible) local conflicts = mergeConflictLists(raw.conflicts, raw.incompatible)
local requiredImports = parseImports(raw.required_imports,
"required_imports", true)
local optionalImports = parseImports(raw.optional_imports,
"optional_imports", false)
local importIds, importFiles = {}, {}
for _, list in ipairs({ requiredImports, optionalImports }) do
for _, import in ipairs(list) do
assert(not importIds[import.id], "duplicate import id: " .. import.id)
assert(not importFiles[import.file], "duplicate import file: " .. import.file)
importIds[import.id], importFiles[import.file] = true, true
end
end
return { return {
id = raw.id, id = raw.id,
name = raw.name, name = raw.name,
@@ -318,6 +415,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet, permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"), options_schema = optionalFile(raw.options_schema, "options_schema"),
assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"),
required_imports = requiredImports,
optional_imports = optionalImports,
-- an env var name, not a path, so it keeps the plain string check -- an env var name, not a path, so it keeps the plain string check
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"), force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
path = path, path = path,
+295
View File
@@ -0,0 +1,295 @@
-- Engine-owned import handling for files declared by a mod's required or
-- optional import arrays. Mods never receive host paths or broader
-- filesystem access: accepted bytes are copied into their own
-- mods/<id>/baseroms/ tree, where the existing mod:read sandbox can see them.
local CacheFs = require("src.import.CacheFs")
local RequiredImports = {}
local function allSpecs(manifest)
local out = {}
for _, spec in ipairs((manifest and manifest.required_imports) or {}) do
out[#out + 1] = spec
end
for _, spec in ipairs((manifest and manifest.optional_imports) or {}) do
out[#out + 1] = spec
end
return out
end
local function isRequired(spec)
return spec.required ~= false
end
RequiredImports.specs = allSpecs
RequiredImports.MAX_BYTES = 128 * 1024 * 1024
local function sizeLabel(bytes)
return ("%.1f MiB"):format(bytes / (1024 * 1024))
end
-- Check size before a caller reads an external or stored file into one large
-- Lua string. N64 sources may carry a 512-byte copier header, while stored
-- files are always canonical and therefore must match the declared size.
function RequiredImports.sizeError(spec, size, stored)
if type(size) ~= "number" then return nil end
if size > RequiredImports.MAX_BYTES then
return ("file is too large (%s; hard limit is %s)")
:format(sizeLabel(size), sizeLabel(RequiredImports.MAX_BYTES))
end
local headerAllowance = not stored and spec and spec.format == "n64" and 512 or 0
if spec and spec.size then
if size ~= spec.size and size ~= spec.size + headerAllowance then
return ("wrong file size (expected %d bytes%s, got %d)")
:format(spec.size, headerAllowance > 0 and " or a 512-byte header" or "", size)
end
end
if spec and spec.max_size and size > spec.max_size + headerAllowance then
return ("file is too large for this import (maximum %d bytes, got %d)")
:format(spec.max_size, size)
end
return nil
end
local N64_MAGIC = {
["\128\55\18\64"] = "z64", -- big endian / canonical
["\55\128\64\18"] = "v64", -- byte-swapped
["\64\18\55\128"] = "n64", -- little endian words
}
local function n64KindAt(data, offset)
return N64_MAGIC[data:sub(offset, offset + 3)]
end
-- Return canonical big-endian N64 bytes. A 512-byte copier header is
-- recognized only when valid N64 magic follows it, so arbitrary data is never
-- shortened just because its size happens to line up.
function RequiredImports.normalizeN64(data)
if type(data) ~= "string" then return nil, "selected file could not be read" end
local offset, kind = 1, n64KindAt(data, 1)
if not kind then
kind = n64KindAt(data, 513)
if kind then offset = 513 end
end
if not kind then
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
end
data = data:sub(offset)
if kind == "z64" then return data end
if kind == "v64" then
if #data % 2 ~= 0 then return nil, "byte-swapped N64 ROM has an odd size" end
return (data:gsub("(.)(.)", "%2%1"))
else
if #data % 4 ~= 0 then return nil, "little-endian N64 ROM size is not word aligned" end
return (data:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
end
end
function RequiredImports.normalize(spec, data)
if spec and spec.format == "n64" then
return RequiredImports.normalizeN64(data)
end
if type(data) ~= "string" then return nil, "selected file could not be read" end
return data
end
local function hexDigest(data, hashFn)
if hashFn then return hashFn(data):lower() end
if not (love and love.data and love.data.hash and love.data.encode) then
return nil, "MD5 support is unavailable in this build"
end
local digest = love.data.hash("md5", data)
if type(digest) == "userdata" and digest.getString then
digest = digest:getString()
end
return love.data.encode("string", "hex", digest):lower()
end
local function accepts(spec, digest)
for _, wanted in ipairs((spec and spec.md5) or {}) do
if wanted == digest then return true end
end
return false
end
function RequiredImports.path(manifest, spec)
return manifest.path .. "/baseroms/" .. spec.file
end
local function removedMarker(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".removed"
end
local function receiptPath(manifest, spec)
return manifest.path .. "/baseroms/.required-import-" .. spec.id .. ".validated"
end
RequiredImports.receiptPath = receiptPath
local function parseReceipt(raw)
if type(raw) ~= "string" then return nil end
local digest, size, modtime = raw:match("^v1\n([%x]+)\n(%d+)\n([^\n]+)\n?$")
if not digest then return nil end
return digest:lower(), tonumber(size), tonumber(modtime)
end
local function cachedDigest(manifest, spec, fs, info)
-- A size alone cannot detect a same-length replacement. Require modtime as
-- well; filesystems that do not expose it simply take the safe hash path.
if not (fs and fs.read and info and info.size and info.modtime) then return nil end
local digest, size, modtime = parseReceipt(fs.read(receiptPath(manifest, spec)))
if digest and size == info.size and modtime == info.modtime
and accepts(spec, digest) then
return digest
end
return nil
end
local function writeReceipt(manifest, spec, digest, info, fs)
if not (digest and info and info.size and info.modtime) then return end
local path = receiptPath(manifest, spec)
local body = ("v1\n%s\n%d\n%s\n")
:format(digest, info.size, tostring(info.modtime))
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.write(path, body)
CacheFs.prefix = savedPrefix
elseif fs and fs.write then
fs.write(path, body)
end
end
local function removeReceipt(manifest, spec, fs)
local path = receiptPath(manifest, spec)
if love and fs == love.filesystem then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(path)
CacheFs.prefix = savedPrefix
elseif fs and fs.remove then
fs.remove(path)
end
end
-- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn)
local sourceSizeErr = type(data) == "string"
and RequiredImports.sizeError(spec, #data, false)
if sourceSizeErr then return nil, sourceSizeErr end
local normalized, normalizeErr = RequiredImports.normalize(spec, data)
if not normalized then return nil, normalizeErr end
local storedSizeErr = RequiredImports.sizeError(spec, #normalized, true)
if storedSizeErr then return nil, storedSizeErr end
local digest, hashErr = hexDigest(normalized, hashFn)
if not digest then return nil, hashErr end
if not accepts(spec, digest) then
return nil, ("MD5 mismatch (got %s)"):format(digest)
end
return normalized, digest
end
-- Validate one installed import without reading it when the engine-authored
-- receipt still matches the file's size and modification time.
function RequiredImports.validateStored(manifest, spec, fs, hashFn)
fs = fs or (love and love.filesystem)
if not (fs and fs.getInfo) then return nil, "filesystem is unavailable" end
local path = RequiredImports.path(manifest, spec)
local info = fs.getInfo(path, "file")
if not info then
removeReceipt(manifest, spec, fs)
return nil, "file is missing"
end
local sizeErr = RequiredImports.sizeError(spec, info.size, true)
if sizeErr then
removeReceipt(manifest, spec, fs)
return nil, sizeErr
end
local cached = cachedDigest(manifest, spec, fs, info)
if cached then return true, cached, true end
removeReceipt(manifest, spec, fs)
if not fs.read then return nil, "file could not be read" end
local data = fs.read(path)
local normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
if not normalized then return nil, detail end
info = fs.getInfo(path, "file") or info
info.size = info.size or #data
writeReceipt(manifest, spec, detail, info, fs)
return true, detail, false
end
function RequiredImports.validateStoredData(spec, data, hashFn)
local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
if not normalized then return nil, detail end
if normalized ~= data then
return nil, "stored N64 ROM is not canonical; choose the source file again"
end
return normalized, detail
end
function RequiredImports.inspect(manifest, fs, hashFn)
fs = fs or (love and love.filesystem)
local rows, missing, missingOptional = {}, 0, 0
for _, spec in ipairs(allSpecs(manifest)) do
local path = RequiredImports.path(manifest, spec)
local suppressed = fs and fs.getInfo
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
local row = { id = spec.id, name = spec.name, file = spec.file,
description = spec.description, format = spec.format, path = path,
present = valid == true, digest = valid and detail or nil,
error = exists and not valid and detail or nil,
suppressed = suppressed, required = isRequired(spec), spec = spec }
if not row.present then
if row.required then missing = missing + 1
else missingOptional = missingOptional + 1 end
end
rows[#rows + 1] = row
end
return rows, missing, missingOptional
end
function RequiredImports.importData(manifest, importId, data, opts)
opts = opts or {}
local spec
for _, candidate in ipairs(allSpecs(manifest)) do
if candidate.id == importId then spec = candidate break end
end
if not spec then return nil, "unknown required import: " .. tostring(importId) end
local normalized, digest = RequiredImports.validateData(spec, data, opts.hash)
if not normalized then return nil, digest end
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(receiptPath(manifest, spec))
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
if ok then CacheFs.remove(removedMarker(manifest, spec)) end
if ok and love and love.filesystem and love.filesystem.getInfo then
local info = love.filesystem.getInfo(RequiredImports.path(manifest, spec), "file")
writeReceipt(manifest, spec, digest, info, love.filesystem)
end
CacheFs.prefix = savedPrefix
if not ok then return nil, "could not copy import: " .. tostring(err) end
return true, digest
end
function RequiredImports.remove(manifest, importId)
for _, spec in ipairs(allSpecs(manifest)) do
if spec.id == importId then
local savedPrefix = CacheFs.prefix
CacheFs.prefix = ""
CacheFs.remove(RequiredImports.path(manifest, spec))
CacheFs.remove(receiptPath(manifest, spec))
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
CacheFs.prefix = savedPrefix
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
return true
end
end
return nil, "unknown required import: " .. tostring(importId)
end
return RequiredImports
+60
View File
@@ -17,6 +17,7 @@ local ARCHIVE = {
local files, dirs, arch = {}, {}, {} local files, dirs, arch = {}, {}, {}
local fileDataMounts, pathMounts, stagedTemps = 0, 0, {} local fileDataMounts, pathMounts, stagedTemps = 0, 0, {}
local stagedEver = false local stagedEver = false
local failWriteOnce
local function resetFs() local function resetFs()
for k in pairs(files) do files[k] = nil end for k in pairs(files) do files[k] = nil end
@@ -25,6 +26,7 @@ local function resetFs()
fileDataMounts, pathMounts = 0, 0 fileDataMounts, pathMounts = 0, 0
stagedTemps = {} stagedTemps = {}
stagedEver = false stagedEver = false
failWriteOnce = nil
end end
local function dirChild(key, name) local function dirChild(key, name)
@@ -45,6 +47,10 @@ end
local vfs = {} local vfs = {}
function vfs.write(name, data) function vfs.write(name, data)
if failWriteOnce == name then
failWriteOnce = nil
return nil, "simulated write failure"
end
files[name] = data files[name] = data
if name:match("^mod_import_") then if name:match("^mod_import_") then
stagedTemps[name] = true stagedTemps[name] = true
@@ -171,6 +177,16 @@ eq(staged, 0, "FileData path leaves no staged temp zip")
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"install wrote manifest into mods/") "install wrote manifest into mods/")
-- A third-party archive cannot bypass modkit's no-baseroms packaging gate.
resetFs()
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = "packaged rom"
files["imports/mods/packaged-rom.zip"] = "PK\3\4packaged-rom"
ok, err = LauncherMods.installZip("imports/mods/packaged-rom.zip")
check(not ok, "an archive containing baseroms is rejected")
check(tostring(err):find("must not include", 1, true),
"baseroms archive rejection explains the policy")
ARCHIVE[MOD_ID .. "/baseroms/source.z64"] = nil
-- Fallback: no newFileData → stage temp + path mount -- Fallback: no newFileData → stage temp + path mount
resetFs() resetFs()
vfs.newFileData = nil vfs.newFileData = nil
@@ -205,6 +221,50 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"replace still lands in mods/<id>") "replace still lands in mods/<id>")
-- User-selected baseroms belong to the installation, not the downloaded mod
-- archive, and survive the same replacement path.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
:format(MOD_ID)
files["mods/OldFolder/main.lua"] = "return function() end\n"
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
files["imports/mods/update-with-rom.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/update-with-rom.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "replace with a baserom succeeds (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"replace preserves user-owned baseroms under the canonical mod folder")
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
"the shadow mod tree is still removed after preservation")
-- A preservation write failure keeps recovery bytes outside mods/, where
-- discovery cannot mistake a baseroms-only directory for an installed mod.
resetFs()
files["mods/OldFolder/manifest.json"] =
('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}')
:format(MOD_ID)
files["mods/OldFolder/main.lua"] = "return function() end\n"
files["mods/OldFolder/baseroms/stadium2.z64"] = "user-owned-rom"
files["imports/mods/preserve-fail.zip"] = "PK\3\4update"
failWriteOnce = "mods/" .. MOD_ID .. "/baseroms/stadium2.z64"
ok, err = LauncherMods.installZip("imports/mods/preserve-fail.zip",
{ replace = true, expectId = MOD_ID })
check(not ok, "preservation failure rejects the update")
check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil,
"preservation failure leaves no manifest-less tree under mods")
eq(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"],
"user-owned-rom", "preservation failure stages recovery outside mods")
files["imports/mods/preserve-retry.zip"] = "PK\3\4update"
ok, err = LauncherMods.installZip("imports/mods/preserve-retry.zip",
{ replace = true, expectId = MOD_ID })
check(ok == true, "retry restores staged baseroms (" .. tostring(err) .. ")")
eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
"retry restores the recovered baserom into the installed mod")
check(files["imports/baseroms-recovery/" .. MOD_ID .. "/stadium2.z64"] == nil,
"successful retry clears baserom recovery debris")
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not -- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
-- block a plain re-import as "already installed" -- block a plain re-import as "already installed"
resetFs() resetFs()
+256
View File
@@ -0,0 +1,256 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = love or require("tests.love_stub")
local Manifest = require("src.mods.Manifest")
local RequiredImports = require("src.mods.RequiredImports")
local Loader = require("src.mods.Loader")
local S = require("tests.harness").suite("required mod imports")
local check, eq = S.check, S.eq
local DIGEST = "0123456789abcdef0123456789abcdef"
local function fakeHash(data)
return data:sub(1, 4) == "\128\55\18\64" and DIGEST
or "ffffffffffffffffffffffffffffffff"
end
local manifest = Manifest.validate({
id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "stadium2", name = "Stadium 2", file = "stadium2.z64",
description = "USA dump", format = "n64", size = 8,
md5 = { DIGEST, DIGEST:upper() } },
},
}, "mods/stadium_fx")
eq(#manifest.required_imports, 1, "required import parses")
eq(#manifest.required_imports[1].md5, 1, "accepted MD5 values normalize and dedupe")
eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase")
eq(manifest.required_imports[1].description, "USA dump",
"import description is preserved for the picker UI")
eq(manifest.required_imports[1].size, 8, "exact import size parses")
local optionalManifest = Manifest.validate({
id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua",
optional_imports = {
{ id = "bonus", name = "Bonus ROM", file = "bonus.z64",
format = "n64", md5 = DIGEST },
},
}, "mods/optional_fx")
eq(#optionalManifest.optional_imports, 1, "optional import parses")
eq(optionalManifest.optional_imports[1].required, false,
"optional import is marked non-blocking")
local optionalRows, optionalMissing, missingOptional =
RequiredImports.inspect(optionalManifest, love.filesystem, fakeHash)
eq(optionalMissing, 0, "missing optional import is not required")
eq(missingOptional, 1, "missing optional import is reported separately")
check(not optionalRows[1].required, "optional row is labeled optional")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "../outside.z64", md5 = DIGEST } },
}), "required import cannot escape baseroms")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } },
}), "malformed MD5 is refused")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = ".rom.removed", md5 = DIGEST } },
}), "hidden import filenames cannot collide with engine metadata")
check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "rom.bin", md5 = DIGEST,
max_size = RequiredImports.MAX_BYTES + 1 } },
}), "manifest import sizes cannot exceed the hard limit")
local canonical = "\128\55\18\64ABCD"
local v64 = "\55\128\64\18BADC"
local n64 = "\64\18\55\128DCBA"
check(RequiredImports.importData(optionalManifest, "bonus", canonical,
{ hash = fakeHash }), "optional import uses the normal validation path")
eq(RequiredImports.normalizeN64(canonical), canonical, "z64 stays canonical")
eq(RequiredImports.normalizeN64(v64), canonical, "v64 pair swap canonicalizes")
eq(RequiredImports.normalizeN64(n64), canonical, "n64 word swap canonicalizes")
eq(RequiredImports.normalizeN64(string.rep("H", 512) .. v64), canonical,
"recognized 512-byte copier header is stripped")
check(RequiredImports.normalizeN64(string.rep("H", 520)) == nil,
"an arbitrary 512-byte prefix is not treated as a copier header")
local ok, digest = RequiredImports.importData(manifest, "stadium2", v64,
{ hash = fakeHash })
check(ok, "validated bytes import")
eq(digest, DIGEST, "import reports canonical digest")
eq(love.filesystem.read("mods/stadium_fx/baseroms/stadium2.z64"), canonical,
"import writes canonical bytes inside the mod")
local rows, missing = RequiredImports.inspect(manifest, love.filesystem, fakeHash)
eq(missing, 0, "written import satisfies its declaration")
check(rows[1].present, "inspection reports ready")
local target = Manifest.validate({
id = "other_fx", name = "Other FX", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST },
},
}, "mods/other_fx")
local targetRows, targetMissing = RequiredImports.inspect(target,
love.filesystem, fakeHash)
eq(targetMissing, 1, "matching hashes do not silently share another mod's import")
check(not targetRows[1].present,
"a mod needs its own explicit player-selected file")
check(RequiredImports.remove(target, "same_rom"), "a required import can be removed")
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil,
"remove deletes this mod's private copy")
check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }),
"choosing the file again clears the removal decision")
local legacy = Manifest.validate({
id = "legacy", name = "Legacy", version = "1.0.0", entry = "main.lua",
}, "mods/legacy")
local legacyTarget = Manifest.validate({
id = "legacy_user", name = "Legacy User", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "rom", file = "legacy-source.z64", format = "n64", md5 = DIGEST },
},
}, "mods/legacy_user")
love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64)
local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget,
love.filesystem, fakeHash)
eq(legacyMissing, 1, "undeclared files in another mod are never indexed")
check(not legacyRows[1].present, "legacy baseroms remain private to their mod")
local capped = Manifest.validate({
id = "capped", name = "Capped", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "small", file = "small.bin", md5 = DIGEST, max_size = 4 },
},
}, "mods/capped")
local tooLarge, sizeWhy = RequiredImports.validateData(
capped.required_imports[1], "12345", fakeHash)
eq(tooLarge, nil, "per-import size cap rejects before hashing")
check(tostring(sizeWhy):find("too large", 1, true) ~= nil,
"size rejection explains the limit")
-- A successful validation writes an engine receipt. Matching size + modtime
-- lets later launcher refreshes avoid reading and hashing the ROM again.
local cacheFiles = {
["mods/cache/baseroms/source.z64"] = canonical,
}
local dataReads = 0
local cacheModtime = 123
local cacheFs = {
getInfo = function(path, kind)
local data = cacheFiles[path]
if data then return { type = "file", size = #data, modtime = cacheModtime } end
return nil
end,
read = function(path)
if path == "mods/cache/baseroms/source.z64" then dataReads = dataReads + 1 end
return cacheFiles[path]
end,
write = function(path, data) cacheFiles[path] = data return true end,
remove = function(path) cacheFiles[path] = nil return true end,
}
local cacheManifest = Manifest.validate({
id = "cache", name = "Cache", version = "1.0.0", entry = "main.lua",
required_imports = {
{ id = "source", file = "source.z64", format = "n64", size = 8,
md5 = DIGEST },
},
}, "mods/cache")
local cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
check(cacheRows[1].present, "initial cached import validation succeeds")
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, function()
error("unchanged cached import should not be hashed again")
end)
check(cacheRows[1].present, "validation receipt satisfies the next refresh")
eq(dataReads, 1, "unchanged imported ROM is read only once")
cacheFiles["mods/cache/baseroms/source.z64"] = "BADBYTES"
cacheModtime = 124
cacheRows = RequiredImports.inspect(cacheManifest, cacheFs, fakeHash)
check(not cacheRows[1].present, "changed imported ROM bypasses a stale receipt")
eq(dataReads, 2, "changed imported ROM is read again")
eq(cacheFiles[RequiredImports.receiptPath(cacheManifest, cacheManifest.required_imports[1])],
nil, "stale validation receipt is removed")
local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
{ hash = fakeHash })
eq(rejected, nil, "mismatched selection is rejected")
check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil,
"normalization failure explains the selected format")
love.filesystem.write("mods/launcher_needs/manifest.json", ([[{
"id":"launcher_needs","name":"Launcher Needs","version":"1.0.0",
"entry":"main.lua","required_imports":[{"id":"source","name":"Source ROM",
"file":"source.bin","md5":"%s"}]
}]]):format(DIGEST))
love.filesystem.write("mods/launcher_needs/main.lua", "return function(mod) end")
local launcherRows = require("src.mods.LauncherMods").list()
eq(#launcherRows, 1, "launcher keeps a mod with a missing required import visible")
eq(launcherRows[1].missingRequiredImports, 1,
"launcher row carries the missing import count")
eq(launcherRows[1].status, "needs_import",
"missing import changes Ready to Import required")
check(launcherRows[1].statusDetail:find("Source ROM", 1, true) ~= nil,
"launcher warning names the missing file")
local function memfs(files)
return {
read = function(path) return files[path] end,
getInfo = function(path)
if files[path] then return { type = "file" } end
local prefix = path .. "/"
for key in pairs(files) do
if key:sub(1, #prefix) == prefix then return { type = "directory" } end
end
end,
getDirectoryItems = function(path)
if path == "mods" then
local out, seen = {}, {}
for key in pairs(files) do
local id = key:match("^mods/([^/]+)/manifest%.json$")
if id and not seen[id] then seen[id] = true; out[#out + 1] = id end
end
table.sort(out)
return out
end
return {}
end,
load = function(path)
local source = files[path]
if not source then return nil, "missing" end
return load(source, path)
end,
}
end
local manifestJson = ([[{
"id":"needs_rom","name":"Needs ROM","version":"1.0.0","entry":"main.lua",
"required_imports":[{"id":"rom","name":"Source ROM","file":"source.bin",
"md5":"%s"}]
}]]):format(DIGEST)
local loader = Loader.new({ fs = memfs({
["mods/needs_rom/manifest.json"] = manifestJson,
["mods/needs_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
}) })
check(loader:load({}) == false, "missing required import blocks the enabled mod")
local status = loader:status().available[1]
eq(status.state, "invalid", "blocked mod reports invalid")
check(status.error:find("Source ROM", 1, true) ~= nil,
"loader failure names the required import")
check(not (loader.exports.needs_rom and loader.exports.needs_rom.ran),
"blocked mod entry never executes")
local optionalJson = ([[{
"id":"optional_rom","name":"Optional ROM","version":"1.0.0","entry":"main.lua",
"optional_imports":[{"id":"rom","name":"Bonus ROM","file":"bonus.bin",
"md5":"%s"}]
}]]):format(DIGEST)
local optionalLoader = Loader.new({ fs = memfs({
["mods/optional_rom/manifest.json"] = optionalJson,
["mods/optional_rom/main.lua"] = "return function(mod) mod.exports.ran = true end",
}) })
check(optionalLoader:load({}), "missing optional import does not block the mod")
check(optionalLoader.exports.optional_rom.ran,
"mod entry executes without its optional import")
S.finish()
+4
View File
@@ -463,6 +463,8 @@ return function(mod)
end end
]]) ]])
write(bad .. "/hack.gb", "GBDATA") write(bad .. "/hack.gb", "GBDATA")
os.execute((mkdir .. " %q"):format(bad .. "/baseroms"))
write(bad .. "/baseroms/stadium2.z64", "USER ROM")
write(bad .. "/cachepath.lua", write(bad .. "/cachepath.lua",
'return { pic = "assets/generated/battle/front/mew.png" }') 'return { pic = "assets/generated/battle/front/mew.png" }')
@@ -474,6 +476,8 @@ check(out:find("MK101", 1, true) ~= nil, "schema typo reported as MK101")
check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field") check(out:find("base_stats", 1, true) ~= nil, "MK101 names the bad field")
check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301") check(out:find("MK301", 1, true) ~= nil, "cache reference reported as MK301")
check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303") check(out:find("MK303", 1, true) ~= nil, "ROM patch file reported as MK303")
check(out:find("MK307", 1, true) ~= nil,
"a user-supplied baseroms file is refused explicitly")
out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture") out, code = run(("%s tools/modkit.py pack %q -o %q --base fixture")
:format(python, bad, root .. "/bad.modpkg")) :format(python, bad, root .. "/bad.modpkg"))
@@ -14,6 +14,7 @@ love.system = love.system or {}
local saved = { local saved = {
getOS = love.system.getOS, getOS = love.system.getOS,
pickFile = love.system.pickFile, pickFile = love.system.pickFile,
pickFileKinds = love.system.pickFileKinds,
} }
local pickCalls = {} local pickCalls = {}
@@ -22,6 +23,7 @@ love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom" pickCalls[#pickCalls + 1] = kind or "rom"
return true return true
end end
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
local function freshImporter(ready) local function freshImporter(ready)
return setmetatable({ return setmetatable({
@@ -94,11 +96,83 @@ eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
check(love.filesystem.getInfo("picked_save.sav") == nil, check(love.filesystem.getInfo("picked_save.sav") == nil,
"successful focus import removes picked_save.sav") "successful focus import removes picked_save.sav")
-- Required mod files use their own safe picker kind and pending filename.
pickCalls = {}
ri = freshImporter({ red = true, blue = true })
ri.nativePicker = true
ri.mobileFileBridge = true
ri.mods = { {
id = "needs_source",
manifest = { id = "needs_source", name = "Needs Source",
required_imports = { { id = "source", name = "Source", file = "source.bin",
format = "raw", md5 = { "00000000000000000000000000000000" } } } },
} }
ri:chooseRequiredImport("needs_source", "source")
eq(pickCalls[1], "required_import",
"required file asks for the dedicated picker kind")
eq(ri.pickerPendingModId, "needs_source", "pending mod is remembered")
eq(ri.pickerPendingImportId, "source", "pending import is remembered")
-- A rejected selection stays on the imported-files page, where the player can
-- see it before choosing another file, instead of behind the modal.
ri.nativePicker = false
ri._importRequiredData = RomImporter._importRequiredData
local savedData = love.data
love.data = {
hash = function() return "not accepted" end,
encode = function() return "ffffffffffffffffffffffffffffffff" end,
}
ri:_importRequiredData("needs_source", "source", "wrong source bytes")
love.data = savedData
check(ri.requiredImportNotice ~= nil,
"required import rejection creates an in-modal notice")
eq(ri.requiredImportNotice.modId, "needs_source",
"required import notice identifies its mod")
eq(ri.requiredImportNotice.importId, "source",
"required import notice identifies its file")
check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil,
"required import notice includes the MD5 failure")
check(ri.modNotice == nil,
"required import rejection is not hidden in the general Mods notice")
-- Reported size is checked before the selected file is read into Lua.
local savedGetInfo = love.filesystem.getInfo
love.filesystem.getInfo = function(name, kind)
if name == "oversized_required.bin" then
return { type = "file", size = 10 }
end
return savedGetInfo(name, kind)
end
ri.mods[1].manifest.required_imports[1].max_size = 4
ri._importRequiredSource = RomImporter._importRequiredSource
ri._importRequiredData = function(self) self._oversizedWasRead = true end
ri:_importRequiredSource("needs_source", "source", "oversized_required.bin")
check(not ri._oversizedWasRead, "oversized required file is rejected before import")
check(ri.requiredImportNotice.text:find("too large", 1, true) ~= nil,
"oversized required file reports its size error in the modal")
ri.mods[1].manifest.required_imports[1].max_size = nil
love.filesystem.getInfo = savedGetInfo
ri.nativePicker = true
ri._importRequiredSource = function(self, modId, importId, source)
self._requiredImported = { modId = modId, importId = importId, source = source }
return true
end
love.filesystem.write("picked_required_import.bin", "source bytes")
ri:focus(true)
check(ri._requiredImported ~= nil, "focus consumes a required-file SAF pick")
eq(ri._requiredImported.modId, "needs_source", "focus routes to the pending mod")
eq(ri._requiredImported.importId, "source", "focus routes to the pending declaration")
check(love.filesystem.getInfo("picked_required_import.bin") == nil,
"focus removes the staged required-file pick")
love.system.getOS = saved.getOS love.system.getOS = saved.getOS
love.system.pickFile = saved.pickFile love.system.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds
-- leftover cleanup if a failed assertion left files behind -- leftover cleanup if a failed assertion left files behind
love.filesystem.remove("usb_mod.zip") love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip") love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav") love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin")
S.finish() S.finish()
+8
View File
@@ -1045,6 +1045,14 @@ def lint_dir(repo, mod_dir, manifest):
for rel in mod_files(mod_dir): for rel in mod_files(mod_dir):
path = os.path.join(mod_dir, rel) path = os.path.join(mod_dir, rel)
ext = os.path.splitext(rel)[1].lower() ext = os.path.splitext(rel)[1].lower()
# MK307: required_imports are user-owned installation state. Keeping
# baseroms in the walk without an explicit gate would let `pack`
# silently bundle exactly the ROM this feature exists not to ship.
if rel.startswith("baseroms/"):
findings.append(Finding(
"MK307", "error",
"user-supplied baseroms must not be distributed", rel))
continue
# MK301: nothing may live in (or point into) the generated trees # MK301: nothing may live in (or point into) the generated trees
if rel.startswith(("data/generated/", "assets/generated/")): if rel.startswith(("data/generated/", "assets/generated/")):
findings.append(Finding( findings.append(Finding(