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

StadiumInstall.lua

lib/StadiumInstall.lua17.0 KB100644d706437892
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 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
-- 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"

-- Bumped when the packs' CONTENT changes without the byte layout moving, so
-- a cache built by an older extractor is rebuilt rather than trusted. Rev 2
-- is the hermite-animation decode fix: the five keyframe species (Pidgeot,
-- Dodrio, Exeggutor, Tangela, Magmar) come out garbled or bind-posed from
-- any rev-1 build.
--
-- Rev 3 adds the shiny variants (NNNs.dsm). The normal packs are unchanged
-- byte for byte, so this is exactly the case REV exists for and not a FORMAT
-- bump: nothing about DSM3 moved, there is simply a second file per species
-- that a rev-2 cache does not have. Without the bump a player who already
-- installed would keep a complete-looking cache with no shiny models in it,
-- and every shiny they met would silently show its normal colours.
StadiumInstall.REV = 3

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 same thing with a FILENAME on the end, which is what a player actually
-- needs: a folder alone leaves them guessing what to call the file, and the
-- guess is not obviously "baserom.z64".
--
-- Taken from the head of NAMED rather than retyped, so the name shown is by
-- construction the first name looked for. It is not the ONLY one that works
-- -- `.n64` and `.v64` are accepted, and so is any other name carrying one
-- of those extensions -- but an instruction that names one file is one a
-- player can follow, and an instruction that lists every possibility is one
-- they have to interpret.
function StadiumInstall.romHintFile()
  return StadiumInstall.romHint() .. "/" .. (NAMED[1]:match("[^/]+$") or "")
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, rev = text:match("^(%S+)%s+(%d+)%s*(%S*)%s*(%S*)")
  if not format then return nil end
  return { format = format, count = tonumber(count), md5 = md5,
           rev = tonumber(rev) }
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 m.rev == StadiumInstall.REV) 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 packs on disk can be READ, even if they are not current.
--
-- Format and count, but deliberately NOT rev. The distinction matters on an
-- upgrade: a rev bump means the packs are out of date, not that they are
-- unreadable, and treating the two the same is what would make the STADIUM
-- rungs disappear off the options row for anyone whose cache predates it.
-- Losing the recolour until a rebuild is a blemish; losing the mode is not.
function StadiumInstall.usable()
  local m = readMarker()
  return (m ~= nil and m.format == StadiumInstall.FORMAT
          and m.count == StadiumInstall.COUNT) and true or false
end

-- Whether the STADIUM rungs can be offered at all: the packs have been built
-- from the player's ROM (current or merely readable), or the mod folder
-- already carries a set.
function StadiumInstall.available()
  if StadiumInstall.ready() then return true end
  if StadiumInstall.usable() then return true end
  return shipped()
end

-- Whether there is work to do: a ROM to build from, and no CURRENT set.
--
-- Keyed on ready() rather than available(), and that is the whole upgrade
-- story. It used to short-circuit on available(), which meant a checkout
-- carrying assets/stadium was never pending -- so when REV went to 3 for the
-- shiny variants, such a machine did not rebuild, was not asked to, and
-- quietly kept serving the old set: every shiny Pokemon drawn in its
-- ordinary colours, with nothing on screen to say why. That is exactly what
-- happened here, and it took a driver run sitting at "idle 0/151" to notice.
--
-- The cost this trades away is real and was the original reason: a checkout
-- with a ROM now spends one loading screen rebuilding a set it already had
-- files for. Once. After that ready() is true and it is not pending again --
-- and what it buys is that a rev bump actually reaches the people it was
-- bumped for.
function StadiumInstall.pending()
  if not StadiumInstall.romPresent() then return false end
  return not StadiumInstall.ready()
end

function StadiumInstall.forget()
  readyCache = nil
end

-- ------- building

local job = nil
local status = { state = "idle", done = 0, total = StadiumInstall.COUNT }

StadiumInstall.status = status

-- The shiny variant rides beside its species as NNNs.dsm.
--
-- A separate FILE rather than a second block inside NNN.dsm, and that is a
-- deliberate trade. A second block would mean a new magic (DSM4), the same
-- change mirrored into tools/stadium_pack.py, a regenerated oracle and a
-- re-run of the 34MB byte diff -- the project's central safety net disturbed
-- for a feature that does not need the format to move at all. As its own
-- file it is the SAME DSM3 a normal pack is, written by the same writer and
-- read by the same reader, and the 151 normal packs stay byte-identical.
--
-- A species with no shiny variant simply has no NNNs.dsm, and StadiumPack
-- falls back to the normal model. That is also what a half-finished install
-- looks like, which is the behaviour we want from one.
local function writePack(species, bytes, shinyBytes)
  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
  if shinyBytes then
    -- A failed shiny write is not a failed install: the species still has
    -- its model. Left unwritten, the runtime shows the normal one.
    local sok, serr = f.write(
      ("%s/%03ds.dsm"):format(StadiumInstall.DIR, species), shinyBytes)
    if not sok and V.mod and V.mod.log then
      V.mod.log.warn("shiny pack %03d not written: %s", species, tostring(serr))
    end
  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 %d\n"):format(StadiumInstall.FORMAT, job.total,
                                     tostring(job.md5 or ""),
                                     StadiumInstall.REV))
      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