Add manifest-driven required mod imports

This commit is contained in:
anxiousintrovert
2026-08-14 14:22:46 -05:00
parent 545a99d86d
commit 542856c83d
19 changed files with 1224 additions and 14 deletions
+5
View File
@@ -79,3 +79,8 @@ mobile/ios/bundle_id.local
# Local options / preferences
/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/
+41
View File
@@ -44,6 +44,23 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
"optional_dependencies": [
"gen1_modern_ui"
],
"required_imports": [
{
"id": "stadium2",
"name": "Pokemon Stadium 2 ROM",
"file": "stadium2.z64",
"format": "n64",
"md5": ["00000000000000000000000000000000"]
}
],
"optional_imports": [
{
"id": "bonus_source",
"name": "Optional bonus source",
"file": "bonus.bin",
"md5": "00000000000000000000000000000000"
}
],
"conflicts": [],
"permissions": ["engine_internals"],
"description": "A brief description of the mod.",
@@ -67,6 +84,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). |
| `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. |
| `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. |
| `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). |
| `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. |
@@ -91,6 +110,28 @@ Dependencies in `dependencies` and `optional_dependencies` can be declared in se
#### 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.
### 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"`.
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>`. Another installed mod with an overlapping
accepted MD5 automatically supplies a copy, so the player only selects a ROM
once. 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; it is not used as a security or authenticity
guarantee. Mod archives must not include anything beneath `baseroms/`.
## Mods and Gold (Gen 2)
The mod API is one API across both generations, but Gold runs its own battle
+105
View File
@@ -0,0 +1,105 @@
# RFC 0010 — Manifest-declared user imports for mods
## Status
Proposed. Engine: `Manifest.lua`, `RequiredImports.lua`, `Loader.lua`,
`LauncherMods.lua`. Launcher: `RomImporter.lua`, `LauncherView.lua`. Native
picker bridges: Android and iOS.
## Motivation
Some mods derive presentation data from another cartridge the player owns.
Shipping those bytes in a mod is not acceptable, while asking each mod to
escape the sandbox, implement native pickers, and maintain platform-specific
paths defeats the sandbox's purpose. Pokemon Stadium and Pokemon Stadium 2 are
the first concrete consumers, but the ownership/import problem is generic.
## The decision it extends
This extends the manifest as the engine-owned declaration of mod dependencies
and preserves the sandbox rule in `src/mods/Sandbox.lua`: mods do not receive
raw host filesystem access. It also extends the launcher's established ROM,
save, and mod archive picker/inbox flows instead of introducing a second host
integration.
## Exact manifest delta
Additive `required_imports` and `optional_imports` arrays are accepted:
```json
{
"required_imports": [{
"id": "stadium2",
"name": "Pokemon Stadium 2 ROM",
"file": "stadium2.z64",
"format": "n64",
"md5": ["00000000000000000000000000000000"]
}]
}
```
Both arrays use the same object schema. A missing `required_imports` entry
blocks the mod; a missing `optional_imports` entry only leaves that bonus
functionality unavailable.
- `id` is unique within the manifest and uses the mod-id vocabulary.
- `name` is launcher-facing text.
- `file` is one safe filename below the mod's `baseroms/` directory.
- `md5` is one 32-digit hexadecimal digest or a non-empty array of accepted
digests. MD5 is a known-dump identity convention, not a trust primitive.
- `format` is `raw` by default. `n64` recognizes a canonical big-endian dump,
pair-byte-swapped and little-endian-word dumps, with or without a recognized
512-byte copier header. Validation and stored output use canonical big-endian
bytes.
The launcher copies a validated file to
`mods/<manifest.id>/baseroms/<file>`. Missing required imports make the launcher
row need attention and make the loader refuse that enabled mod before its entry
chunk runs. Missing optional imports remain selectable without changing load
status. A matching validated import owned by another installed mod is
copied automatically. Replace and remove remain explicit per-mod actions.
Desktop and UWP use their existing native/host picker routes. Android and iOS
add a `required_import` picker kind which stages
`picked_required_import.bin`. NX scans the engine-owned
`imports/baseroms/` MTP inbox. All writes continue through `CacheFs`, preserving
portable-mode placement.
`modkit validate` and `modkit pack` report MK307 for every file beneath a
source mod's `baseroms/` directory, so the packaging path cannot accidentally
distribute a file the launcher placed there. The installer also rejects a mod
archive containing `baseroms/` files, covering packages built without modkit.
## Mod-facing API and sandbox statement
There is no new runtime API and no new permission. A mod reads its own copied
file through the existing `mod:read("baseroms/<file>")` capability. It never
learns the selected host path, cannot browse another mod's tree, and receives
no raw `io`, `love.filesystem`, or platform-picker access.
## Migration and compatibility
Existing manifests omit both import arrays and behave exactly as before. The
fields are additive for both manifest API levels. Mods currently maintaining
their own cross-platform picker can declare the source file and remove that
host integration; their processing code changes only to read the declared
`baseroms/<file>` path.
## Parity tests
- Empty/absent `required_imports` leaves existing manifests and loader behavior
unchanged.
- Manifest validation refuses path traversal, duplicate ids/files, malformed
MD5 values, and unknown normalization formats.
- N64 byte orders and the recognized copier-header form produce identical
canonical bytes before MD5 validation.
- A mismatched selection is never written.
- An enabled mod with a missing declared file never executes its entry chunk.
- A matching import in another installed mod is copied into the requesting
mod's own `baseroms/` directory.
- The packaging gate refuses every `baseroms/` file.
- The launcher refuses an archive that contains `baseroms/` files.
## Deprecation etiquette
Nothing deprecated or removed.
@@ -192,8 +192,12 @@ bool System::pickFile(const char *kind) const
dest = "picked_mod.zip";
else if (strcmp(kind, "sav") == 0 || strcmp(kind, "save") == 0)
dest = "picked_save.sav";
else if (strcmp(kind, "required_import") == 0)
dest = "picked_required_import.bin";
else if (strcmp(kind, "rom") == 0)
dest = "picked_rom.gb";
else
return false;
}
return love::android::showFilePicker(dest);
#else
@@ -202,6 +206,15 @@ bool System::pickFile(const char *kind) const
#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
{
#ifdef LOVE_ANDROID
@@ -112,10 +112,12 @@ public:
* love::android::showFilePicker and src/import/RomImporter.lua.
*
* @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.
**/
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
@@ -102,6 +102,12 @@ int w_pickFile(lua_State *L)
return 1;
}
int w_pickFileKinds(lua_State *L)
{
luax_pushstring(L, instance()->pickFileKinds());
return 1;
}
int w_createFile(lua_State *L)
{
const char *suggested = luaL_optstring(L, 1, nullptr);
@@ -222,6 +228,7 @@ static const luaL_Reg functions[] =
{ "openURL", w_openURL },
{ "vibrate", w_vibrate },
{ "pickFile", w_pickFile },
{ "pickFileKinds", w_pickFileKinds },
{ "createFile", w_createFile },
{ "syncHealthSteps", w_syncHealthSteps },
{ "restartApp", w_restartApp },
+4 -2
View File
@@ -5,7 +5,7 @@
// without updating that patch (see mobile/ios/patch_love_src.py).
//
// 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 /
// picked_save.sav; RomImporter's pending-file scan consumes it.
// love.system.createFile(name) -> exports save dir's pending_export.sav
@@ -83,6 +83,8 @@ public final class GRPickerBridge: NSObject {
types = [.zip]
case "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 --
// 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
@@ -135,7 +137,7 @@ public final class GRPickerBridge: NSObject {
// Kept beside the switch it describes, because the two drifting apart is
// the only way this can lie.
@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:)
+2 -1
View File
@@ -89,7 +89,8 @@ int w_pickFile(lua_State *L)
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.
// An unknown kind is refused (GRPickerBridge), and a refusal looks exactly
+118 -2
View File
@@ -638,6 +638,7 @@ end
local function modStatusColor(status)
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
-- not a fault: the mod is intact, this is simply not a game it is for
-- (src/mods/ModTargets.lua)
@@ -2767,11 +2768,14 @@ local function buildModActionsModal(imp, m)
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 imports = mod.imports or mod.requiredImports
local hasImports = imports and #imports > 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) + (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)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
@@ -2821,6 +2825,19 @@ local function buildModActionsModal(imp, m)
end })
cy = cy + m.btnH + gap
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)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
DELETE_LABEL(armed), {
@@ -2837,6 +2854,103 @@ local function buildModActionsModal(imp, m)
action = function() imp._modActions = nil 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(56 * 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 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,
cy + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(3 * m.s),
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 /
-- Details / Source live here instead of crowding every row.
local function buildFindEntryModal(imp, m)
@@ -3377,7 +3491,8 @@ local function modalUp(imp)
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._appPatchNotes
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._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
@@ -3509,6 +3624,7 @@ local function buildModals(imp, m)
end
if imp._modVersions then buildVersionsModal(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
-- 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
+205 -5
View File
@@ -981,6 +981,25 @@ local function findPendingSav(preferAny, skip)
return nil
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,
-- 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
@@ -1112,6 +1131,39 @@ local function chooseSav()
return nil
end
-- Generic user-supplied dependency picker. Validation is manifest-driven,
-- so the dialog intentionally permits every file extension; a wrong choice
-- cannot reach the mod because its canonical MD5 must match first.
local function chooseRequiredFile(label)
local prompt = shellSafe("Choose " .. tostring(label or "required 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,
-- interactive launcher with no scripted-run override. A dev / source checkout
-- (unfused, where Boot.run already no-ops) or an autopilot / driver /
@@ -1230,7 +1282,9 @@ function RomImporter.new(onComplete, opts)
-- (refreshed lazily on first draw and after any toggle/install/delete);
-- modScroll is the current paged list's inner scroll offset (px, clamped
-- 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 =
-- every game). Rows resolve their enable-state and their "runs here"
-- verdict against it (src/mods/ModTargets.lua).
@@ -1420,7 +1474,13 @@ function RomImporter:focus(f)
local text = "Could not read the picked file. Reopen the picker and choose "
.. "it with the Files (Documents) app, or copy it into: "
.. 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 }
elseif pickError:find("picked_save", 1, true) then
local version = self.androidPendingVersion or self:_savedropTarget()
@@ -1431,6 +1491,20 @@ function RomImporter:focus(f)
end
return
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)
if modName then
self:_installMod(modName)
@@ -1738,6 +1812,123 @@ function RomImporter:chooseMod()
if path then self:_installMod(path) 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 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 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
for _, candidate in ipairs(require("src.mods.RequiredImports").specs(manifest)) do
if candidate.id == importId then spec = candidate break end
end
if not spec then return end
if self.isNX then
local inbox = "imports/baseroms"
love.filesystem.createDirectory(inbox)
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name
local data = love.filesystem.read(path)
if data and self:_importRequiredData(modId, importId, data) then return end
end
end
requiredImportNotice(self, modId, importId,
"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(spec.name)
if path then self:_importRequiredSource(modId, importId, path) end
end
-- 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
-- showing, default to red -- the always-present first game -- rather than
@@ -2045,7 +2236,8 @@ function RomImporter:_pollPickedFiles(dt)
if not found then
for _, name in ipairs(love.filesystem.getDirectoryItems("")) do
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
break
end
@@ -2174,7 +2366,12 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion
self.pickerPendingKind = 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)
if Platform.isUWP() and self.modNotice and self.modNotice.ok then
os.remove(path)
@@ -2196,7 +2393,10 @@ function RomImporter:update(dt)
local version = self.pickerPendingVersion or self:_savedropTarget()
self.pickerPendingKind = 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 }
elseif kind == "sav" then
self.saveNotice[version] = { ok = false, text = errorText }
+71 -1
View File
@@ -38,6 +38,7 @@ local Version = require("src.core.Version")
local SaveData = require("src.core.SaveData")
local GameVersion = require("src.core.GameVersion")
local CacheFs = require("src.import.CacheFs")
local RequiredImports = require("src.mods.RequiredImports")
local LauncherMods = {}
@@ -459,13 +460,33 @@ function LauncherMods.list(version)
local ok, result = pcall(function()
local options = SaveData.loadOptions()
local manifests = discover()
-- A validated copy owned by another installed mod can satisfy the same
-- declared MD5 without asking the player to select the ROM twice.
local _, importState = RequiredImports.reconcile(manifests)
-- The first build containing game-specific switches turns the old shared
-- state into one explicit answer per installed mod and game. Saving here
-- means users who only visit the launcher still receive the migration.
if SaveData.migrateModEnablement(options, manifests) then
SaveData.saveOptions(options)
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)
if not ok then
-- a single bad options/mod file must not blank the launcher
@@ -677,6 +698,26 @@ local function copyTree(src, dst)
return true
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
-- 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
@@ -919,6 +960,12 @@ function LauncherMods._installZipInner(source, opts)
return nil, ("zip is for '%s', expected '%s'")
:format(manifest.id, opts.expectId)
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 existing, installedSomewhere = sameIdTrees(fs, manifest.id)
@@ -926,7 +973,11 @@ function LauncherMods._installZipInner(source, opts)
cleanup()
return nil, "a mod named '" .. manifest.id .. "' is already installed"
end
local preservedBaseroms = {}
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
-- under another name, or the survivor keeps winning discover()'s
-- first-id-wins race after the "successful" update (#801). A tree with
@@ -950,6 +1001,25 @@ function LauncherMods._installZipInner(source, opts)
CacheFs.prefix = ""
local copied, copyErr = copyTree(root, dest)
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. Keep
-- a best-effort baseroms-only tree for the next retry instead.
removeTree(dest)
for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then CacheFs.write(dest .. "/baseroms/" .. rel, bytes) end
end
copied, copyErr = nil, preserveErr
end
CacheFs.prefix = savedPrefix
if not copied then
cleanup()
+19 -1
View File
@@ -4,6 +4,7 @@ local SaveData = require("src.core.SaveData")
local Data = require("src.core.Data")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local RequiredImports = require("src.mods.RequiredImports")
local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime")
@@ -565,7 +566,24 @@ function Loader:_validate()
elseif manifest.assets_transforms
and not self:_exists(mod.path .. "/" .. manifest.assets_transforms) then
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 data = self.fs.read and self.fs.read(path)
local valid, importErr = RequiredImports.validateStoredData(import, data)
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)
if not ok then
reason = ("needs game version %s, engine is %s")
+77 -1
View File
@@ -132,13 +132,74 @@ local function mergeConflictLists(conflicts, incompatible)
return out
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(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")
out[#out + 1] = {
id = id,
name = scrubUtf8(name),
file = file,
md5 = accepted,
format = format,
required = required ~= false,
}
end
return out
end
-- Drop bytes that are not valid UTF-8 (malformed sequences, overlongs,
-- 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
-- panel may draw must be scrubbed here -- the one place every mod manifest
-- passes through -- or a single mangled description crashes the whole MODS
-- panel instead of misrendering one card.
local function scrubUtf8(s)
scrubUtf8 = function(s)
if type(s) ~= "string" then return s end
s = s:gsub("^\239\187\191", "")
local out, i, n = {}, 1, #s
@@ -290,6 +351,19 @@ function Manifest.validate(raw, path)
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 {
id = raw.id,
name = raw.name,
@@ -318,6 +392,8 @@ function Manifest.validate(raw, path)
permissionSet = permissionSet,
options_schema = optionalFile(raw.options_schema, "options_schema"),
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
force_enable_env = optionalString(raw.force_enable_env, "force_enable_env"),
path = path,
+266
View File
@@ -0,0 +1,266 @@
-- 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
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, "not a recognized Nintendo 64 ROM" 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/." .. spec.id .. ".removed"
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 normalized, normalizeErr = RequiredImports.normalize(spec, data)
if not normalized then return nil, normalizeErr 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
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 data = fs and fs.read and fs.read(path) or nil
local normalized, detail
if data then
normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
end
local row = { id = spec.id, name = spec.name, file = spec.file,
format = spec.format, path = path, present = normalized ~= nil,
digest = normalized and detail or nil,
error = data and not normalized 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 = ""
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
if ok then CacheFs.remove(removedMarker(manifest, spec)) 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))
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
-- Fill missing imports from another installed mod when its accepted canonical
-- MD5 overlaps. The source remains inside the engine-owned mods tree, and a
-- fresh validation is performed before every copy.
function RequiredImports.reconcile(manifests, fs, hashFn)
fs = fs or (love and love.filesystem)
if not (fs and fs.read) then return {}, {} end
local available, state, declaredPaths = {}, {}, {}
for _, manifest in ipairs(manifests or {}) do
local rows, missing, missingOptional = {}, 0, 0
for _, spec in ipairs(allSpecs(manifest)) do
local path = RequiredImports.path(manifest, spec)
declaredPaths[path] = true
local data = fs.read(path)
local suppressed = fs.getInfo
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
local normalized, detail
if data then
normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
if normalized then available[detail] = normalized end
end
local row = { id = spec.id, name = spec.name, file = spec.file,
format = spec.format, path = path, present = normalized ~= nil,
digest = normalized and detail or nil,
error = data and not normalized 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
state[manifest.id] = { rows = rows, missing = missing,
missingOptional = missingOptional }
end
-- Compatibility with mods that already maintained their own baseroms
-- folder before this manifest field existed: index every other file in an
-- installed mod's folder by both raw and (when recognizable) canonical N64
-- MD5. Nothing outside the engine-owned mods tree is searched.
if fs.getDirectoryItems and fs.getInfo then
for _, manifest in ipairs(manifests or {}) do
local dir = manifest.path .. "/baseroms"
if fs.getInfo(dir, "directory") then
for _, name in ipairs(fs.getDirectoryItems(dir) or {}) do
local path = dir .. "/" .. name
if name:sub(1, 1) ~= "." and not declaredPaths[path]
and fs.getInfo(path, "file") then
local data = fs.read(path)
if data then
local rawDigest = hexDigest(data, hashFn)
if rawDigest then available[rawDigest] = data end
local canonical = RequiredImports.normalizeN64(data)
if canonical then
local canonicalDigest = hexDigest(canonical, hashFn)
if canonicalDigest then available[canonicalDigest] = canonical end
end
end
end
end
end
end
end
local copied = {}
for _, manifest in ipairs(manifests or {}) do
local entry = state[manifest.id]
for _, row in ipairs(entry.rows) do
if not row.present and not row.suppressed then
for _, digest in ipairs(row.spec.md5) do
local data = available[digest]
if data then
local ok = RequiredImports.importData(manifest, row.id, data,
{ hash = hashFn })
if ok then
copied[#copied + 1] = { mod = manifest.id, import = row.id,
digest = digest }
row.present, row.digest, row.error = true, digest, nil
if row.required then entry.missing = entry.missing - 1
else entry.missingOptional = entry.missingOptional - 1 end
end
break
end
end
end
end
end
return copied, state
end
return RequiredImports
+27
View File
@@ -171,6 +171,16 @@ eq(staged, 0, "FileData path leaves no staged temp zip")
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"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
resetFs()
vfs.newFileData = nil
@@ -205,6 +215,23 @@ check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil,
check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil,
"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")
-- #834: a manifest-less mods/<id> tree (interrupted copy debris) must not
-- block a plain re-import as "already installed"
resetFs()
+193
View File
@@ -0,0 +1,193 @@
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",
format = "n64", 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")
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")
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 copied = RequiredImports.reconcile({ manifest, target }, love.filesystem, fakeHash)
eq(#copied, 1, "matching installed import is reused")
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), canonical,
"reuse creates a private per-mod copy")
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")
local copiedAfterRemove = RequiredImports.reconcile({ manifest, target },
love.filesystem, fakeHash)
eq(#copiedAfterRemove, 0,
"an explicit removal is not immediately undone by automatic reuse")
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 legacyCopied = RequiredImports.reconcile({ legacy, legacyTarget },
love.filesystem, fakeHash)
eq(#legacyCopied, 1, "an undeclared legacy baserom can satisfy a new declaration")
eq(love.filesystem.read("mods/legacy_user/baseroms/legacy-source.z64"), canonical,
"legacy reuse still stores canonical bytes")
local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
{ hash = fakeHash })
eq(rejected, nil, "mismatched selection is rejected")
check(tostring(why):find("Nintendo 64", 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
]])
write(bad .. "/hack.gb", "GBDATA")
os.execute((mkdir .. " %q"):format(bad .. "/baseroms"))
write(bad .. "/baseroms/stadium2.z64", "USER ROM")
write(bad .. "/cachepath.lua",
'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("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("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")
:format(python, bad, root .. "/bad.modpkg"))
@@ -14,6 +14,7 @@ love.system = love.system or {}
local saved = {
getOS = love.system.getOS,
pickFile = love.system.pickFile,
pickFileKinds = love.system.pickFileKinds,
}
local pickCalls = {}
@@ -22,6 +23,7 @@ love.system.pickFile = function(kind)
pickCalls[#pickCalls + 1] = kind or "rom"
return true
end
love.system.pickFileKinds = function() return "rom,mod,sav,required_import" end
local function freshImporter(ready)
return setmetatable({
@@ -94,11 +96,65 @@ eq(ri._imported.source, "picked_save.sav", "focus reads the SAF save filename")
check(love.filesystem.getInfo("picked_save.sav") == nil,
"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")
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.pickFile = saved.pickFile
love.system.pickFileKinds = saved.pickFileKinds
-- leftover cleanup if a failed assertion left files behind
love.filesystem.remove("usb_mod.zip")
love.filesystem.remove("picked_mod.zip")
love.filesystem.remove("picked_save.sav")
love.filesystem.remove("picked_required_import.bin")
S.finish()
+8
View File
@@ -1045,6 +1045,14 @@ def lint_dir(repo, mod_dir, manifest):
for rel in mod_files(mod_dir):
path = os.path.join(mod_dir, rel)
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
if rel.startswith(("data/generated/", "assets/generated/")):
findings.append(Finding(