mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-08 09:42:55 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user