mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:50:50 +02:00
a3fb18a589
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to decide it -- and the engine already ships that reading (Stats.isShiny, its own comment calling it "the RBY virtual shiny", allowlisted for mods precisely so an indicator mod can call it). Nothing new is stored on a Pokemon and nothing migrates: every save already contains the answer, and this starts drawing it. Random DVs land on the pattern 1 in 8192, which is the classic rate and the default the odds dial ships at. Deriving rather than storing is what makes it survive a save, a box, a trade and an evolution with no second copy of the truth to drift. mon.shiny is a cache written from the DVs, never read as the source. The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is built there, and it is before the battle bakes its sprite, which battle.started is already too late for. It draws from the mod's own random stream so installing this does not shift the sequence damage rolls and encounter slots come out of. Trainers stay ordinary by themselves: the engine pins their DVs, as the real games do. The models are genuinely recoloured, as part of the extraction. Each species is decoded once, packed as usual, then recoloured and packed again as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees, saturation and lightness on a -8..+8 scale at 12.5% a step); five species carry an explicit colour table instead, because Stadium gives them a real alternate texture that no single slide reproduces -- Jigglypuff's body must stay pink while its irises rotate to green. Extraction is the right moment because StadiumFx's generated frames are still marked there and the packer drops the marker: it is the last point a flame is distinguishable from a hide. A shiny Charizard has a shiny hide and an ordinary fire. The normal packs are written BEFORE the recolour, so they come out byte-identical and stadium_extract_test still diffs all 151 against the Python oracle unchanged -- no format change, no DSM4, no second implementation to keep in step. REV goes to 3 so an existing cache rebuilds. Flat art is tinted instead, because the engine bakes a species palette into a cache with no notion of which individual is drawn. The tint comes from that species' own slide rather than a generic gold. A multiply can only darken, so species whose shiny is lighter read quieter there than on the model; the status page's star is the mode-proof mark. Tests: 58 assertions in tests/shiny_test.lua, including the colour transform against 640 real colour pairs lifted from the verified texture set, the DV model, the read side, and the end-to-end through the engine's own constructor. stadium_extract_test gains --mod (worktrees have neither the ROM nor the packs, both gitignored) and now also checks that every shiny pack is the same length as its twin and actually differs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
751 lines
25 KiB
Lua
751 lines
25 KiB
Lua
-- 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 ShinyPalette = V.require("ShinyPalette")
|
|
|
|
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", "attack_default", "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", "attack_default", "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 as extracted.
|
|
--
|
|
-- No species trips this today. Exeggutor, Tangela and Magmar used to, when
|
|
-- the flags byte was misread and their hermite-keyframe animations were
|
|
-- decoded as packed streams, throwing bones hundreds of units off the body.
|
|
-- It stays as the guard against the next extraction bug: played, a broken
|
|
-- idle looks like a Pokemon coming apart, and the mod would rather show
|
|
-- the sprite fallback (see StadiumMon).
|
|
--
|
|
-- 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('<f') does, so the four floats in the
|
|
-- header come out bit for bit the same as the packer's.
|
|
local function f32(x)
|
|
local sign = 0
|
|
if x < 0 or (x == 0 and 1 / x < 0) then
|
|
sign = 128
|
|
x = -x
|
|
end
|
|
if x ~= x then return char(0, 0, 192, 127 + sign) end -- NaN
|
|
if x == math.huge then return char(0, 0, 128, 127 + sign) end
|
|
if x == 0 then return char(0, 0, 0, sign) end
|
|
local m, e = frexp(x) -- x = m * 2^e, 0.5 <= m < 1
|
|
local E = e - 1 + 127
|
|
local mant
|
|
if E >= 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)
|
|
|
|
-- ------- and the shiny, from the same extraction
|
|
--
|
|
-- ORDER MATTERS AND IS THE WHOLE TRICK. The normal pack is written FIRST,
|
|
-- off untouched texels, so `bytes` is bit-for-bit what it has always been
|
|
-- and tests/stadium_extract_test.lua keeps diffing green against the
|
|
-- Python oracle. Only then are the textures recoloured and the model
|
|
-- packed a second time. The oracle knows nothing about shiny and does not
|
|
-- need to: the format did not move, so there is no second implementation
|
|
-- to keep in step and no DSM4.
|
|
--
|
|
-- Recolouring HERE rather than at load is what makes the effect textures
|
|
-- separable. StadiumFx marks its generated frames `generated = true` and
|
|
-- the packer drops the field, so this is the last moment a flame is
|
|
-- distinguishable from a hide without inferring it back from the prim
|
|
-- table. A shiny Charizard has a shiny hide and an ordinary fire.
|
|
--
|
|
-- Failure is not fatal: a species whose colours we lack, or a transform
|
|
-- that throws, simply ships without a shiny variant and the runtime falls
|
|
-- back to the normal model. Losing a recolour is a blemish; losing the
|
|
-- install is a broken mod.
|
|
local shinyBytes
|
|
local ok, err = pcall(function()
|
|
local spec = ShinyPalette.forDex(species)
|
|
if not spec then return end
|
|
if ShinyPalette.recolorTextures(data.textures, spec) == 0 then return end
|
|
shinyBytes = StadiumBuild.pack(data, species, moveRows, ctx)
|
|
end)
|
|
if not ok and V and V.mod and V.mod.log then
|
|
V.mod.log.warn("shiny recolour failed for species %d: %s",
|
|
species, tostring(err))
|
|
end
|
|
|
|
return { species = species, bytes = bytes, shinyBytes = shinyBytes,
|
|
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, res.shinyBytes)
|
|
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
|
|
if res.shinyBytes then
|
|
self.bytes = self.bytes + #res.shinyBytes
|
|
self.shiny = (self.shiny or 0) + 1
|
|
end
|
|
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
|