mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-19 20:20:19 +02:00
Add manifest-driven required mod imports
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user