-- STADIUM battles: turning the ROM into assets/stadium/NNN.dsm. -- -- The Lua half of tools/stadium_pack.py: measure the bind pose, decide -- whether a species' standby loop can be trusted, and write the packed file. -- Together with StadiumRom, StadiumFragment and StadiumFx this is everything -- between `baserom.z64` and a Pokemon standing on a battle tile. -- -- The Python remains the ORACLE. tests/stadium_extract_test.lua runs this -- over the same ROM and requires all 151 files to come out byte for byte -- identical to what the packer writes. That is a strong test in a way a unit -- test of any one function here would not be: every rounding mode, every -- iteration order, every off-by-one in an index shows up as a differing byte, -- and there are thirty-four megabytes of them. -- -- ------- stepped, not blocking -- -- `StadiumBuild.job()` returns a coroutine-backed job that does one species -- per `step()`, so the caller can draw a progress bar between them -- (StadiumInstall). A species is a few tens of milliseconds; the whole set is -- around half a minute, which is far too long to spend inside one frame and -- perfectly fine spread across a loading screen. -- the mod namespace (see main.lua): V.require loads a sibling module local V = ... local StadiumRom = V.require("StadiumRom") local StadiumFragment = V.require("StadiumFragment") local StadiumFx = V.require("StadiumFx") local StadiumBuild = {} local floor = math.floor local sin, cos = math.sin, math.cos local pi = math.pi local char = string.char local concat = table.concat local frexp = math.frexp local roundHalfEven = StadiumFragment.roundHalfEven -- The battle system's fixed context slots, in slot order from 165. The mod -- indexes this list by POSITION, so the ORDER is the format's contract and -- has to stay identical to StadiumPack.CONTEXT and to the packer's CONTEXTS. StadiumBuild.CONTEXTS = { "idle", "hit", "faint", "entrance", "reaction_169", "reaction_170", "reaction_171", "reaction_172", "reaction_173", "reaction_174", "struggle", "idle_alt", "faint_alt", "flinch", "reaction_179", "reaction_180", "reaction_181", "reaction_182", "entrance_alt", "idle_return", } -- Which context name wins when several claim the same animation. local NAME_PREF = { "idle", "hit", "faint", "entrance", "struggle", "flinch" } local N_MOVES = StadiumRom.N_MOVES local CTX_BASE = 165 local NONE16 = 0xFFFF -- ------- the bind pose -- The game's rotation as a 3x3, rows first (src/F420.c func_8000F730): -- Rx*Ry*Rz in row-vector form. local function quatBasis(r) local sx, cx = sin(r[1] / 32768 * pi), cos(r[1] / 32768 * pi) local sy, cy = sin(r[2] / 32768 * pi), cos(r[2] / 32768 * pi) local sz, cz = sin(r[3] / 32768 * pi), cos(r[3] / 32768 * pi) return { cy * cz, sx * sy * cz - cx * sz, cx * sy * cz + sx * sz }, { cy * sz, sx * sy * sz + cx * cz, cx * sy * sz - sx * cz }, { -sy, sx * cy, cx * cy } end -- 3x4 (three rotation rows plus a translation column) times the same. local function matMul(a, b) local out = {} for r = 1, 3 do local ar = a[r] out[r] = { ar[1] * b[1][1] + ar[2] * b[2][1] + ar[3] * b[3][1], ar[1] * b[1][2] + ar[2] * b[2][2] + ar[3] * b[3][2], ar[1] * b[1][3] + ar[2] * b[2][3] + ar[3] * b[3][3], ar[1] * b[1][4] + ar[2] * b[2][4] + ar[3] * b[3][4] + ar[4], } end return out end -- One component of one bone's t/r/s at a frame. The extractor's own shape: a -- bare number when the component holds still for the whole animation, one -- number a frame when it does not. local function component(comps, i, frame, fallback) if comps == nil then return fallback end local c = comps[i] if type(c) == "table" then local n = #c if n == 0 then return fallback end return c[frame % n + 1] end return c end -- The bone TRS an animation holds at `frame`, rest where it is silent. local function animSample(bones, anim, frame) local tracks = anim.tracks return function(i) local b = bones[i] local tr = tracks[i] if not tr then return b.t, b.r, b.s end return { component(tr.t, 1, frame, b.t[1]), component(tr.t, 2, frame, b.t[2]), component(tr.t, 3, frame, b.t[3]) }, { component(tr.r, 1, frame, b.r[1]), component(tr.r, 2, frame, b.r[2]), component(tr.r, 3, frame, b.r[3]) }, { component(tr.s, 1, frame, b.s[1]), component(tr.s, 2, frame, b.s[2]), component(tr.s, 3, frame, b.s[3]) } end end local function restSample(bones) return function(i) local b = bones[i] return b.t, b.r, b.s end end -- Every bone's draw matrix at one instant, as 3x4 rows. -- -- The game keeps bone scale OUT of the matrix chain: it accumulates in its own -- stack, a bone's local translation is pre-multiplied by the PARENT's -- accumulated scale, and the bone's own accumulated scale is applied to the -- finished matrix at draw time. -- -- Two chains, and the distinction is the whole point: `pivot` is the -- rotation/translation a CHILD inherits, and the draw matrix is that with the -- bone's own accumulated scale applied on the right. Folding the scale into -- the chain instead applies every ancestor's scale twice -- which is exactly -- the multiplicative propagation glTF has and the game does not. local function bindMatrices(bones, sample) sample = sample or restSample(bones) local pivot, draw, acc = {}, {}, {} local IDENT = { { 1, 0, 0, 0 }, { 0, 1, 0, 0 }, { 0, 0, 1, 0 } } for i = 1, #bones do local bt, br, bs = sample(i) local p = bones[i].parent local pa = (p >= 0) and acc[p + 1] or { 1.0, 1.0, 1.0 } local pm = (p >= 0) and pivot[p + 1] or IDENT local r1, r2, r3 = quatBasis(br) local m = matMul(pm, { { r1[1], r1[2], r1[3], bt[1] * pa[1] }, { r2[1], r2[2], r2[3], bt[2] * pa[2] }, { r3[1], r3[2], r3[3], bt[3] * pa[3] }, }) local a = { pa[1] * bs[1], pa[2] * bs[2], pa[3] * bs[3] } acc[i] = a pivot[i] = m -- scale on the right: the bone's own space, so it cannot reach children draw[i] = { { m[1][1] * a[1], m[1][2] * a[2], m[1][3] * a[3], m[1][4] }, { m[2][1] * a[1], m[2][2] * a[2], m[2][3] * a[3], m[2][4] }, { m[3][1] * a[1], m[3][2] * a[2], m[3][3] * a[3], m[3][4] }, } end return draw end StadiumBuild.bindMatrices = bindMatrices StadiumBuild.animSample = animSample -- The axis-aligned box the whole model occupies under `mats`, in game units -- after the model_root scale. local function poseBox(data, mats) local root = data.rootScale[1] local lo1, lo2, lo3 = 1e30, 1e30, 1e30 local hi1, hi2, hi3 = -1e30, -1e30, -1e30 for _, prim in ipairs(data.prims) do local pos, skin = prim.pos, prim.skin for i = 1, prim.nverts do local m = mats[skin[i] + 1] if m then local x, y, z = pos[i * 3 - 2], pos[i * 3 - 1], pos[i * 3] local a = (m[1][1] * x + m[1][2] * y + m[1][3] * z + m[1][4]) * root local b = (m[2][1] * x + m[2][2] * y + m[2][3] * z + m[2][4]) * root local c = (m[3][1] * x + m[3][2] * y + m[3][3] * z + m[3][4]) * root if a < lo1 then lo1 = a end if b < lo2 then lo2 = b end if c < lo3 then lo3 = c end if a > hi1 then hi1 = a end if b > hi2 then hi2 = b end if c > hi3 then hi3 = c end end end end return lo1, lo2, lo3, hi1, hi2, hi3 end -- (height, floor, radius): how tall the mon is, where its lowest point sits -- relative to the model's own origin, and how wide it is -- all in game units -- after the model_root scale. -- -- Measured on the BIND POSE, which is the one pose in the set that can be -- trusted for this. It reproduces the verified glTF export exactly on all 151 -- species, and it is immune to the animation quirks a handful of them carry -- (see idleIsBroken) -- quirks that would otherwise decide how big every OTHER -- frame of those species is drawn. -- -- The floor is the interesting number, and it reads cleanly: 119 of the 151 -- sit within 5% of zero, which says the model origin IS where the game stands -- a Pokemon on its field. Every species that does not is one that hovers. local function stance(data) local lo1, lo2, lo3, hi1, hi2, hi3 = poseBox(data, bindMatrices(data.bones)) if lo1 > hi1 then return 0.0, 0.0, 0.0 end local w, d = hi1 - lo1, hi3 - lo3 return hi2 - lo2, lo2, (w > d and w or d) / 2 end StadiumBuild.stance = stance -- Whether this species' standby loop is corrupt in the source data. -- -- A handful come out of the extraction with animations that throw bones -- hundreds of units off the body -- Exeggutor, Tangela and Magmar, whose -- channel streams the game's own index arithmetic evidently reads differently -- from the way this does. Played, they look like a Pokemon coming apart; the -- mod would rather stand them still. -- -- The test is deliberately narrow, because "differs from the bind pose" is NOT -- brokenness. It is asked only of the STANDBY loop -- the one animation that -- is supposed to stay where it is, since a faint is meant to end far from the -- standing pose and an attack is meant to lunge -- and it wants both a large -- size blow-up and real drift, or an enormous amount of one. Dewgong is what -- calibrates it: its idle is 2.4x its own bind pose because the BIND is the -- collapsed one, and it drifts barely at all, so it must not be caught. local function idleIsBroken(data, idle) if idle == nil then return false end local bones = data.bones local _, lo2, _, _, hi2 = poseBox(data, bindMatrices(bones)) local span = hi2 - lo2 if span <= 0 then return false end local worstH, worstDrift = 1.0, 0.0 local frame = 0 while frame < idle.frames do local _, flo2, _, _, fhi2 = poseBox(data, bindMatrices(bones, animSample(bones, idle, frame))) local h = (fhi2 - flo2) / span if h > worstH then worstH = h end local d1 = (flo2 - lo2) / span local d2 = (fhi2 - hi2) / span if d1 < 0 then d1 = -d1 end if d2 < 0 then d2 = -d2 end if d1 > worstDrift then worstDrift = d1 end if d2 > worstDrift then worstDrift = d2 end frame = frame + 3 end return (worstH > 2.5 and worstDrift > 1.5) or worstDrift > 2.0 or worstH > 3.4 end -- ------- writing local function clamp(v, lo, hi) if v < lo then return lo end if v > hi then return hi end return v end -- Toward zero, which is what Python's int() does to a float and NOT what -- floor() does to a negative one. -- -- It matters in exactly one place, and it is easy to miss: almost everything -- reaching the integer writers below has already been rounded, so truncation -- is a no-op on it. The exception is the generated effects' crossed quads -- (StadiumFx), whose vertices are raw floats and straddle the origin -- so -- the ones at negative x, and only those, come out a unit adrift if this -- floors. local function trunc(v) if v >= 0 then return floor(v) end return -floor(-v) end -- 16.16, which holds every bone scale in the set (-31 .. 100) with more -- precision than anything can see. local function fixed(v) return clamp(roundHalfEven(v * 65536), -2147483648, 2147483647) end -- IEEE 754 single, little-endian, rounded to nearest with ties to even -- the -- same rounding Python's struct.pack('= 255 then return char(0, 0, 128, 127 + sign) -- overflow elseif E <= 0 then -- subnormal: no exponent left, so the mantissa carries the whole value mant = roundHalfEven(x / 2 ^ -149) if mant >= 8388608 then mant, E = mant - 8388608, 1 else E = 0 end else mant = roundHalfEven((m * 2 - 1) * 8388608) if mant == 8388608 then -- rounded up into the next mant, E = 0, E + 1 if E >= 255 then return char(0, 0, 128, 127 + sign) end end end local b4 = sign + floor(E / 2) local b3 = (E % 2) * 128 + floor(mant / 65536) local b2 = floor(mant / 256) % 256 local b1 = mant % 256 return char(b1, b2, b3, b4) end StadiumBuild.f32 = f32 local Writer = {} Writer.__index = Writer local function newWriter() return setmetatable({ parts = {}, n = 0 }, Writer) end function Writer:raw(s) self.n = self.n + 1 self.parts[self.n] = s end function Writer:u8(v) self:raw(char(v % 256)) end function Writer:i8(v) v = clamp(trunc(v), -128, 127) self:raw(char(v % 256)) end function Writer:u16(v) v = v % 65536 self:raw(char(v % 256, floor(v / 256))) end function Writer:i16(v) v = clamp(trunc(v), -32768, 32767) % 65536 self:raw(char(v % 256, floor(v / 256))) end function Writer:u32(v) v = v % 4294967296 self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256, floor(v / 16777216) % 256)) end function Writer:i32(v) v = clamp(trunc(v), -2147483648, 2147483647) % 4294967296 self:raw(char(v % 256, floor(v / 256) % 256, floor(v / 65536) % 256, floor(v / 16777216) % 256)) end function Writer:f32(v) self:raw(f32(v)) end function Writer:bytes() return concat(self.parts) end -- One component of one bone's t/r/s in one animation. `values` is the -- extractor's own shape: a bare number when the component holds still for the -- whole animation, or one number a frame when it does not. That fold is where -- most of the size saving is -- a bone that only rotates costs two bytes for -- each of its six other components. local function writeTrackComponent(w, values, kind) local isArray = type(values) == "table" w:u8(isArray and 1 or 0) if kind == "s" then if isArray then for i = 1, #values do w:i32(fixed(values[i])) end else w:i32(fixed(values)) end else if isArray then for i = 1, #values do w:i16(roundHalfEven(values[i])) end else w:i16(roundHalfEven(values)) end end end -- Which animation each fixed battle context slot resolves to: entries 165 -- upward of the species' own battle table, in slot order. An entry naming an -- animation the species does not have is written as "none" rather than -- clamped -- the mod would rather fall back than play the wrong clip. function StadiumBuild.contextTable(rows, nAnims) local ctx = {} for i = 1, #StadiumBuild.CONTEXTS do local row = rows[CTX_BASE + i - 1] local ai = row and row[1] or nil ctx[i] = (ai ~= nil and ai < nAnims) and ai or NONE16 end return ctx end -- ------- naming the animations -- -- build.py's label_animations. The names are not read at runtime -- the mod -- addresses animations by index through the move and context tables -- but -- they are in the format, so they have to be produced the same way for the -- oracle diff to mean anything. They also make a packed file readable in a -- hex dump, which is worth the byte apiece. local function labelAnimations(data, rows, nAux) local anims = data.anims local n = #anims local uses, moveUses = {}, {} local auxOrder, auxCount = {}, {} for i = 1, n do uses[i], moveUses[i] = {}, 0 auxOrder[i], auxCount[i] = {}, {} end for e = 0, rows.n - 1 do local ai = rows[e][1] if ai < n then if e < N_MOVES then moveUses[ai + 1] = moveUses[ai + 1] + 1 elseif e >= CTX_BASE and e < CTX_BASE + #StadiumBuild.CONTEXTS then local list = uses[ai + 1] list[#list + 1] = StadiumBuild.CONTEXTS[e - CTX_BASE + 1] end local ax = rows[e][2] if ax >= 0 and ax < nAux then local counts, order = auxCount[ai + 1], auxOrder[ai + 1] if counts[ax] == nil then counts[ax] = 0 order[#order + 1] = ax end counts[ax] = counts[ax] + 1 end end end for i = 1, n do -- sorted(set(uses)) -- the alphabetically first context is the fallback -- name, so the ordering is part of the answer local seen, ctx = {}, {} for _, name in ipairs(uses[i]) do if not seen[name] then seen[name] = true ctx[#ctx + 1] = name end end table.sort(ctx) local name = nil for _, pref in ipairs(NAME_PREF) do if seen[pref] then name = pref break end end if not name then if moveUses[i] > 0 then name = "attack" elseif ctx[1] then name = ctx[1] else name = "anim" .. (i - 1) end end anims[i].name = name -- Counter.most_common(1): the highest count, and on a tie the one that -- was inserted first local best, bestN = -1, -1 local order, counts = auxOrder[i], auxCount[i] for _, ax in ipairs(order) do if counts[ax] > bestN then best, bestN = ax, counts[ax] end end anims[i].aux = best end local seenName = {} for i = 1, n do local base = anims[i].name local k = seenName[base] or 0 seenName[base] = k + 1 if k > 0 then anims[i].name = base .. "_" .. (k + 1) end end end -- ------- the pack function StadiumBuild.pack(data, species, moveRows, ctx) local w = newWriter() local bones, prims = data.bones, data.prims local textures, anims, aux = data.textures, data.anims, data.auxAnims local height, floorY, radius = stance(data) local idleIndex = ctx[1] -- CONTEXTS[1] is "idle" local idle = (idleIndex ~= NONE16) and anims[idleIndex + 1] or nil local static = idleIsBroken(data, idle) w:raw("DSM3") w:u16(species) w:u16(#bones) w:u16(#prims) w:u16(#textures) w:u16(#anims) w:u16(#aux) w:f32(data.rootScale[1]) -- 1 = hold the bind pose, never play an animation w:u8(static and 1 or 0) w:f32(height) w:f32(floorY) w:f32(radius) for m = 1, N_MOVES do local row = moveRows[m] w:u16((row and row[1] < #anims) and row[1] or NONE16) end for m = 1, N_MOVES do local row = moveRows[m] w:i16((row and row[2] >= 0 and row[2] < #aux) and row[2] or -1) end for i = 1, #ctx do w:u16(ctx[i]) end for i = 1, #bones do local b = bones[i] w:i16(b.parent) for k = 1, 3 do w:i16(roundHalfEven(b.t[k])) end for k = 1, 3 do w:i16(b.r[k]) end for k = 1, 3 do w:i32(fixed(b.s[k])) end end for i = 1, #prims do local p = prims[i] w:u16(p.tex) -- the display list's own cull mode: 1024 is G_CULL_BACK w:u8((p.cull and p.cull ~= 0) and 1 or 0) w:u8((p.blend == "add") and 1 or 0) w:i16(p.texAnim or -1) -- sorted by the stream's own byte, which is what the reader keys on local keys = {} if p.texMap then for k in pairs(p.texMap) do keys[#keys + 1] = k end table.sort(keys) end w:u8(#keys) for _, k in ipairs(keys) do w:u8(k) w:u16(p.texMap[k]) end local frames = p.fxFrames w:u16(frames and #frames or 0) if frames then for k = 1, #frames do w:u16(frames[k]) end end local pos, uv, nrm, skin = p.pos, p.uv, p.nrm, p.skin w:u16(p.nverts) w:u16(p.nidx) for k = 1, p.nverts do w:i16(pos[k * 3 - 2]) w:i16(pos[k * 3 - 1]) w:i16(pos[k * 3]) -- 1/512, which puts a texel of the largest texture in the set well -- inside a step and still reaches the +-32 the wrapped coordinates of -- some display lists run to w:i16(roundHalfEven(uv[k * 2 - 1] * 512)) w:i16(roundHalfEven(uv[k * 2] * 512)) w:i8(roundHalfEven(nrm[k * 3 - 2] * 127)) w:i8(roundHalfEven(nrm[k * 3 - 1] * 127)) w:i8(roundHalfEven(nrm[k * 3] * 127)) w:u8(skin[k]) end for k = 1, p.nidx do w:u16(p.idx[k]) end end for i = 1, #textures do local t = textures[i] w:u16(t.w) w:u16(t.h) w:u32(#t.rgba) w:raw(t.rgba) end local REST = { t = { 0, 0, 0 }, r = { 0, 0, 0 }, s = { 1.0, 1.0, 1.0 } } for i = 1, #anims do local a = anims[i] local name = a.name or "" if #name > 255 then name = name:sub(1, 255) end w:u8(#name) w:raw(name) w:u16(a.frames) w:u16(a.loopStart or 0) w:i16(a.aux or -1) for bi = 1, #bones do local tr = a.tracks[bi] if not tr then w:u8(0) else w:u8(1) for _, key in ipairs({ "t", "r", "s" }) do local comps = tr[key] if comps == nil then -- a bone the animation leaves at its rest value for this path: -- written as three constants so the reader never has to branch on -- a missing path comps = bones[bi][key] or REST[key] end for c = 1, 3 do writeTrackComponent(w, comps[c], key) end end end end end for i = 1, #aux do local a = aux[i] w:u16(a.frames) w:u16(a.loopStart or 0) w:u16(#a.channels) for _, ch in ipairs(a.channels) do w:u16(ch.n) for k = 1, ch.n do w:u16(ch[k]) end end end return w:bytes(), height, floorY, radius end -- ------- one species, end to end -- The same three steps build.py takes: parse the fragment, label the -- animations off the species' battle table, then hang the generated fire/gas -- stand-ins on the bones the game's own effect callbacks hang off. function StadiumBuild.species(rom, fileno) local blob = rom:model(fileno) if not blob then return nil, ("file %d is not in the archive"):format(fileno) end local data, err = StadiumFragment.extract(blob, ("%d.bin"):format(fileno)) if not data then return nil, err end local species = data.species local rows = rom:battleRows(species) labelAnimations(data, rows, #data.auxAnims) StadiumFx.attach(data, species) local moveRows = {} for m = 1, N_MOVES do moveRows[m] = rows[m - 1] end local ctx = StadiumBuild.contextTable(rows, #data.anims) local bytes, height, floorY, radius = StadiumBuild.pack(data, species, moveRows, ctx) return { species = species, bytes = bytes, height = height, floor = floorY, radius = radius, bones = #data.bones, prims = #data.prims, anims = #data.anims, warnings = data.warnings } end -- ------- the stepped job -- -- `write(species, bytes)` is called for each finished pack and must answer -- truthy; anything else stops the job with an error. Returning a job rather -- than taking a callback for progress keeps the caller in charge of when work -- happens, which is what lets a loading screen stay responsive. function StadiumBuild.job(rom, write, count) local total = count or StadiumRom.N_POKEMON local n = rom:modelCount() if total > n then total = n end local job = { total = total, done = 0, bytes = 0, failed = {}, species = nil } function job:step() if self.done >= self.total then return false end local fileno = self.done local ok, res, err = pcall(StadiumBuild.species, rom, fileno) if ok and res then local wrote, wErr = write(res.species, res.bytes) if not wrote then self.error = wErr or ("could not write species " .. res.species) self.done = self.total return false end self.bytes = self.bytes + #res.bytes self.species = res.species else self.failed[#self.failed + 1] = fileno self.lastError = ok and err or res end self.done = self.done + 1 return self.done < self.total end function job:progress() if self.total <= 0 then return 1 end return self.done / self.total end return job end return StadiumBuild