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

StadiumRom.lua

lib/StadiumRom.lua10.9 KB100644e57493f508
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
-- STADIUM battles: getting at the Pokemon Stadium ROM.
--
-- Byte order, the archive the battle models are packed into, the Yay0
-- decompressor that unwraps each one, and the per-species battle tables. It
-- is a port of model_extract/pipeline/rom.py, function for function, and the
-- Python remains the reference: tools/stadium_pack.py drives that side and
-- tests/stadium_extract_test.lua diffs this side's finished packs against it
-- byte for byte.
--
-- ------- why this exists in Lua at all
--
-- The mod cannot ship the models. They are ROM data, so what ships is the
-- READER, and the player supplies the ROM -- exactly the arrangement the
-- engine itself already has for the Game Boy ROM it is a recompilation of
-- (src/import/RomImporter.lua). Everything from `baserom.z64` to
-- `assets/stadium/NNN.dsm` therefore has to happen here, on the machine, in
-- Lua, with no Python and no build step.
--
-- ------- what makes that tractable
--
-- Three steps, and none of them needs a decompilation toolchain:
--
--   1. BYTE ORDER. The three N64 dump conventions differ by a swap that is
--      detected from the magic word and undone once, on load.
--   2. THE ARCHIVE. The segment at 0x920000 is a count and a table of
--      (offset, size) records. No compression at that level, no names.
--   3. Yay0. Nintendo's LZ variant: a bitstream where a 1 copies a literal
--      byte and a 0 pulls a (distance, length) pair out of a side table.
--      Thirty lines, and the same thirty lines the Python has.
--
-- Verified in the Python by decompressing all 215 entries and diffing against
-- what the decompilation's own `make init` produces: 215/215 identical.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local StadiumRom = {}

local byte = string.byte
local char = string.char
local concat = table.concat
local sub = string.sub
local floor = math.floor

-- ROM offsets, from pokestadium-us.yaml by way of pipeline/rom.py.
StadiumRom.POKEMON_MODELS = 0x920000    -- archive of the 215 battle models
StadiumRom.BATTLE_DATA    = 0x70D3A0    -- per-species battle tables
StadiumRom.MAIN_ROM       = 0x1000      -- main code segment ...
StadiumRom.MAIN_VRAM      = 0x80000400  -- ... and where it lands in RAM
StadiumRom.PTR_TABLE_VRAM = 0x80075BD0  -- D_80075BD0[species - 1]

-- The revision every offset above is keyed to. A different ROM still runs --
-- it may well be a regional variant with the same layout -- but the caller is
-- told, because "the models came out as garbage" and "that is not the ROM
-- this was written against" are the same fact and only one of them is useful.
StadiumRom.US_MD5 = "ed1378bc12115f71209a77844965ba50"

-- The battle table's shape: 0xB90 bytes a species, as 0x10-byte entries.
-- Entries 0..164 are the moves (entry n drives move n + 1) and 165 up are the
-- fixed battle contexts.
StadiumRom.STRIDE = 0xB90
StadiumRom.ENTRY = 0x10
StadiumRom.N_MOVES = 165

-- How many of the archive's 215 models are the battle Pokemon. The rest are
-- props and trophies with no battle table.
StadiumRom.N_POKEMON = 151

-- ------- byte order
--
-- .z64 is big-endian and native; .v64 has each pair of bytes swapped; .n64
-- has each word reversed. `gsub` with a capture-reversing replacement does
-- either in one call through C rather than a Lua loop over 33 million bytes.

local MAGIC_Z64 = "\128\055\018\064"
local MAGIC_V64 = "\055\128\064\018"
local MAGIC_N64 = "\064\018\055\128"

-- Normalise a dump to .z64 order, or nil when it is not an N64 ROM at all.
function StadiumRom.normalise(bytes)
  if type(bytes) ~= "string" or #bytes < 0x1000 then return nil end
  local magic = sub(bytes, 1, 4)
  if magic == MAGIC_Z64 then return bytes end
  if magic == MAGIC_V64 then return (bytes:gsub("(.)(.)", "%2%1")) end
  if magic == MAGIC_N64 then
    return (bytes:gsub("(.)(.)(.)(.)", "%4%3%2%1"))
  end
  return nil
