DramaticShapeVoxelMod archival mirror · read-only
lib/StadiumRomPick.lua

StadiumRomPick.lua

lib/StadiumRomPick.lua13.1 KB100644a99d5832a1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309
-- 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 a DIALOG
--
-- 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`. That is not
-- merely the wrong name -- it is the file the engine's Game Boy importer is
-- watching, and reading that code settles it: the importer's size test only
-- SKIPS a 1 MB file it has already imported, so a 32 MB N64 ROM landing
-- there falls straight through to `love.filesystem.remove` and
-- `startData` -- deleted, and then reported to the player as a broken Game
-- Boy ROM. So the bridge is not called until it learns the kind, which is a
-- two-line change in System.cpp and an APK rebuild (see README).
--
-- Android is not stuck without it: 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. What Android
-- lacked was being TOLD that -- the row vanished, and the folder's absolute
-- path was only ever written to a console no phone shows. That is what the
-- note below is for.
function StadiumRomPick.canDialog()
  if not (haveShell() and haveFiles()) then return false end
  local p = osName()
  return p == "Windows" or p == "OS X" or p == "Linux"
end

-- Kept as the old name for callers that only wanted "is there a dialog".
StadiumRomPick.available = StadiumRomPick.canDialog

-- Where a SAF pick would land if the native bridge grows a Stadium kind.
-- Watched unconditionally (see poll): on a build that never writes it this
-- costs one getInfo a frame, and on one that does the mod needs no further
-- change to use it.
StadiumRomPick.PICKED = "picked_stadium.z64"

-- 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 StadiumScreen = V.require("StadiumScreen")

  -- No dialog on this platform: say where the file goes, on screen, because
  -- that is the whole of what the player is missing and the console is not
  -- somewhere they can read it.
  if not StadiumRomPick.canDialog() then
    if game and game.stack then
      game.stack:push(StadiumScreen.newNote(game, "STADIUM ROM",
        "PUT STADIUM US 1.0 HERE:",
        StadiumInstall.romHintFile()))
    end
    return false
  end

  local path = StadiumRomPick.choose()
  if not path then return false end
  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()
  return {
    id = StadiumRomPick.ID,
    label = StadiumRomPick.LABEL,
    value = function()
      if StadiumInstall.status.state == "building" then return "BUILDING" end
      if StadiumInstall.available() then return "READY" end
      -- WHERE, not IMPORT, where pressing it can only tell you the folder:
      -- a row that says IMPORT and then does not import is a worse row than
      -- one that says what it actually does
      return StadiumRomPick.canDialog() and "IMPORT" or "WHERE?"
    end,
    step = function(game)
      pcall(StadiumRomPick.import, game)
      return true
    end,
  }
end

-- ------- a pick that landed while we were not looking
--
-- The desktop dialog BLOCKS, so `import` above can read the answer on the
-- next line. A SAF pick cannot work that way: it is a separate activity,
-- Android is free to destroy the game while it is up, and the file appears
-- some frames later -- so the only way to notice one is to look for it.
--
-- Nothing writes this filename today (see canDialog). It is watched anyway so
-- that teaching the native bridge one more kind is the whole of the Android
-- picker work, with no second change needed here.
--
-- Consumed and DELETED either way: a 32 MB file left in the save directory
-- would be imported again on the next boot, and kept forever if the import
-- failed.
function StadiumRomPick.poll(game)
  local f = love and love.filesystem
  if not (f and f.getInfo) then return false end
  if StadiumInstall.status.state == "building" then return false end
  local ok, info = pcall(f.getInfo, StadiumRomPick.PICKED, "file")
  if not (ok and info) then return false end

  local okRead, bytes = pcall(f.read, StadiumRomPick.PICKED)
  pcall(f.remove, StadiumRomPick.PICKED)
  if not (okRead and type(bytes) == "string") then return false end

  local StadiumScreen = V.require("StadiumScreen")
  local started, err = StadiumInstall.beginFrom(bytes, StadiumRomPick.PICKED)
  if not started then
    StadiumInstall.status.state = "failed"
    StadiumInstall.status.error = tostring(err)
  end
  if game and game.stack then
    game.stack:push(StadiumScreen.new(game, true))
  end
  return true
end

return StadiumRomPick