Import ROM from .zip/.7z archives

RomArchive sniffs magic, probes platform mount capability (zip/7z,
cached), unwraps first accepted-size ROM (prefer known SHA-1). Wired
into startData, filedropped game-tab zip routing, chooseRom picker
exts, FileBrowser rom filter (hidden when capability probe fails).
This commit is contained in:
PokeParadox
2026-10-06 21:45:25 +01:00
parent 602f7acdff
commit f8ed24ff6e
7 changed files with 642 additions and 12 deletions
+4 -2
View File
@@ -79,8 +79,10 @@ imported side by side. Gen 2 support is still under construction.
[latest release](https://github.com/bryanthaboi/gen1recomp/releases/latest).
2. Launch it. The packaged app contains no ROM and no game data, so the
launcher will ask for one.
3. Choose your legally obtained `.gb` / `.gbc` / `.gba` file, or drop it onto
the window. Import takes a few seconds and the game starts automatically.
3. Choose your legally obtained `.gb` / `.gbc` / `.gba` file, a `.zip` /
`.7z` that holds one (offered only where your platform's build supports
archives), or drop it onto the window. Import takes a few seconds and the
game starts automatically.
4. Repeat for any other game you own. Each one gets its own tab in the launcher.
Only the canonical US English ROMs below are accepted. The importer checks the
+219
View File
@@ -0,0 +1,219 @@
-- Unwrap a .zip / .7z that carries a ROM into raw cart bytes for import.
--
-- Two rules hold it together:
--
-- * Detection is content (magic bytes), never the filename -- the mobile
-- bridges stage every pick as picked_rom.gb, so the bytes decide.
-- * A format is offered only where PhysFS really mounts it here:
-- capabilities() probes a tiny built-in sample of each archive kind
-- once per filesystem and caches the answer, so a build without the
-- 7z archiver never shows a .7z in a picker and never claims to open
-- one. On such a platform the archive options simply do not exist.
--
-- Mount/scan follows the same shape LauncherMods.installZip uses: prefer an
-- in-memory FileData mount, fall back to staging a save-dir temp.
local RomArchive = {}
-- A ROM archive only ever holds one 1-16 MiB cart, so anything bigger is
-- not what this path is for and never gets inflated into a Lua string.
RomArchive.MAX_ARCHIVE_BYTES = 64 * 1024 * 1024
local capsCache = {}
function RomArchive._resetForTests()
capsCache = {}
end
-- "zip" | "7z" | nil, by content signature.
function RomArchive.kind(data)
if type(data) ~= "string" or #data < 8 then return nil end
local head = data:sub(1, 4)
if head == "PK\3\4" or head == "PK\5\6" then return "zip" end
if data:sub(1, 6) == "7z\188\175\39\28" then return "7z" end
return nil
end
local function u16(n)
return string.char(n % 256, math.floor(n / 256) % 256)
end
local function u32(n)
return string.char(n % 256, math.floor(n / 256) % 256,
math.floor(n / 65536) % 256, math.floor(n / 16777216) % 256)
end
-- Minimal stored-entry .zip holding one "probe" file, built at need so no
-- binary blob ships in the source. CRC is real CRC32("ok") so any reader
-- that validates it passes; the capability probe only lists, never reads.
function RomArchive._zipProbeBytes()
local name, body = "probe", "ok"
local localHeader = "PK\3\4" .. u16(20) .. u16(0) .. u16(0) .. u16(0)
.. u16(0) .. u32(2044517703) .. u32(#body) .. u32(#body) .. u16(#name)
.. u16(0) .. name .. body
local central = "PK\1\2" .. u16(20) .. u16(20) .. u16(0) .. u16(0)
.. u16(0) .. u16(0) .. u32(2044517703) .. u32(#body) .. u32(#body)
.. u16(#name) .. u16(0) .. u16(0) .. u16(0) .. u16(0) .. u32(0)
.. u32(0) .. name
local eocd = "PK\5\6" .. u16(0) .. u16(0) .. u16(1) .. u16(1)
.. u32(#central) .. u32(#localHeader) .. u16(0)
return localHeader .. central .. eocd
end
-- 130-byte .7z from `7z a -t7z -mx=0` over a 16-byte file ("p.romprobe"
-- holding "gen1recomp-probe"). PhysFS picks its archiver from these magic
-- bytes, so mounting this is the honest question "can this build open7z".
local SEVENZ_PROBE_HEX =
"377abcaf271c00044e999ef910000000000000005200000000000000f427ec00"
.. "67656e317265636f6d702d70726f62650104060001091000070b0100010100"
.. "0c1000080a01451423990000050111170070002e0072006f006d0070007200"
.. "6f00620065000000190400000000140a0100332a3077cb55dd011506010020"
.. "80a4810000"
local function sevenZProbeBytes()
return (SEVENZ_PROBE_HEX:gsub("%x%x", function(h)
return string.char(tonumber(h, 16))
end))
end
-- Mount the probe, require a non-empty listing (a refused archiver fails
-- the mount itself), always unmount.
local function probeFormat(fs, ext, bytes)
local okFd, fd = pcall(fs.newFileData, bytes, "rom_probe." .. ext)
if not okFd or not fd then return false end
local point = "_rom_probe_" .. ext
local okMount, mounted = pcall(fs.mount, fd, point)
local found = false
if okMount and mounted then
local okList, items = pcall(fs.getDirectoryItems, point)
found = okList and type(items) == "table" and #items > 0
end
pcall(fs.unmount, fd)
return found and true or false
end
-- { zip = bool, z7 = bool } for this filesystem (default love.filesystem).
-- Cached per filesystem object; _resetForTests clears the cache.
function RomArchive.capabilities(fs)
fs = fs or (love and love.filesystem)
if type(fs) ~= "table" or not (fs.newFileData and fs.mount
and fs.getDirectoryItems and fs.unmount) then
return { zip = false, z7 = false }
end
local cached = capsCache[fs]
if cached then return cached end
local caps = {
zip = probeFormat(fs, "zip", RomArchive._zipProbeBytes()),
z7 = probeFormat(fs, "7z", sevenZProbeBytes()),
}
capsCache[fs] = caps
return caps
end
-- opts:
-- fs filesystem to mount on (default love.filesystem)
-- isRomName fn(name) -> bool; defaults to .gb / .gbc / .gba
-- acceptedSize fn(byteLength) -> bool; defaults to 1/2/16 MiB
-- prefer fn(bytes) -> truthy; first candidate that answers wins
-- (the importer passes its SHA-1 -> version lookup)
-- Returns (bytes, entryName) on success, (nil, errorMessage) on failure --
-- check the first slot; the second carries the name or the message.
function RomArchive.unwrap(data, displayName, opts)
opts = opts or {}
local fs = opts.fs or (love and love.filesystem)
local kind = RomArchive.kind(data)
if not kind then return nil, "not a .zip or .7z file" end
local caps = RomArchive.capabilities(fs)
if (kind == "zip" and not caps.zip) or (kind == "7z" and not caps.z7) then
return nil, ("this platform cannot open .%s files; drop the raw "
.. ".gb/.gbc/.gba instead"):format(kind)
end
local isRomName = opts.isRomName or function(name)
return type(name) == "string" and name:lower():match("%.gb[ac]?$") ~= nil
end
local acceptedSize = opts.acceptedSize or function(n)
return n == 1048576 or n == 2097152 or n == 16777216
end
local mount = "_rom_pick_" .. kind
local fd, staged
if fs.newFileData then
local okFd, made = pcall(fs.newFileData, data, "rom_pick." .. kind)
if okFd and made and fs.mount(made, mount) then fd = made end
end
if not fd then
staged = "rom_pick_tmp." .. kind
local wrote = fs.write and fs.write(staged, data)
if not wrote or not fs.mount(staged, mount) then
if fs.remove then pcall(fs.remove, staged) end
return nil, "the archive could not be opened"
end
end
local function cleanup()
pcall(fs.unmount, fd or staged)
if staged and fs.remove then pcall(fs.remove, staged) end
end
-- Walk the mounted tree. AppleDouble / macOS junk and hidden names are
-- never carts even when they end in .gb (MTP copies are full of them).
local candidates = {}
local function scan(dir)
local okItems, items = pcall(fs.getDirectoryItems, dir)
if not okItems or type(items) ~= "table" then return end
for _, item in ipairs(items) do
if item ~= "." and item ~= ".." then
local path = dir .. "/" .. item
local okInfo, info = pcall(fs.getInfo, path)
if okInfo and info then
if info.type == "directory" then
scan(path)
elseif item:sub(1, 1) ~= "." and not dir:find("__MACOSX", 1, true)
and isRomName(item) and type(info.size) == "number" then
candidates[#candidates + 1] =
{ path = path, name = item, size = info.size }
end
end
end
end
end
scan(mount)
if #candidates == 0 then
cleanup()
return nil, ("no .gb/.gbc/.gba file found inside %s")
:format(tostring(displayName or "the archive"))
end
table.sort(candidates, function(a, b) return a.path < b.path end)
-- Only carts of a size the importer accepts are ever read (also the zip
-- bomb guard: a hostile entry of any other length is skipped by its
-- declared size, before a single byte is inflated).
local fallback, chosen
for _, cand in ipairs(candidates) do
if acceptedSize(cand.size) then
local okRead, bytes = pcall(fs.read, cand.path)
if okRead and type(bytes) == "string" and #bytes == cand.size then
fallback = fallback or { bytes = bytes, name = cand.name }
if opts.prefer then
local okPref, hit = pcall(opts.prefer, bytes)
if okPref and hit then
chosen = { bytes = bytes, name = cand.name }
break
end
end
end
end
end
chosen = chosen or fallback
cleanup()
if chosen then return chosen.bytes, chosen.name end
local sizes = {}
for _, cand in ipairs(candidates) do
sizes[#sizes + 1] = string.format("%s (%d bytes)", cand.name, cand.size)
end
return nil, ("no 1 MiB / 2 MiB / 16 MiB ROM inside %s: %s")
:format(tostring(displayName or "the archive"), table.concat(sizes, ", "))
end
return RomArchive
+87 -7
View File
@@ -1240,19 +1240,40 @@ local function consumePick(self, name, safName, ok)
self.pickSkip[name] = true
end
-- Extensions a ROM pick may offer here. Archives only when this
-- platform's PhysFS actually mounts them (probe result); drop the raw
-- answer if the probe layer is missing.
local function romPickerExts()
local exts = { "gb", "gbc", "gba" }
local ok, RomArchive = pcall(require, "src.import.RomArchive")
if ok then
local caps = RomArchive.capabilities()
if caps.zip then exts[#exts + 1] = "zip" end
if caps.z7 then exts[#exts + 1] = "7z" end
end
return exts
end
local function chooseRom(promptName)
promptName = promptName or "Pokemon"
local prompt = shellSafe("Choose your " .. promptName .. " ROM")
local platform = love.system.getOS()
local quoted, globs, semis = {}, {}, {}
for _, ext in ipairs(romPickerExts()) do
quoted[#quoted + 1] = ('"%s"'):format(ext)
globs[#globs + 1] = "*." .. ext
semis[#semis + 1] = "*." .. ext
end
if platform == "OS X" then
return commandOutput(
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {"gb", "gbc", "gba"})' 2>/dev/null]])
:format(prompt))
([[osascript -e 'POSIX path of (choose file with prompt "%s" of type {%s})' 2>/dev/null]])
:format(prompt, table.concat(quoted, ", ")))
elseif platform == "Windows" then
local script = table.concat({
HostPicker.WIN_OPEN_DIALOG,
"$d.Title='" .. prompt .. "';",
"$d.Filter='Game Boy / GBA ROM (*.gb;*.gbc;*.gba)|*.gb;*.gbc;*.gba|All files (*.*)|*.*';",
"$d.Filter='Game Boy / GBA ROM (" .. table.concat(semis, ";") .. ")|"
.. table.concat(semis, ";") .. "|All files (*.*)|*.*';",
-- copy the pick to a plain-ASCII temp name and answer with that:
-- the console's OEM codepage would mangle a non-ASCII path
-- (Pokémon -> Pok\x82mon) and io.open on Windows needs ANSI bytes,
@@ -1268,11 +1289,12 @@ local function chooseRom(promptName)
'powershell -NoProfile -STA -Command "' .. script .. '"')
elseif platform == "Linux" then
local path = commandOutput(
([[zenity --file-selection --title="%s" --file-filter="Game Boy / GBA ROM | *.gb *.gbc *.gba" 2>/dev/null]])
:format(prompt))
([[zenity --file-selection --title="%s" --file-filter="Game Boy / GBA ROM | %s" 2>/dev/null]])
:format(prompt, table.concat(globs, " ")))
if path then return path end
return commandOutput(
[[kdialog --getopenfilename "$HOME" "*.gb *.gbc *.gba|Game Boy / GBA ROM" 2>/dev/null]])
([[kdialog --getopenfilename "$HOME" "%s|Game Boy / GBA ROM" 2>/dev/null]])
:format(table.concat(globs, " ")))
end
return nil
end
@@ -2064,6 +2086,33 @@ function RomImporter:startData(data, displayName, sourcePath)
self:setError("The selected file could not be read.")
return
end
-- A .zip / .7z holding one cart gets unwrapped first (content sniff, not
-- the filename); raw bytes fall straight through. Caps gates it: on a
-- platform PhysFS can't open the kind, unwrap fails with a plain message.
local RomArchive = require("src.import.RomArchive")
local kind = RomArchive.kind(data)
if kind then
if #data > RomArchive.MAX_ARCHIVE_BYTES then
self:setError(("That .%s is %.1f MiB; a ROM archive only has to hold "
.. "one small cart. Drop the raw .gb/.gbc/.gba instead.")
:format(kind, #data / 1024 / 1024))
return
end
-- (bytes, entryName) | (nil, errorMessage)
local bytes, entry = RomArchive.unwrap(data, displayName, {
isRomName = isRomFilename,
acceptedSize = isAcceptedRomSize,
prefer = function(candidate)
return self:_versionForSha1(sha1(candidate)) ~= nil
end,
})
if not bytes then
self:setError(entry)
return
end
data = bytes
displayName = displayName and (displayName .. " / " .. entry) or entry
end
if not isAcceptedRomSize(#data) then
self:setError(("Expected a 1 MiB Game Boy ROM (%s), a "
.. "2 MiB Game Boy Color ROM (%s), or a 16 MiB Game Boy Advance ROM (%s); "
@@ -2505,12 +2554,31 @@ function RomImporter:startPath(path)
require("src.import.RomSources").absolute(path))
end
-- Does this .zip payload hold a ROM this platform can open? Drives the
-- drop route only; the actual unwrap runs again in startData.
function RomImporter:_zipHoldsRom(data)
local ok, RomArchive = pcall(require, "src.import.RomArchive")
if not ok then return false end
if RomArchive.kind(data) ~= "zip" then return false end
if not RomArchive.capabilities().zip then return false end
local bytes = RomArchive.unwrap(data, nil, {
isRomName = isRomFilename,
acceptedSize = isAcceptedRomSize,
prefer = function(candidate)
return self:_versionForSha1(sha1(candidate)) ~= nil
end,
})
return bytes ~= nil
end
function RomImporter:filedropped(file)
if self.workState == "working" then return end
-- A dropped .zip is a mod archive: hand it straight to the mods installer
-- (which mounts + validates it). A .deltaskin is only ever a skin, and
-- everything else is treated as a ROM. The dropped file itself is passed
-- through -- installZip opens it the same way readDroppedFile does here.
-- Exception: on a game tab a .zip holding a cart is the ROM itself (.7z
-- needs no branch; startData sniffs it by content below).
local name = file:getFilename() or ""
if name:lower():match("%.gci$")
or (self.tab == "box" and name:lower():match("%.sav$")
@@ -2524,9 +2592,21 @@ function RomImporter:filedropped(file)
return
end
if name:lower():match("%.zip$") then
-- On the SKINS tab a zip is a skin; everywhere else it is a mod archive.
-- On the SKINS tab a zip is a skin; elsewhere a cart inside wins, else
-- it stays a mod archive.
if self.tab == "skins" then
self:_installSkinZip(file)
elseif GameVersion.VERSIONS[self.tab] then
local data, readError = readDroppedFile(file)
if not data then
self:setError("Could not read the dropped file: " .. tostring(readError))
return
end
if self:_zipHoldsRom(data) then
self:startData(data, name, name)
return
end
self:_installMod(file)
else
self:_installMod(file)
end
+12 -2
View File
@@ -95,9 +95,19 @@ local function isMatchingFilter(name, isDir, mode)
if not ext then return (mode == "all") end
ext = ext:lower()
if mode == "rom" then
return (ext == "gb" or ext == "gbc" or ext == "gba" or ext == "zip")
if ext == "gb" or ext == "gbc" or ext == "gba" then return true end
-- Archives only when this platform mounts them here; a build that
-- can't open the kind never offers it for pick.
if ext == "zip" or ext == "7z" then
local ok, RomArchive = pcall(require, "src.import.RomArchive")
if not ok or type(RomArchive.capabilities) ~= "function" then return false end
local okCaps, caps = pcall(RomArchive.capabilities)
if not okCaps or type(caps) ~= "table" then return false end
return (ext == "zip" and caps.zip) or (ext == "7z" and caps.z7) or false
end
return false
elseif mode == "save" then
return (ext == "sav" or ext == "lua")
return (ext == "sav" or ext == "srm" or ext == "lua")
elseif mode == "box" then
return (ext == "gci" or ext == "sav")
elseif mode == "mod" then
+12 -1
View File
@@ -16,6 +16,13 @@ love = love or require("tests.love_stub")
local FileBrowser = require("src.ui.kit.FileBrowser")
local HostShell = require("src.core.HostShell")
-- ROM archive caps for this fixture: this platform opens .zip but not .7z.
-- Faking it keeps the one-time probe (mount sample) out of this test.
local realRomArchive = package.loaded["src.import.RomArchive"]
package.loaded["src.import.RomArchive"] = {
capabilities = function() return { zip = true, z7 = false } end,
}
-- 1. Injection: a directory named with a command substitution must not run.
-- The marker must not pre-exist (os.tmpname creates its file, so name it here).
local marker = "/tmp/gen1recomp-inject-" .. tostring(os.time())
@@ -50,7 +57,8 @@ end
local realItems = love.filesystem.getDirectoryItems
local realInfo = love.filesystem.getInfo
local listing = { "images", "videos", "Red.gb", "Crystal.GBC", "FireRed.gba",
"Emerald.GBA", "roms.zip", "save.sav", "save.lua", "demo.g1rcart", "notes.txt" }
"Emerald.GBA", "roms.zip", "pack.7z", "save.sav", "save.lua",
"demo.g1rcart", "notes.txt" }
local function listingText()
local out = {}
for _, name in ipairs(listing) do
@@ -84,6 +92,8 @@ for _, backend in ipairs({ "shell", "love" }) do
end
check(found["notes.txt"] == nil and found["save.sav"] == nil,
backend .. " excludes unrelated files from ROM mode")
check(found["pack.7z"] == nil,
backend .. " hides .7z when the platform caps disallow it")
if found["FireRed.gba"] then
FileBrowser.selectedIdx = found["FireRed.gba"]
check(FileBrowser.gamepadpressed(button), backend .. " consumes " .. button)
@@ -108,5 +118,6 @@ end
io.popen = realPopen
love.filesystem.getDirectoryItems = realItems
love.filesystem.getInfo = realInfo
package.loaded["src.import.RomArchive"] = realRomArchive
T.finish("game3_file_browser_quote_test")
+306
View File
@@ -0,0 +1,306 @@
-- ROM import from .zip / .7z: unwrap one cart, gate on platform caps.
--
-- luajit tests/rom_archive_import_test.lua
-- also dofile'd by tests/run_tests.lua.
--
-- The capability probe mounts a tiny built-in sample of each format, so
-- these tests feed RomArchive a fake filesystem where mount success is
-- decided per content -- that IS the platform question (a build without
-- the 7z archiver just cannot mount the probe).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom import from archives")
local check, eq = S.check, S.eq
local RomArchive = require("src.import.RomArchive")
local RomImporter = require("src.import.RomImporter")
local KIND_7Z = "7z\188\175\39\28"
local zipProbe = RomArchive._zipProbeBytes()
local userArchive = "PK\3\4" .. "user-archive-body"
local sevenZFile = KIND_7Z .. "user-7z-body"
local goodBody = string.rep("R", 1048576) -- accepted 1 MiB cart
local SUPPORT_Z7 = false
local userEntries = { ["Red.gb"] = goodBody }
-- ---- fake filesystem -----------------------------------------------------
local function makeVfs()
local vfs = {}
local arch = {} -- fullpath -> { body=string } | { dir=true }
local mounted = {} -- archive key -> mount point
local staged = {} -- temp path -> data
local function keyOf(archive)
if type(archive) == "table" then return archive end
return staged[archive] and archive or nil
end
function vfs.newFileData(data, name)
return { _fileData = true, data = data, name = name }
end
function vfs.write(path, data)
staged[path] = data
return true
end
function vfs.remove(path)
staged[path] = nil
return true
end
function vfs.mount(archive, point)
local data
if type(archive) == "table" then
data = archive.data
else
data = staged[archive]
end
if not data then return false end
local entries
if data == userArchive then
entries = userEntries
elseif data == sevenZFile then
entries = userEntries
elseif data == zipProbe then
entries = { probe = "ok" }
elseif RomArchive.kind(data) == "7z" then
-- the built-in 7z probe: only a platform that supports 7z mounts it
if not SUPPORT_Z7 then return false end
entries = { ["p.romprobe"] = "gen1recomp-probe" }
else
return false
end
arch[point] = { dir = true }
for rel, body in pairs(entries) do
arch[point .. "/" .. rel] = { body = body }
end
mounted[keyOf(archive) or data] = point
return true
end
function vfs.unmount(archive)
local point = mounted[archive] or mounted[archive.data and archive or nil]
if not point then
for k, v in pairs(mounted) do
if k == archive or (type(k) == "table" and k.data == archive) then
point = v
end
end
end
if not point then return false end
for path in pairs(arch) do
if path == point or path:sub(1, #point + 1) == point .. "/" then
arch[path] = nil
end
end
mounted[archive] = nil
for k, v in pairs(mounted) do
if v == point then mounted[k] = nil end
end
return true
end
function vfs.getDirectoryItems(dir)
if not (arch[dir] and arch[dir].dir) then return {} end
local out = {}
for path in pairs(arch) do
if path ~= dir and path:sub(1, #dir + 1) == dir .. "/" then
local rest = path:sub(#dir + 2)
if not rest:find("/", 1, true) and not arch[path].dir then
out[#out + 1] = rest
end
end
end
table.sort(out)
return out
end
function vfs.getInfo(path)
local node = arch[path]
if node then
if node.dir then return { type = "directory" } end
return { type = "file", size = #node.body }
end
for other in pairs(arch) do
if other:sub(1, #path + 1) == path .. "/" then
return { type = "directory" }
end
end
return nil
end
function vfs.read(path)
local node = arch[path]
if node and node.body then return node.body end
return nil, "not found"
end
return vfs
end
local savedFs = love.filesystem
local vfs = makeVfs()
love.filesystem = vfs
RomArchive._resetForTests()
-- ---- 1. content sniff ----------------------------------------------------
eq(RomArchive.kind("GBROM" .. string.rep("x", 20)), nil, "raw ROM is not an archive")
eq(RomArchive.kind("PK\3\4rest"), "zip", "PK\\3\\4 is a zip")
eq(RomArchive.kind("PK\5\6rest"), "zip", "empty zip is a zip")
eq(RomArchive.kind(KIND_7Z .. "rest"), "7z", "7z magic recognised")
eq(RomArchive.kind("PK"), nil, "truncated input is not an archive")
eq(RomArchive.kind(nil), nil, "non-string input is not an archive")
-- ---- 2. platform caps drive what exists ----------------------------------
local caps = RomArchive.capabilities(vfs)
check(caps.zip == true, "zip probe mounts here")
check(caps.z7 == false, "7z probe refuses while SUPPORT_Z7 is off")
SUPPORT_Z7 = true
RomArchive._resetForTests()
caps = RomArchive.capabilities(vfs)
check(caps.z7 == true, "7z probe mounts once the platform supports it")
SUPPORT_Z7 = false
RomArchive._resetForTests()
check(RomArchive.capabilities(vfs).z7 == false, "caps are re-probed after reset")
-- ---- 3. unwrap -----------------------------------------------------------
userEntries = { ["._Red.gb"] = "APPLEDOUBLE", ["Red.gb"] = goodBody }
local bytes, entry = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs })
eq(bytes, goodBody, "cart bytes come out")
eq(entry, "Red.gb", "AppleDouble ._Red.gb is skipped, Red.gb wins")
check(RomArchive.unwrap("hello", "x.zip", { fs = vfs }) == nil, "not-an-archive refused")
userEntries = { ["A.gb"] = "AAA", ["Y.gb"] = "YYY" }
bytes, entry = RomArchive.unwrap(userArchive, "pack.zip", {
fs = vfs,
acceptedSize = function(n) return n >= 3 end,
prefer = function(b) return b:sub(1, 1) == "Y" end,
})
eq(entry, "Y.gb", "prefer() picks the known-version cart over sort order")
userEntries = { ["notes.txt"] = "text" }
local noRom, noRomErr = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs })
eq(noRom, nil, "an archive with no cart refuses")
check(tostring(noRomErr):find("no .gb/.gbc/.gba", 1, true) ~= nil,
"the no-cart message says so: " .. tostring(noRomErr))
userEntries = { ["Red.gb"] = string.rep("R", 2000) }
local wrongSize, wrongErr = RomArchive.unwrap(userArchive, "pack.zip", { fs = vfs })
eq(wrongSize, nil, "a wrong-size cart is never read (zip bomb guard)")
check(tostring(wrongErr):find("2000 bytes", 1, true) ~= nil,
"the wrong-size message lists sizes: " .. tostring(wrongErr))
userEntries = { ["Red.gb"] = goodBody }
local z7bytes, z7err = RomArchive.unwrap(sevenZFile, "cart.7z", { fs = vfs })
eq(z7bytes, nil, "7z refused while the platform lacks the archiver")
check(tostring(z7err):find("cannot open .7z", 1, true) ~= nil,
"the caps refusal names the format: " .. tostring(z7err))
SUPPORT_Z7 = true
RomArchive._resetForTests()
local z7ok, z7entry = RomArchive.unwrap(sevenZFile, "cart.7z", { fs = vfs })
eq(z7ok, goodBody, "7z opens once the platform supports it")
eq(z7entry, "Red.gb", "and yields its cart")
SUPPORT_Z7 = false
RomArchive._resetForTests()
-- ---- 4. startData end-to-end (importer view) -----------------------------
love.data = love.data or {}
local savedData = { hash = love.data.hash, encode = love.data.encode }
love.data.hash = function(_, data)
return { tag = data:sub(1, 1) }
end
love.data.encode = function(_, _, digest)
if type(digest) == "table" and digest.tag == "R" then
return require("src.core.GameVersion").info("red").sha1
end
return "0000000000000000000000000000000000000000"
end
local function freshImporter(extra)
local ri = setmetatable({ tab = "red", workState = nil }, RomImporter)
for k, v in pairs(extra or {}) do ri[k] = v end
return ri
end
userEntries = { ["Red.gb"] = string.rep("R", 2000) }
local ri = freshImporter()
ri:startData(userArchive, "pack.zip")
eq(ri.workState, "error", "wrong-size cart in a zip lands in the error state")
check(tostring(ri.detail):find("2000 bytes", 1, true) ~= nil,
"the size filter runs before any read: " .. tostring(ri.detail))
userEntries = { ["Red.gb"] = string.rep("X", 1048576) }
ri = freshImporter()
ri:startData(userArchive, "pack.zip")
eq(ri.workState, "error", "unknown-sha cart in a zip errors too")
check(tostring(ri.detail):find("Unsupported ROM (SHA-1", 1, true) ~= nil,
"the SHA-1 gate runs on the unwrapped bytes: " .. tostring(ri.detail))
-- oversized archive: lower the ceiling instead of building 64 MiB
local realMax = RomArchive.MAX_ARCHIVE_BYTES
RomArchive.MAX_ARCHIVE_BYTES = 10
ri = freshImporter()
ri:startData(userArchive, "pack.zip")
RomArchive.MAX_ARCHIVE_BYTES = realMax
eq(ri.workState, "error", "an oversized archive is refused before mounting")
check(tostring(ri.detail):find(".zip", 1, true) ~= nil,
"the oversize message names the format: " .. tostring(ri.detail))
ri = freshImporter()
ri:startData(sevenZFile, "cart.7z")
eq(ri.workState, "error", "a 7z drop takes the same startData path")
check(tostring(ri.detail):find("cannot open .7z", 1, true) ~= nil,
"and is refused by caps: " .. tostring(ri.detail))
-- ---- 5. drop routing -----------------------------------------------------
userEntries = { ["Red.gb"] = goodBody }
local function fakeFile(path, content)
return {
open = function() return true end,
read = function() return content end,
close = function() end,
getSize = function() return #content end,
getFilename = function() return path end,
}
end
ri = freshImporter({ tab = "red" })
local started
ri.startData = function(self, data, name, src)
started = { data = data, name = name, src = src }
end
ri:filedropped(fakeFile("/tmp/packs/cart-pack.zip", userArchive))
check(started ~= nil, "a game-tab zip holding a cart routes to startData")
if started then
eq(started.data, userArchive, "startData receives the archive payload (it unwraps)")
eq(started.name, "/tmp/packs/cart-pack.zip", "and the dropped path for display")
end
-- .7z has no filedropped branch: generic path -> startData sniffs content.
-- Use a real startData here so the caps refusal comes from unwrap.
ri = freshImporter({ tab = "red" })
ri:filedropped(fakeFile("/tmp/cart.7z", sevenZFile))
eq(ri.workState, "error", "a dropped .7z reaches startData via the generic path")
check(tostring(ri.detail):find("cannot open .7z", 1, true) ~= nil,
"and is refused when the platform lacks 7z: " .. tostring(ri.detail))
-- ---- teardown ------------------------------------------------------------
love.filesystem = savedFs
love.data.hash = savedData.hash
love.data.encode = savedData.encode
RomArchive._resetForTests()
S.finish()
+2
View File
@@ -3856,6 +3856,8 @@ runSuites({ "tests/rom_importer_android_mod_pick_test.lua" })
-- ---------------------------------------------- import with no picker (#482)
runSuites({ "tests/rom_importer_no_picker_test.lua" })
runSuites({ "tests/rom_importer_double_pick_test.lua" })
-- ---------------------------------------------- ROM from a .zip / .7z archive
runSuites({ "tests/rom_archive_import_test.lua" })
-- the same pickerless scan, asked for one version in particular (#1274)
runSuites({ "tests/rom_importer_choose_version_test.lua" })
-- ---------------------------------------------- Switch platform capabilities