end

-- ------- Yay0
--
-- The output has to be RANDOM ACCESS while it is being written -- a back
-- reference copies from what has already been produced, and overlapping runs
-- are legal and common -- so it is built in a flat table of byte values and
-- turned into a string at the end.
--
-- The string.char conversion is the part that wants care: it is variadic and
-- has an argument limit, so the table is walked in blocks and the blocks
-- concatenated. Blocks of 4096 keep the call count and the intermediate
-- string count both low; the whole 151-model set converts in well under a
-- second on LuaJIT, which is what made an FFI buffer unnecessary here and
-- kept this module portable to any Lua the engine runs on.

local CHUNK = 4096

-- LuaJIT keeps `unpack` global; 5.2+ moved it onto table.
local unpack = unpack or table.unpack

local function bytesToString(out, n)
  if n == 0 then return "" end
  local parts, np = {}, 0
  local i = 1
  while i <= n do
    local j = i + CHUNK - 1
    if j > n then j = n end
    np = np + 1
    parts[np] = char(unpack(out, i, j))
    i = j + 1
  end
  return concat(parts)
end

-- Nintendo Yay0. Header: magic, decompressed size, link table offset, chunk
-- offset; then a bitstream read a word at a time.
function StadiumRom.yay0(src, base)
  base = base or 0
  if sub(src, base + 1, base + 4) ~= "Yay0" then return nil, "not Yay0" end
  local function be32(o)
    local a, b, c, d = byte(src, base + o + 1, base + o + 4)
    return ((a * 256 + b) * 256 + c) * 256 + d
  end
  local size = be32(4)
  -- all three cursors are 1-based indices into `src`; the mask stream starts
  -- immediately after the 16-byte header
  local maskP = base + 0x10 + 1
  local linkP = base + be32(8) + 1
  local chunkP = base + be32(12) + 1

  local out = {}
  local pos = 0                      -- bytes produced so far
  local mask, bits = 0, 0

  while pos < size do
    if bits == 0 then
      local a, b, c, d = byte(src, maskP, maskP + 3)
      mask = ((a * 256 + b) * 256 + c) * 256 + d
      maskP = maskP + 4
      bits = 32
    end
    if mask >= 0x80000000 then
      pos = pos + 1
      out[pos] = byte(src, chunkP)
      chunkP = chunkP + 1
    else
      local a, b = byte(src, linkP, linkP + 1)
      linkP = linkP + 2
      local link = a * 256 + b
      local dist = link % 0x1000
      local count = floor(link / 0x1000)
      if count == 0 then
        count = byte(src, chunkP) + 0x12
        chunkP = chunkP + 1
      else
        count = count + 2
      end
      -- overlapping runs are legal: copying one byte at a time from the
      -- output as it grows is the behaviour, not a naive version of it
      local copy = pos - dist
      for _ = 1, count do
        pos = pos + 1
        out[pos] = out[copy]
        copy = copy + 1
      end
    end
    mask = (mask * 2) % 0x100000000
    bits = bits - 1
  end

  return bytesToString(out, size)
end

-- Unwrap whatever container an asset arrived in. The model archive's entries
-- are PERS-SZP: an eight-byte magic plus a header size, wrapping a Yay0
-- stream.
function StadiumRom.decompress(blob)
  if sub(blob, 1, 8) == "PERS-SZP" then
    local a, b, c, d = byte(blob, 9, 12)
    local header = ((a * 256 + b) * 256 + c) * 256 + d
    return StadiumRom.yay0(blob, header)
  end
  if sub(blob, 1, 4) == "Yay0" then return StadiumRom.yay0(blob, 0) end
  return blob
end

-- ------- the ROM

local Rom = {}
Rom.__index = Rom

-- `bytes` is the whole file. Returns the ROM, or nil plus why.
function StadiumRom.open(bytes)
  local data = StadiumRom.normalise(bytes)
  if not data then return nil, "not an N64 ROM (bad magic)" end
  return setmetatable({ data = data }, Rom)
