From f8ed24ff6eded01e2327357d6b223bb0ec777557 Mon Sep 17 00:00:00 2001 From: PokeParadox Date: Tue, 6 Oct 2026 21:45:25 +0100 Subject: [PATCH] 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). --- README.md | 6 +- src/import/RomArchive.lua | 219 +++++++++++++ src/import/RomImporter.lua | 94 +++++- src/ui/kit/FileBrowser.lua | 14 +- .../engine/game3_file_browser_quote_test.lua | 13 +- tests/rom_archive_import_test.lua | 306 ++++++++++++++++++ tests/run_tests.lua | 2 + 7 files changed, 642 insertions(+), 12 deletions(-) create mode 100644 src/import/RomArchive.lua create mode 100644 tests/rom_archive_import_test.lua diff --git a/README.md b/README.md index e1d20f83..007490d1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/import/RomArchive.lua b/src/import/RomArchive.lua new file mode 100644 index 00000000..262f19c4 --- /dev/null +++ b/src/import/RomArchive.lua @@ -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 diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 052857db..927222e4 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/ui/kit/FileBrowser.lua b/src/ui/kit/FileBrowser.lua index b7ec8eef..c42c6e23 100755 --- a/src/ui/kit/FileBrowser.lua +++ b/src/ui/kit/FileBrowser.lua @@ -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 diff --git a/tests/engine/game3_file_browser_quote_test.lua b/tests/engine/game3_file_browser_quote_test.lua index 6468a52c..b8c143ae 100644 --- a/tests/engine/game3_file_browser_quote_test.lua +++ b/tests/engine/game3_file_browser_quote_test.lua @@ -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") diff --git a/tests/rom_archive_import_test.lua b/tests/rom_archive_import_test.lua new file mode 100644 index 00000000..6e154054 --- /dev/null +++ b/tests/rom_archive_import_test.lua @@ -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() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 9224390d..a6e9b1b1 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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