mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 17:10:52 +02:00
315 lines
11 KiB
Lua
315 lines
11 KiB
Lua
-- 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
|