end

function Rom:u8(o)
  return byte(self.data, o + 1)
end

function Rom:u32(o)
  local a, b, c, d = byte(self.data, o + 1, o + 4)
  if not d then return 0 end
  return ((a * 256 + b) * 256 + c) * 256 + d
end

function Rom:vramToRom(vram)
  return StadiumRom.MAIN_ROM + (vram - StadiumRom.MAIN_VRAM)
end

-- The md5 of the normalised image, or nil where LOVE's hash is not there
-- (the headless suite). Only ever used to tell the player which ROM they
-- gave us, never to refuse one.
function Rom:md5()
  if self.hash ~= nil then return self.hash or nil end
  local ok, hex = pcall(function()
    local digest = love.data.hash("md5", self.data)
    if type(digest) == "userdata" and digest.getString then
      digest = digest:getString()
    end
    return love.data.encode("string", "hex", digest)
  end)
  self.hash = (ok and hex) or false
  return self.hash or nil
end

function Rom:isExpectedUS()
  local hex = self:md5()
  return hex == nil or hex == StadiumRom.US_MD5
end

-- ------- the archive
--
-- Segments that hold many files start with
--     u32 tag, u32 0, u32 totalSize, u32 fileCount
-- followed by fileCount { u32 offset, u32 size, u32 pad[2] } records, all
-- relative to the start of the segment.
--
-- Only the top three bytes of the first word are reliably zero: the model
-- archive puts a nonzero value in the low byte, which is the same quirk the
-- decompilation's own tools/unpack_asset.py works around.
--
-- Returns a list of { start, size } rather than the bytes, so nothing is
-- copied until a caller actually wants a file.
function Rom:archive(off)
  local tag = self:u32(off)
  if (tag - tag % 256) ~= 0 or self:u32(off + 4) ~= 0 then return nil end
  local count = self:u32(off + 12)
  if count <= 0 or count >= 4096 then return nil end
  local out = {}
  for i = 0, count - 1 do
    local rec = off + 0x10 + i * 0x10
    out[i + 1] = { start = off + self:u32(rec), size = self:u32(rec + 4) }
  end
  return out
end

-- The entries of the battle-model archive, uncopied.
function Rom:models()
  if not self.modelDir then
    self.modelDir = self:archive(StadiumRom.POKEMON_MODELS) or {}
  end
  return self.modelDir
end

function Rom:modelCount()
  return #self:models()
end

-- One model fragment, decompressed. `fileno` is 0-based, as in the Python and
-- in the source-file names: `N.bin` holds species N + 1.
function Rom:model(fileno)
  local rec = self:models()[fileno + 1]
  if not rec then return nil end
  return StadiumRom.decompress(sub(self.data, rec.start + 1,
                                   rec.start + rec.size))
end

-- ------- the per-species battle tables
--
-- func_84302658 in src/fragments/62 DMAs 0xB90 bytes a species out of the
-- 0x70D3A0 segment, addressed through the D_80075BD0 pointer table. Byte 0 of
-- each 0x10-byte entry indexes that Pokemon's animation list and byte 1 its
-- auxiliary (texture) animation list.
--
-- Returns a 0-based array-like table of { anim, aux }, aux 0xFF meaning none
-- and coming back as -1 -- the shape the packer writes.
function Rom:battleRows(species)
  local ptrTable = self:vramToRom(StadiumRom.PTR_TABLE_VRAM)
  local raw = self:u32(ptrTable + (species - 1) * 4)
  local o = StadiumRom.BATTLE_DATA + raw % 0x1000000
  local rows = {}
  local n = StadiumRom.STRIDE / StadiumRom.ENTRY
  for e = 0, n - 1 do
    local anim = self:u8(o + e * StadiumRom.ENTRY)
    local aux = self:u8(o + e * StadiumRom.ENTRY + 1)
    rows[e] = { anim, aux == 0xFF and -1 or aux }
  end
  rows.n = n
  return rows
end

return StadiumRom