Address required import review feedback

This commit is contained in:
anxiousintrovert
2026-08-14 17:08:23 -05:00
parent dfc216f974
commit c48fc578ca
12 changed files with 384 additions and 144 deletions
+1
View File
@@ -84,3 +84,4 @@ mobile/ios/bundle_id.local
# destinations, but source checkouts and packaged mods never ship the files. # destinations, but source checkouts and packaged mods never ship the files.
/mods/*/baseroms/ /mods/*/baseroms/
/imports/baseroms/ /imports/baseroms/
/imports/baseroms-recovery/
+22 -6
View File
@@ -48,8 +48,10 @@ Every mod contains a root `manifest.json` defining its metadata, supported games
{ {
"id": "stadium2", "id": "stadium2",
"name": "Pokemon Stadium 2 ROM", "name": "Pokemon Stadium 2 ROM",
"description": "Pokemon Stadium 2 (USA), any supported N64 byte order",
"file": "stadium2.z64", "file": "stadium2.z64",
"format": "n64", "format": "n64",
"size": 67108864,
"md5": ["00000000000000000000000000000000"] "md5": ["00000000000000000000000000000000"]
} }
], ],
@@ -116,21 +118,35 @@ When a mod supports multiple games (`"games": ["gen1", "gen2"]`), a dependency c
out of mod archives while giving every platform the same installation flow. out of mod archives while giving every platform the same installation flow.
Each object requires a stable `id`, a display `name`, a destination `file` 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 (a filename, never a path), and one MD5 digest or an array of accepted MD5
digests. `format` is either `"raw"` (the default) or `"n64"`. 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, For `"n64"`, the launcher recognizes `.z64`, `.v64`, and `.n64` byte orders,
strips a recognized 512-byte copier header, converts the bytes to canonical 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 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 to `mods/<mod-id>/baseroms/<file>`. Each selection is a private grant to that
accepted MD5 automatically supplies a copy, so the player only selects a ROM mod: the launcher never scans or copies another mod's imported files merely
once. Mods read the result with their existing scoped `mod:read` API, for 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 example `mod:read("baseroms/stadium2.z64")`; no host path or new filesystem
permission is exposed. Missing `required_imports` block the mod before its permission is exposed. Missing `required_imports` block the mod before its
entry chunk runs; missing `optional_imports` remain visible in the same entry chunk runs; missing `optional_imports` remain visible in the same
launcher panel but do not block loading. launcher panel but do not block loading.
MD5 here identifies a known dump; it is not used as a security or authenticity MD5 here identifies a known dump because ROM databases commonly publish it;
guarantee. Mod archives must not include anything beneath `baseroms/`. 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)
@@ -196,6 +196,8 @@ bool System::pickFile(const char *kind) const
dest = "picked_required_import.bin"; 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 else
return false; return false;
} }
+9 -3
View File
@@ -2877,7 +2877,7 @@ local function buildRequiredImportsModal(imp, m)
end end
local noticeW = w - 2 * pad local noticeW = w - 2 * pad
local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0 local noticeH = noticeText and Kit.wrapHeight("small", noticeText, noticeW, 2) or 0
local rowH = math.max(math.floor(56 * m.s), m.btnH) local rowH = math.max(math.floor(70 * m.s), m.btnH)
local perPage = math.min(4, math.max(1, #imports)) 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 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) local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
@@ -2914,12 +2914,18 @@ local function buildRequiredImportsModal(imp, m)
local textW = actionX - innerX - math.floor(8 * m.s) local textW = actionX - innerX - math.floor(8 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX, Kit.text("small", Kit.ellipsize("small", row.name, textW), innerX,
cy + math.floor(8 * m.s), PAL.heading) 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) local state = row.present and Strings("Ready - %s", row.file)
or (row.error and Strings("Invalid file - choose again") or (row.error and Strings("Invalid file - choose again")
or (row.required and Strings("Required - %s", row.file) or (row.required and Strings("Required - %s", row.file)
or Strings("Optional - %s", row.file))) or Strings("Optional - %s", row.file)))
Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, Kit.text("micro", Kit.ellipsize("micro", state, textW), innerX, stateY,
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)) 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, btn(imp, actionX, cy + (rowH - m.btnH) / 2, actionW, m.btnH,
"req-pick-" .. mod.id .. "-" .. importId, "req-pick-" .. mod.id .. "-" .. importId,
+50 -13
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
@@ -1131,11 +1139,11 @@ local function chooseSav()
return nil return nil
end end
-- Generic user-supplied dependency picker. Validation is manifest-driven, -- Generic user-supplied dependency picker. Keep the native prompt entirely
-- so the dialog intentionally permits every file extension; a wrong choice -- engine-owned: manifest labels are untrusted and must never enter shell
-- cannot reach the mod because its canonical MD5 must match first. -- command templates. The LÖVE modal already shows the specific import name.
local function chooseRequiredFile(label) local function chooseRequiredFile()
local prompt = shellSafe("Choose " .. tostring(label or "required file")) local prompt = shellSafe(Strings("Choose required mod file"))
local platform = love.system.getOS() local platform = love.system.getOS()
if platform == "OS X" then if platform == "OS X" then
return commandOutput( return commandOutput(
@@ -1819,6 +1827,13 @@ local function requiredManifest(self, modId)
return nil return nil
end 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) local function requiredImportNotice(self, modId, importId, text)
self.requiredImportNotice = { self.requiredImportNotice = {
modId = modId, modId = modId,
@@ -1850,6 +1865,21 @@ function RomImporter:_importRequiredData(modId, importId, data)
end end
function RomImporter:_importRequiredSource(modId, importId, source) 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) local data = love.filesystem.read(source)
if not data then data = readExternalPath(source) end if not data then data = readExternalPath(source) end
if not data then if not data then
@@ -1881,24 +1911,31 @@ function RomImporter:chooseRequiredImport(modId, importId)
if self.workState == "working" then return end if self.workState == "working" then return end
local manifest = requiredManifest(self, modId) local manifest = requiredManifest(self, modId)
if not manifest then return end if not manifest then return end
local spec local spec = requiredSpec(manifest, importId)
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 not spec then return end
if self.isNX then if self.isNX then
local inbox = "imports/baseroms" local inbox = "imports/baseroms"
love.filesystem.createDirectory(inbox) love.filesystem.createDirectory(inbox)
local lastError
for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do for _, name in ipairs(love.filesystem.getDirectoryItems(inbox) or {}) do
if name:sub(1, 1) ~= "." then if name:sub(1, 1) ~= "." then
local path = inbox .. "/" .. name local path = inbox .. "/" .. name
local data = love.filesystem.read(path) 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 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
end end
requiredImportNotice(self, modId, importId, requiredImportNotice(self, modId, importId, lastError
"No matching file in imports/baseroms/. Copy it there over MTP, then try again.") or "No matching file in imports/baseroms/. Copy it there over MTP, then try again.")
self.modNotice = nil self.modNotice = nil
return return
end end
@@ -1925,7 +1962,7 @@ function RomImporter:chooseRequiredImport(modId, importId)
return return
end end
local path = chooseRequiredFile(spec.name) local path = chooseRequiredFile()
if path then self:_importRequiredSource(modId, importId, path) end if path then self:_importRequiredSource(modId, importId, path) end
end end
+18 -6
View File
@@ -460,9 +460,14 @@ 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()
-- A validated copy owned by another installed mod can satisfy the same -- Imports are private player grants. Never scan or copy another mod's
-- declared MD5 without asking the player to select the ROM twice. -- baseroms: a matching public digest is not permission to share the file.
local _, importState = RequiredImports.reconcile(manifests) 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.
@@ -968,12 +973,16 @@ function LauncherMods._installZipInner(source, opts)
end 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 = {} 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 for _, path in ipairs(existing) do
snapshotTree(path .. "/baseroms", preservedBaseroms) snapshotTree(path .. "/baseroms", preservedBaseroms)
@@ -1012,13 +1021,16 @@ function LauncherMods._installZipInner(source, opts)
end end
end end
if preserveErr then if preserveErr then
-- Do not report a successful update that discarded user-owned input. Keep -- Do not report a successful update that discarded user-owned input, and
-- a best-effort baseroms-only tree for the next retry instead. -- do not leave a manifest-less baseroms tree that resembles an install.
removeTree(dest) removeTree(dest)
removeTree(baseromRecovery)
for rel, bytes in pairs(preservedBaseroms) do for rel, bytes in pairs(preservedBaseroms) do
if bytes ~= nil then CacheFs.write(dest .. "/baseroms/" .. rel, bytes) end if bytes ~= nil then CacheFs.write(baseromRecovery .. "/" .. rel, bytes) end
end end
copied, copyErr = nil, preserveErr copied, copyErr = nil, preserveErr
elseif copied then
removeTree(baseromRecovery)
end end
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not copied then if not copied then
+2 -2
View File
@@ -574,8 +574,8 @@ function Loader:_validate()
reason = "required import missing: " .. import.name reason = "required import missing: " .. import.name
break break
end end
local data = self.fs.read and self.fs.read(path) local valid, importErr = RequiredImports.validateStored(
local valid, importErr = RequiredImports.validateStoredData(import, data) manifest, import, self.fs)
if not valid then if not valid then
reason = "required import invalid: " .. import.name reason = "required import invalid: " .. import.name
.. " (" .. tostring(importErr) .. ")" .. " (" .. tostring(importErr) .. ")"
+23
View File
@@ -157,6 +157,8 @@ local function parseImports(value, field, required)
file = SafePath.require(file, field .. " file") file = SafePath.require(file, field .. " file")
assert(not file:find("/", 1, true), assert(not file:find("/", 1, true),
field .. " file must be a filename inside baseroms") 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) assert(not files[file], "duplicate " .. field .. " file: " .. file)
files[file] = true files[file] = true
@@ -181,12 +183,33 @@ local function parseImports(value, field, required)
local name = entry.name or id local name = entry.name or id
assert(type(name) == "string" and name ~= "", assert(type(name) == "string" and name ~= "",
field .. " name must be a non-empty string") 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] = { out[#out + 1] = {
id = id, id = id,
name = scrubUtf8(name), name = scrubUtf8(name),
description = scrubUtf8(description),
file = file, file = file,
md5 = accepted, md5 = accepted,
format = format, format = format,
size = size,
max_size = maxSize,
required = required ~= false, required = required ~= false,
} }
end end
+129 -100
View File
@@ -23,6 +23,34 @@ local function isRequired(spec)
end end
RequiredImports.specs = allSpecs 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 = { local N64_MAGIC = {
["\128\55\18\64"] = "z64", -- big endian / canonical ["\128\55\18\64"] = "z64", -- big endian / canonical
@@ -44,7 +72,9 @@ function RequiredImports.normalizeN64(data)
kind = n64KindAt(data, 513) kind = n64KindAt(data, 513)
if kind then offset = 513 end if kind then offset = 513 end
end end
if not kind then return nil, "not a recognized Nintendo 64 ROM" end if not kind then
return nil, "expected an N64 ROM (.z64/.v64/.n64); file signature was not recognized"
end
data = data:sub(offset) data = data:sub(offset)
if kind == "z64" then return data end if kind == "z64" then return data end
@@ -89,14 +119,71 @@ function RequiredImports.path(manifest, spec)
end end
local function removedMarker(manifest, spec) local function removedMarker(manifest, spec)
return manifest.path .. "/baseroms/." .. spec.id .. ".removed" 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 end
-- Validate bytes against a declaration. The returned data is canonicalized -- Validate bytes against a declaration. The returned data is canonicalized
-- (notably for N64 byte order/header variants) and is what must be stored. -- (notably for N64 byte order/header variants) and is what must be stored.
function RequiredImports.validateData(spec, data, hashFn) 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) local normalized, normalizeErr = RequiredImports.normalize(spec, data)
if not normalized then return nil, normalizeErr end 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) local digest, hashErr = hexDigest(normalized, hashFn)
if not digest then return nil, hashErr end if not digest then return nil, hashErr end
if not accepts(spec, digest) then if not accepts(spec, digest) then
@@ -105,6 +192,35 @@ function RequiredImports.validateData(spec, data, hashFn)
return normalized, digest return normalized, digest
end 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) function RequiredImports.validateStoredData(spec, data, hashFn)
local normalized, detail = RequiredImports.validateData(spec, data, hashFn) local normalized, detail = RequiredImports.validateData(spec, data, hashFn)
if not normalized then return nil, detail end if not normalized then return nil, detail end
@@ -121,15 +237,12 @@ function RequiredImports.inspect(manifest, fs, hashFn)
local path = RequiredImports.path(manifest, spec) local path = RequiredImports.path(manifest, spec)
local suppressed = fs and fs.getInfo local suppressed = fs and fs.getInfo
and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil and fs.getInfo(removedMarker(manifest, spec), "file") ~= nil
local data = fs and fs.read and fs.read(path) or nil local exists = fs and fs.getInfo and fs.getInfo(path, "file") ~= nil
local normalized, detail local valid, detail = RequiredImports.validateStored(manifest, spec, fs, hashFn)
if data then
normalized, detail = RequiredImports.validateStoredData(spec, data, hashFn)
end
local row = { id = spec.id, name = spec.name, file = spec.file, local row = { id = spec.id, name = spec.name, file = spec.file,
format = spec.format, path = path, present = normalized ~= nil, description = spec.description, format = spec.format, path = path,
digest = normalized and detail or nil, present = valid == true, digest = valid and detail or nil,
error = data and not normalized and detail or nil, error = exists and not valid and detail or nil,
suppressed = suppressed, required = isRequired(spec), spec = spec } suppressed = suppressed, required = isRequired(spec), spec = spec }
if not row.present then if not row.present then
if row.required then missing = missing + 1 if row.required then missing = missing + 1
@@ -151,8 +264,13 @@ function RequiredImports.importData(manifest, importId, data, opts)
if not normalized then return nil, digest end if not normalized then return nil, digest end
local savedPrefix = CacheFs.prefix local savedPrefix = CacheFs.prefix
CacheFs.prefix = "" CacheFs.prefix = ""
CacheFs.remove(receiptPath(manifest, spec))
local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized) local ok, err = CacheFs.write(RequiredImports.path(manifest, spec), normalized)
if ok then CacheFs.remove(removedMarker(manifest, spec)) end 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 CacheFs.prefix = savedPrefix
if not ok then return nil, "could not copy import: " .. tostring(err) end if not ok then return nil, "could not copy import: " .. tostring(err) end
return true, digest return true, digest
@@ -164,6 +282,7 @@ function RequiredImports.remove(manifest, importId)
local savedPrefix = CacheFs.prefix local savedPrefix = CacheFs.prefix
CacheFs.prefix = "" CacheFs.prefix = ""
CacheFs.remove(RequiredImports.path(manifest, spec)) CacheFs.remove(RequiredImports.path(manifest, spec))
CacheFs.remove(receiptPath(manifest, spec))
local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n") local marked, markErr = CacheFs.write(removedMarker(manifest, spec), "removed\n")
CacheFs.prefix = savedPrefix CacheFs.prefix = savedPrefix
if not marked then return nil, "could not remember removal: " .. tostring(markErr) end if not marked then return nil, "could not remember removal: " .. tostring(markErr) end
@@ -173,94 +292,4 @@ function RequiredImports.remove(manifest, importId)
return nil, "unknown required import: " .. tostring(importId) return nil, "unknown required import: " .. tostring(importId)
end 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 return RequiredImports
+33
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
@@ -232,6 +238,33 @@ eq(files["mods/" .. MOD_ID .. "/baseroms/stadium2.z64"], "user-owned-rom",
check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil, check(files["mods/OldFolder/baseroms/stadium2.z64"] == nil,
"the shadow mod tree is still removed after preservation") "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()
+77 -14
View File
@@ -17,13 +17,17 @@ local manifest = Manifest.validate({
id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua", id = "stadium_fx", name = "Stadium FX", version = "1.0.0", entry = "main.lua",
required_imports = { required_imports = {
{ id = "stadium2", name = "Stadium 2", file = "stadium2.z64", { id = "stadium2", name = "Stadium 2", file = "stadium2.z64",
format = "n64", md5 = { DIGEST, DIGEST:upper() } }, description = "USA dump", format = "n64", size = 8,
md5 = { DIGEST, DIGEST:upper() } },
}, },
}, "mods/stadium_fx") }, "mods/stadium_fx")
eq(#manifest.required_imports, 1, "required import parses") 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, "accepted MD5 values normalize and dedupe")
eq(manifest.required_imports[1].md5[1], DIGEST, "MD5 is lowercase") 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({ local optionalManifest = Manifest.validate({
id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua", id = "optional_fx", name = "Optional FX", version = "1.0.0", entry = "main.lua",
@@ -49,6 +53,15 @@ check(not pcall(Manifest.validate, {
id = "bad", name = "Bad", version = "1", entry = "main.lua", id = "bad", name = "Bad", version = "1", entry = "main.lua",
required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } }, required_imports = { { id = "rom", file = "rom.z64", md5 = "short" } },
}), "malformed MD5 is refused") }), "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 canonical = "\128\55\18\64ABCD"
local v64 = "\55\128\64\18BADC" local v64 = "\55\128\64\18BADC"
@@ -79,17 +92,14 @@ local target = Manifest.validate({
{ id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST }, { id = "same_rom", file = "source.z64", format = "n64", md5 = DIGEST },
}, },
}, "mods/other_fx") }, "mods/other_fx")
local copied = RequiredImports.reconcile({ manifest, target }, love.filesystem, fakeHash) local targetRows, targetMissing = RequiredImports.inspect(target,
eq(#copied, 1, "matching installed import is reused") love.filesystem, fakeHash)
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), canonical, eq(targetMissing, 1, "matching hashes do not silently share another mod's import")
"reuse creates a private per-mod copy") 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") check(RequiredImports.remove(target, "same_rom"), "a required import can be removed")
eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil, eq(love.filesystem.read("mods/other_fx/baseroms/source.z64"), nil,
"remove deletes this mod's private copy") "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 }), check(RequiredImports.importData(target, "same_rom", canonical, { hash = fakeHash }),
"choosing the file again clears the removal decision") "choosing the file again clears the removal decision")
@@ -103,16 +113,69 @@ local legacyTarget = Manifest.validate({
}, },
}, "mods/legacy_user") }, "mods/legacy_user")
love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64) love.filesystem.write("mods/legacy/baseroms/manually-imported.v64", v64)
local legacyCopied = RequiredImports.reconcile({ legacy, legacyTarget }, local legacyRows, legacyMissing = RequiredImports.inspect(legacyTarget,
love.filesystem, fakeHash) love.filesystem, fakeHash)
eq(#legacyCopied, 1, "an undeclared legacy baserom can satisfy a new declaration") eq(legacyMissing, 1, "undeclared files in another mod are never indexed")
eq(love.filesystem.read("mods/legacy_user/baseroms/legacy-source.z64"), canonical, check(not legacyRows[1].present, "legacy baseroms remain private to their mod")
"legacy reuse still stores canonical bytes")
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", local rejected, why = RequiredImports.importData(target, "same_rom", "wrong",
{ hash = fakeHash }) { hash = fakeHash })
eq(rejected, nil, "mismatched selection is rejected") eq(rejected, nil, "mismatched selection is rejected")
check(tostring(why):find("Nintendo 64", 1, true) ~= nil, check(tostring(why):find("N64 ROM (.z64/.v64/.n64)", 1, true) ~= nil,
"normalization failure explains the selected format") "normalization failure explains the selected format")
love.filesystem.write("mods/launcher_needs/manifest.json", ([[{ love.filesystem.write("mods/launcher_needs/manifest.json", ([[{
@@ -135,6 +135,24 @@ check(ri.requiredImportNotice.text:find("MD5 mismatch", 1, true) ~= nil,
check(ri.modNotice == nil, check(ri.modNotice == nil,
"required import rejection is not hidden in the general Mods notice") "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.nativePicker = true
ri._importRequiredSource = function(self, modId, importId, source) ri._importRequiredSource = function(self, modId, importId, source)
self._requiredImported = { modId = modId, importId = importId, source = source } self._requiredImported = { modId = modId, importId = importId, source = source }