mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 11:10:53 +02:00
dramatic stadium
This commit is contained in:
@@ -0,0 +1,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
|
||||
Reference in New Issue
Block a user