-- STADIUM battles: finding the ROM, and building the models out of it once. -- -- The mod does not ship the Pokemon Stadium models and cannot: they are that -- game's data. What it ships is the READER -- StadiumRom, StadiumFragment, -- StadiumFx and StadiumBuild -- and the player supplies the cartridge, which -- is exactly the arrangement this engine already has for the Game Boy ROM it -- is a recompilation of (src/import/RomImporter.lua). -- -- So: supply a Pokemon Stadium (US) 1.0 ROM -- the OPTIONS row opens a file -- picker for one, or drop it in `baseroms/` -- and the first time the -- game runs with the mod on, the models are built. Once, on a loading screen, -- in about ten seconds. After that the packs sit in the save directory and -- the mod reads them like any other asset. -- -- ------- where "baseroms/" is -- -- One relative path, and it deliberately covers two different places at once, -- because PhysFS searches the save directory AND the game folder under the -- same names: -- -- * a folder install, or a checkout -- `baseroms/` next to main.lua -- * a packaged or fused build, where the game folder is inside an archive -- and cannot be written to -- `baseroms/` in the save directory, whose -- absolute path this reports on screen so it can be found -- -- The file goes STRAIGHT IN THERE, with no revision subfolder under it. The -- decompilation's own `make init` uses `baseroms/us/`, and the offline -- pipeline under model_extract/ still reads from there because it shares that -- tree -- but the instruction given to a player is "drop the file in this -- folder", and one folder is the whole of it. -- -- Any of `.z64`, `.n64` and `.v64` is accepted; StadiumRom normalises the -- byte order on load. -- -- ------- what "installed" means -- -- A marker file next to the packs, holding the format magic, how many species -- were written and the md5 of the ROM they came from. All three matter. The -- magic catches a format change (the packs are rebuilt rather than read as -- garbage), the count catches a build that was interrupted half way, and the -- md5 catches the player swapping the ROM for a different revision. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local StadiumPack = V.require("StadiumPack") local StadiumInstall = {} -- Where a ROM is looked for, and where the built packs are kept. StadiumInstall.ROM_DIR = "baseroms" StadiumInstall.DIR = StadiumPack.CACHE_DIR StadiumInstall.MARKER = StadiumInstall.DIR .. "/pack.info" -- Bumped whenever the .dsm format changes, so an old cache is rebuilt rather -- than misread. Must track StadiumPack's magic. StadiumInstall.FORMAT = "DSM3" StadiumInstall.COUNT = 151 -- Named ROM files, then any ROM at all sitting in the folder. -- -- Flat in `baseroms/`, with no revision subfolder: the offline pipeline under -- model_extract/ keeps the decompilation's own `baseroms/us/` convention -- because it shares that tree, but what is being asked of a PLAYER here is -- "drop the file in this folder", and one folder is the whole of that -- instruction. A path they have to build out of two parts is a path half of -- them will get wrong, and the failure is silent -- the rungs are simply not -- on the row. local NAMED = { StadiumInstall.ROM_DIR .. "/baserom.z64", StadiumInstall.ROM_DIR .. "/baserom.n64", StadiumInstall.ROM_DIR .. "/baserom.v64", } local function fs() return love and love.filesystem end local function isFile(path) local f = fs() if not (f and f.getInfo) then return false end local ok, info = pcall(f.getInfo, path, "file") return (ok and info) and true or false end -- The ROM's path on the PhysFS read path, or nil. function StadiumInstall.romPath() local f = fs() if not f then return nil end for _, path in ipairs(NAMED) do if isFile(path) then return path end end local ok, items = pcall(f.getDirectoryItems, StadiumInstall.ROM_DIR) if ok and items then table.sort(items) for _, name in ipairs(items) do if name:lower():match("%.[nvz]64$") then local path = StadiumInstall.ROM_DIR .. "/" .. name if isFile(path) then return path end end end end return nil end function StadiumInstall.romPresent() return StadiumInstall.romPath() ~= nil end -- Where to tell the player to put it. The save directory is the answer that -- is always writable, and it is the one a packaged build needs. function StadiumInstall.romHint() local f = fs() local base = (f and f.getSaveDirectory and select(2, pcall(f.getSaveDirectory))) if type(base) ~= "string" then base = "the game folder" end return base .. "/" .. StadiumInstall.ROM_DIR end -- ------- the marker local function readMarker() local f = fs() if not (f and isFile(StadiumInstall.MARKER)) then return nil end local ok, text = pcall(f.read, StadiumInstall.MARKER) if not (ok and type(text) == "string") then return nil end local format, count, md5 = text:match("^(%S+)%s+(%d+)%s*(%S*)") if not format then return nil end return { format = format, count = tonumber(count), md5 = md5 } end -- Whether a complete, current set of packs is on disk. local readyCache = nil function StadiumInstall.ready() if readyCache ~= nil then return readyCache end local m = readMarker() readyCache = (m ~= nil and m.format == StadiumInstall.FORMAT and m.count == StadiumInstall.COUNT) and true or false return readyCache end -- Whether a complete set came WITH the mod folder -- a developer checkout -- that has run tools/stadium_pack.py. Never true of a released build, which -- carries no models at all. -- -- Sampled at both ends of the dex rather than counted. The question being -- asked is "did somebody run the packer here", not "is every one of the 151 -- present"; a genuinely half-written folder is a case for the marker file, -- which is what catches an interrupted RUNTIME build. local function shipped() local mod = V.mod if not (mod and mod.read) then return false end for _, dex in ipairs({ 1, 151 }) do local ok, bytes = pcall(mod.read, mod, ("%s/%03d.dsm"):format(StadiumPack.DIR, dex)) if not (ok and type(bytes) == "string" and #bytes > 4) then return false end end return true end -- Whether the STADIUM rungs can be offered at all: either the packs have been -- built from the player's ROM, or the mod folder already carries a set. function StadiumInstall.available() if StadiumInstall.ready() then return true end return shipped() end -- Whether there is work to do: something to build from, and nothing usable -- yet. -- -- A checkout that already carries a set is NOT pending. Building anyway would -- be correct and would also mean a ten-second loading screen on the first run -- of every checkout, to arrive at the files that were already sitting there. function StadiumInstall.pending() if StadiumInstall.available() then return false end return StadiumInstall.romPresent() end function StadiumInstall.forget() readyCache = nil end -- ------- building local job = nil local status = { state = "idle", done = 0, total = StadiumInstall.COUNT } StadiumInstall.status = status local function writePack(species, bytes) local f = fs() if not f then return false, "no filesystem" end local ok, err = f.write(("%s/%03d.dsm"):format(StadiumInstall.DIR, species), bytes) if not ok then return false, tostring(err) end return true end -- Open the ROM found in `baseroms/` and start a stepped build. Returns false -- plus a reason when there is nothing to build from. function StadiumInstall.begin() local f = fs() if not f then return false, "no filesystem" end local path = StadiumInstall.romPath() if not path then return false, "no ROM in " .. StadiumInstall.ROM_DIR end local okRead, bytes = pcall(f.read, path) if not (okRead and type(bytes) == "string") then return false, "could not read " .. path end return StadiumInstall.beginFrom(bytes, path) end -- The same, from bytes somebody else has already got hold of -- which is the -- IMPORTED path (StadiumRomPick), where the file is at an absolute location -- love.filesystem cannot see and was read with io.open. -- -- The two entry points share everything from here down on purpose: an -- imported cartridge and a dropped one produce the same 151 files, the same -- marker and the same md5, so there is exactly one build in this mod and no -- second one to keep in step. -- -- `label` is only ever used to say WHICH file a complaint is about. function StadiumInstall.beginFrom(bytes, label) local f = fs() if not f then return false, "no filesystem" end if type(bytes) ~= "string" or #bytes == 0 then return false, "empty file" end local StadiumRom = V.require("StadiumRom") local StadiumBuild = V.require("StadiumBuild") local rom, err = StadiumRom.open(bytes) if not rom then return false, tostring(err) end status.wrongVersion = false if not rom:isExpectedUS() then -- Built anyway rather than refused: a dump can differ from the reference -- for reasons that do not move a single model offset (a byte-order -- variant already normalised on load, a trimmed overdump). But every -- offset in this reader was measured against US 1.0 and nothing else is -- promised, so it is said loudly, with the md5 that IS expected so the -- player can check their own file against it. status.wrongVersion = true V.mod.log:warn("stadium: %s is md5 %s -- the model offsets are keyed to " .. "Pokemon Stadium (US) 1.0, which is md5 %s. Building " .. "anyway, but the models may be wrong or fail to build.", tostring(label or "the ROM"), tostring(rom:md5()), tostring(StadiumRom.US_MD5)) end -- ------- refuse a ROM with no models in it, BEFORE anything is written -- -- A file picker invites the wrong file -- most obviously the Game Boy -- cartridge the player already imported once -- and the reader's answer to -- one is a model count of zero. That has to be caught HERE rather than -- allowed to become an empty build, because an empty build is -- indistinguishable from a finished one further down: `job.total` is -- clamped to the count, `step` completes on the first call with nothing -- attempted and therefore nothing FAILED, and the marker gets written -- saying `DSM3 0`. -- -- On a fresh machine that is merely a lie on the loading screen -- READY, -- with no models. On one that already HAD them it is worse: the marker is -- the only thing that makes 151 files on disk count as installed, so -- overwriting it with a zero uninstalls a good set and the STADIUM rungs -- vanish off the row. Nothing below this line runs for a file that cannot -- possibly produce a build. local models = rom:modelCount() if not (models and models >= StadiumInstall.COUNT) then return false, "needs Pokemon Stadium US 1.0" end pcall(f.createDirectory, StadiumInstall.DIR) job = StadiumBuild.job(rom, writePack, StadiumInstall.COUNT) job.md5 = rom:md5() status.state = "building" status.done = 0 status.total = job.total status.error = nil return true end -- One species. Returns true while there is more to do. function StadiumInstall.step() if not job then return false end local more = job:step() status.done = job.done status.species = job.species if job.error then status.state = "failed" status.error = job.error job = nil return false end if not more then local f = fs() -- `job.total > 0` as well as "nothing failed", because a job with nothing -- IN it satisfies the second on its own -- and the marker this writes is -- what makes a set count as installed, so it must never be written for a -- build that did not happen. beginFrom refuses such a ROM outright; this -- is the same rule stated where the consequence is. local wrote = #job.failed == 0 and job.total > 0 if wrote and f then pcall(f.write, StadiumInstall.MARKER, ("%s %d %s\n"):format(StadiumInstall.FORMAT, job.total, tostring(job.md5 or ""))) readyCache = nil StadiumPack.forget() end if not wrote then status.state = "failed" -- EVERY species failing is not a bad build, it is the wrong file: the -- offsets the reader walks are Pokemon Stadium's, so a different game -- -- or the Game Boy cartridge the player already imported once, which -- is the mistake a file picker invites -- misses on all 151 rather than -- on a few. Worth telling apart, because "0 of 151 models were built" -- reads as a broken mod and this reads as a wrong click. if #job.failed >= job.total then status.error = "needs Pokemon Stadium US 1.0" else status.error = ("%d of %d models could not be built") :format(#job.failed, job.total) end else status.state = "done" end job = nil return false end return true end function StadiumInstall.cancel() job = nil status.state = "idle" end return StadiumInstall