-- STADIUM battles: importing the ROM, instead of being told where to put it. -- -- The mod ships no Pokemon Stadium models and cannot -- they are that game's -- data -- so the player supplies the cartridge. The original instruction for -- that was "make a folder called baseroms next to the game and drop the file -- in it", which is a fine sentence to write and a poor thing to ask. It needs -- a folder the player has to create, in a place that is different on every -- platform and is inside an unwritable archive on a packaged build, and it -- fails SILENTLY: the two STADIUM rungs are simply not on the row, and -- nothing on screen says why. -- -- So this opens a file picker instead, from a row on the OPTIONS menu, and -- the folder keeps working for anyone who prefers it (StadiumInstall). -- -- ------- the picker is the host's, not LOVE's -- -- LOVE 11.5 has no file dialog. love.window.showFileDialog arrived in 12 and -- love.system.pickFile is a native bridge this project ships for mobile -- rather than part of LOVE at all. What every desktop OS does have is a -- dialog reachable from a shell, so that is what is used here -- osascript on -- macOS, PowerShell's OpenFileDialog on Windows, zenity then kdialog on -- Linux. -- -- This is deliberately the SAME four commands the engine's own ROM importer -- uses for the Game Boy cartridge (src/import/RomImporter.lua's chooseRom), -- down to writing the Windows pick as UTF-8 -- the console's OEM codepage -- mangles a non-ASCII path into something that crashes the next text draw. -- Being a second copy of that is worth it: a mod cannot call into the -- importer's private helpers, and the alternative is asking the engine to -- grow a seam for one caller. -- -- The dialog BLOCKS. io.popen waits for the player to choose, and the game is -- frozen for as long as it is up. That is what the engine's importer does -- too, it is what a modal dialog means, and the frame it freezes on is an -- options menu. -- -- ------- and the ROM is not kept -- -- The picked file is read, built from, and forgotten -- nothing is copied -- anywhere. A Stadium cartridge is 32 MB and the models built out of it are -- 34, so keeping both would double the cost of a feature for a file that has -- no further use: the packs are what the game reads afterwards, and the -- marker records the ROM's md5 so a swapped cartridge is still noticed. -- -- The one thing that costs is a format bump, which invalidates the packs and -- leaves nothing to rebuild from. That is what the row still being there is -- for -- it reads READY, and pressing it imports again. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local StadiumInstall = V.require("StadiumInstall") local StadiumRomPick = {} StadiumRomPick.LABEL = "STADIUM ROM" StadiumRomPick.ID = "DRAMATIC_SHAPE:stadiumRom" -- Names the REVISION, because that is the thing a player gets wrong: the -- model offsets are keyed to US 1.0 and nothing else is going to work. local PROMPT = "Choose your Pokemon Stadium (US) 1.0 ROM" -- ------- the host, at arm's length -- -- Everything below is read through pcall and a presence test. The mod loader -- hands a mod the real `io` and `os` today, but a mod that TAKES that for -- granted is one that stops loading the day a sandbox arrives -- and this is -- a convenience on top of a folder scan that works without any of it. local function haveShell() local ok, popen = pcall(function() return io and io.popen end) return (ok and popen) and true or false end local function haveFiles() local ok, open = pcall(function() return io and io.open end) return (ok and open) and true or false end local function osName() local ok, name = pcall(function() return love.system.getOS() end) return ok and name or nil end -- Run a command and return its trimmed stdout, or nil for anything that did -- not produce a line -- a cancelled dialog, a missing zenity, a shell that -- is not there. local function commandOutput(cmd) if not haveShell() then return nil end local ok, pipe = pcall(io.popen, cmd) if not (ok and pipe) then return nil end local okRead, out = pcall(pipe.read, pipe, "*a") pcall(pipe.close, pipe) if not (okRead and type(out) == "string") then return nil end out = out:gsub("^%s+", ""):gsub("%s+$", "") return (out ~= "") and out or nil end -- ------- can this machine open one at all -- -- Desktop only, and honestly so. On ANDROID the picker is a native bridge -- (love.system.pickFile) whose kind -> filename mapping is a fixed list of -- three in the engine's own C++, and an unrecognised kind falls through to -- `picked_rom.gb` -- which is the file the engine's Game Boy importer is -- watching. Calling it for a 32 MB N64 ROM would hand that to the wrong -- importer, so it is not called. -- -- Android does not need it as badly, either: conf.lua points the save -- directory at the app's external-files folder, so `baseroms/` there is -- reachable over USB or any file manager with no root and no permission -- prompt, which is the flow the engine's own comment describes for -- picker-less builds. function StadiumRomPick.available() if not (haveShell() and haveFiles()) then return false end local p = osName() return p == "Windows" or p == "OS X" or p == "Linux" end -- Open the dialog. Returns the chosen absolute path, or nil when the player -- cancelled or no dialog could be opened. function StadiumRomPick.choose() local p = osName() if p == "OS X" then return commandOutput( ([[osascript -e 'POSIX path of (choose file with prompt "%s" of type ]] .. [[{"z64", "n64", "v64"})' 2>/dev/null]]):format(PROMPT)) elseif p == "Windows" then local script = table.concat({ "Add-Type -AssemblyName System.Windows.Forms;", "$d=New-Object System.Windows.Forms.OpenFileDialog;", "$d.Title='" .. PROMPT .. "';", "$d.Filter='Nintendo 64 ROM (*.z64;*.n64;*.v64)|*.z64;*.n64;*.v64" .. "|All files (*.*)|*.*';", -- as UTF-8: the console's OEM codepage would mangle a non-ASCII path -- and crash the next text draw that showed it "if($d.ShowDialog() -eq 'OK'){[Console]::OutputEncoding=" .. "[Text.Encoding]::UTF8; [Console]::Write($d.FileName)}", }) return commandOutput( 'powershell -NoProfile -STA -Command "' .. script .. '"') elseif p == "Linux" then local path = commandOutput( ([[zenity --file-selection --title="%s" ]] .. [[--file-filter="Nintendo 64 ROM | *.z64 *.n64 *.v64" 2>/dev/null]]) :format(PROMPT)) if path then return path end -- zenity is absent on plenty of installs (and on most handheld Linux -- distributions); KDE's own dialog is the usual second answer return commandOutput( [[kdialog --getopenfilename "$HOME" "*.z64 *.n64 *.v64|]] .. [[Nintendo 64 ROM" 2>/dev/null]]) end return nil end -- Read an ABSOLUTE path, which love.filesystem cannot: it only sees inside -- the physfs mount, and a picked file is anywhere on the disk. Returns the -- bytes, or nil plus a reason short enough to fit the loading screen. function StadiumRomPick.read(path) if not haveFiles() then return nil, "no file access" end local ok, fp = pcall(io.open, path, "rb") if not (ok and fp) then return nil, "could not open that file" end local okRead, bytes = pcall(fp.read, fp, "*a") pcall(fp.close, fp) if not (okRead and type(bytes) == "string" and #bytes > 0) then return nil, "could not read that file" end return bytes end -- ------- the whole flow, from one keypress -- -- Pick, read, start the build, and put the loading screen up over whatever -- asked -- which is the OPTIONS menu, so the row is there again underneath -- when the build finishes and now reads READY. -- -- A CANCELLED dialog is not a failure and says nothing: the player opened a -- file browser and changed their mind, and a mod that made an announcement -- about that would be the second most annoying thing on the menu. -- -- Everything else lands on the loading screen's own failure state, because it -- is the one surface in this mode with room for a sentence -- and because a -- player who has just chosen the wrong file is owed a reason and not a row -- that quietly goes on saying IMPORT. function StadiumRomPick.import(game) if StadiumInstall.status.state == "building" then return false end local path = StadiumRomPick.choose() if not path then return false end local StadiumScreen = V.require("StadiumScreen") local function fail(why) StadiumInstall.status.state = "failed" StadiumInstall.status.error = why if game and game.stack then game.stack:push(StadiumScreen.new(game, true)) end return false end local bytes, err = StadiumRomPick.read(path) if not bytes then return fail(err or "could not read that file") end local ok, beginErr = StadiumInstall.beginFrom(bytes, path) if not ok then return fail(tostring(beginErr)) end if game and game.stack then game.stack:push(StadiumScreen.new(game, true)) end return true end -- ------- the row -- -- An ACTION rather than a value, which is why it is not a ModSetting: there -- is no rung to store, nothing for the mod manager's page to persist, and -- nothing to restore on the next boot. What it shows is a STATE -- the models -- are there or they are not -- and what it does is the only thing it can do. -- -- Still offered once they ARE there, reading READY. Pressing it imports -- again, which is how a player swaps to a different revision, and how they -- rebuild after a format bump has invalidated the packs and left nothing on -- disk to rebuild from (see the header: the ROM is not kept). -- -- nil where no dialog can be opened, which takes the row off the menu -- entirely rather than offering a button that cannot do anything. function StadiumRomPick.row() if not StadiumRomPick.available() then return nil end return { id = StadiumRomPick.ID, label = StadiumRomPick.LABEL, value = function() if StadiumInstall.status.state == "building" then return "BUILDING" end return StadiumInstall.available() and "READY" or "IMPORT" end, step = function(game) pcall(StadiumRomPick.import, game) return true end, } end return StadiumRomPick