fix arrival animation, fix pidgey regression

This commit is contained in:
DramaticShape
2026-08-04 13:22:41 -04:00
parent ecb0b57d26
commit 7ce268e5a2
6 changed files with 346 additions and 54 deletions
+50 -1
View File
@@ -286,6 +286,53 @@
the shot driver's `DS_FAINT` case prints the frame the bar empties against
the frame the animation starts, and they are the same frame.
- **The first Pokemon of a battle arrived, left, and arrived again.** Every
guard deciding whether the player's Pokemon is on the field is a field the
engine sets once the battle is RUNNING, and during the opening none of them
is set yet: `showPlayerBack` is still nil (BattleState assigns it further
in), `playerBackPic` is nil with it, and `sendingOut` does not go true until
the ball is thrown. So the whole intro read as "this Pokemon is standing on
the field" -- two and a half seconds of it, on its tile, playing its standby
loop, before the trainer sprite it is meant to be hiding behind had even
appeared. It then vanished when that sprite arrived and came back with its
entrance when the ball opened. A switch has no intro, which is why a switch
always looked right and was the thing worth comparing against. The player's
side is now simply not on the field during the intro phase.
- **The anchor made birds shake, and the shake read as fast flapping.** The
body estimate it corrects toward was the MEDIAN bone origin -- robust to a
few bones flung out, which is what it was chosen for, and wrong in a way
that only shows on a flapping model: a median is a RANK, and on a bird most
of the skeleton is wing, so which bone sits at the middle of the sorted list
swaps between the up cluster and the down one every beat. Measured, the
estimate moved a tenth of a body-height between adjacent half-frames on
Pidgey and three whole body-heights on Pidgeot, and the anchor turned that
into a translation of the entire Pokemon -- the body counter-shaking against
its own wings, which reads as flapping at twice the real speed.
The centre is now the bone origins averaged and **weighted by how many
vertices each bone moves**. The weights are a property of the mesh, computed
once, so there is no rank to flip -- and a bone with little geometry barely
counts, which is the robustness the median was for in the first place
(Farfetch'd's trail is 30 vertices on five bones). On top of that the
correction is low-passed, so what survives is where the Pokemon has drifted
to and never how it is shaking on the way. Pidgey's shake goes from 0.24 to
**0.10 pixels a frame** on a fourteen-pixel model, and travel correction
improved with it.
Two species -- Pidgeot and Dodrio -- have standby loops with genuinely junk
rotation frames, which move the estimate three and two body-heights in a
single frame against a fifth of a body-height for the fastest real motion in
the set. No filter separates those: rate-limiting the correction bounded the
shake but put 33 of the 148 entrances back outside the frame, and
rate-limiting the measurement could not tell a spike from an excursion
because they are only a factor of fifteen apart. So each species' own
standby loop is walked once, at rig construction, and one whose estimate is
that unsteady is **not anchored at all**: it travels as far as its animation
says and does not vibrate, which is exactly how it behaved before the anchor
existed. One species trading a framing problem for no problem beats 147
trading a solved framing problem for a shake.
- **A Pokemon calling out a fifth species killed every model in the fight.**
Reported as `Cannot use object after it has been released` out of
`Voxel3D.draw`, after which nothing 3D drew for the rest of the battle.
@@ -411,7 +458,9 @@
- Pidgeot, Dodrio and Grimer have a handful of erratic rotation frames in
their standby loops -- a few frames of junk at the top of the loop rather
than a corrupt stream. The blend guard holds those frames rather than
smoothing through them, so they step where the source steps. Not chased
smoothing through them, so they step where the source steps, and Pidgeot
and Dodrio are additionally left unanchored because those frames move the
body estimate too far to measure against (see above). Not chased
further: the extraction is byte-identical to a reference pipeline whose
sampling was validated against the decompilation's own arithmetic, and
guessing at the format to fix three species risks the other 145.
+23
View File
@@ -206,6 +206,29 @@ local function onField(battle, side, mon)
if battle.enemyHidden or battle.enemySendingOut then return false end
else
if battle.safari or battle.demo or battle.sendingOut then return false end
-- ------- and not before the battle has even opened
--
-- The player's Pokemon is not out during the INTRO. Every other guard
-- here is a field the engine sets once the battle is running, and during
-- the opening none of them is set yet: `showPlayerBack` is still nil
-- (BattleState assigns it further in, when the back pic is built),
-- `playerBackPic` is nil with it, and `sendingOut` does not go true until
-- the ball is actually thrown. So the whole opening read as "this
-- Pokemon is standing on the field" and the model was drawn through it --
-- two and a half seconds of it, on its tile, playing its standby loop,
-- before the trainer sprite it is supposed to be hiding behind had even
-- appeared. It then vanished when that sprite arrived and came back with
-- its entrance when the ball opened, so the first Pokemon of a battle
-- appeared, left and arrived again.
--
-- A SWITCH has no intro, which is why a switch always looked right and
-- was the thing worth comparing against.
--
-- Gated on the PHASE rather than on a flag latched at the send-out: a
-- latch that never fires (a link battle, a script pushing a battle
-- straight to the menu) would hide the Pokemon for good, and being wrong
-- in that direction is far worse than the two seconds this fixes.
if battle.phase == "intro" then return false end
end
local ok, hidden = pcall(battle.fxHidden, battle, battler)
if ok and hidden then return false end
+5 -1
View File
@@ -287,6 +287,10 @@ end
-- ------- per frame
function StadiumMon:update(dt)
-- kept for build(), which runs later in the same frame and needs it to
-- advance the anchor's filter (StadiumRig.anchor). Stashed before the
-- early-outs below, so a species with nothing to play still has one.
self.dt = dt or 0
local model = self.model
if not (model and self.anim) then return end
local anim = model.anims[self.anim]
@@ -393,7 +397,7 @@ function StadiumMon:build()
-- and then back onto the tile, because these animations were authored for
-- a camera that followed the Pokemon and this one does not move (see
-- StadiumRig.anchor)
self.rig:anchor(StadiumMon.TRAVEL)
self.rig:anchor(StadiumMon.TRAVEL, self.dt)
self.rig:skin(self.yaw or 0)
-- no clock of its own: the texture animation rides the frame pose() just
-- resolved, which is what keeps a blink inside its standby loop and a
+199 -28
View File
@@ -437,35 +437,80 @@ end
-- they were; past it the excess alone is removed, so a big move still reads
-- as big and still comes back to the tile it left.
--
-- ------- and why the MEDIAN bone
-- ------- where the body IS, and why it is not the median
--
-- The centre is the median bone origin on each axis, not the mean and not
-- the root. The mean is dragged by exactly the thing that must not count --
-- Farfetch'd's five-bone trail streaks three thousand units out while the
-- bird stays put -- and the root is a bone like any other, which several
-- species animate independently of the body hanging off it. The median is
-- the position most of the skeleton agrees on, and a handful of bones flung
-- anywhere cannot move it.
-- The first version of this took the median bone origin, on the reasoning
-- that a handful of bones flung anywhere cannot move a median. True, and it
-- had a worse problem: a median is a RANK, and a rank flips. On a bird most
-- of the skeleton is wing, so as the wings beat, which bone sits at the
-- middle of the sorted list swaps between the up cluster and the down one --
-- and the estimate jumps with it. Measured on Pidgey's standby loop the
-- median moved a tenth of a body-height between adjacent half-frames, and on
-- Pidgeot three whole body-heights. The anchor turns that straight into a
-- translation of the ENTIRE Pokemon, so the body counter-shook against its
-- own wings and the flapping read as twice its real speed. That is the
-- "Pidgey's wings flap super fast" this comment exists because of.
--
-- The centre is now the bone origins averaged, WEIGHTED BY HOW MANY VERTICES
-- EACH BONE MOVES. That fixes both halves at once:
--
-- * the weights are a property of the MESH, computed once and never
-- changing, so there is no rank to flip and no discontinuity available
-- to it -- the estimate is as smooth as the bones themselves
-- * a bone with little geometry on it barely counts, which is exactly the
-- robustness the median was for. Farfetch'd's trail is thirty vertices
-- on five bones -- 1.6% of the model -- so streaking three thousand
-- units out moves this by nothing worth measuring
--
-- Against the median it is two to five times smoother on every species
-- tested and measures the same travel to within a few percent.
-- How far the body estimate may move in ONE 30 Hz frame of a species' own
-- standby loop before that species is judged unmeasurable and left
-- unanchored (see measureBind). The fastest genuine motion in the set is
-- about a fifth of a body-height a frame; the one species that fails this
-- moves three.
StadiumRig.ANCHOR_STEADY = 0.5
-- Which context slot the standby loop is, without requiring StadiumPack --
-- this module is below it and a require would be circular. Position 1 of
-- StadiumPack.CONTEXT, which is the format's own contract.
local IDLE_SLOT = 1
-- How much of the model each bone actually carries. Cached on the shared
-- model: it is a fact about the mesh, not about this instance.
local function boneWeights(model)
if model.boneW then return model.boneW, model.boneWTotal end
local w, total = {}, 0
for b = 1, model.boneCount do w[b] = 0 end
for _, prim in ipairs(model.prims) do
local bone = prim.bone
for k = 1, prim.vertCount do
local b = bone[k]
if w[b] then w[b] = w[b] + 1; total = total + 1 end
end
end
model.boneW, model.boneWTotal = w, total
return w, total
end
-- The body centre of the pose currently in drawM.
local function centre(self, n)
-- made on demand as well as in new(), so the probes and the QA sweep --
-- which build a rig with no meshes by hand, because pose() needs none --
-- can measure without having to know about this scratch
local xs, ys, zs = self.cx, self.cy, self.cz
if not xs then
xs, ys, zs = {}, {}, {}
self.cx, self.cy, self.cz = xs, ys, zs
end
local model = self.model
local w, total = boneWeights(model)
if not (total > 0) then return nil end
local x, y, z = 0, 0, 0
local d = self.drawM
for b = 1, n do
local o = (b - 1) * 12
xs[b], ys[b], zs[b] = d[o + 4], d[o + 8], d[o + 12]
local q = w[b]
if q and q > 0 then
local o = (b - 1) * 12
x = x + d[o + 4] * q
y = y + d[o + 8] * q
z = z + d[o + 12] * q
end
end
for i = n + 1, #xs do xs[i], ys[i], zs[i] = nil, nil, nil end
table.sort(xs) table.sort(ys) table.sort(zs)
local h = floor(n / 2) + 1
return xs[h], ys[h], zs[h]
return x / total, y / total, z / total
end
-- Where the BIND pose puts it -- the spot every animation is measured
@@ -481,11 +526,117 @@ function StadiumRig:measureBind()
if model.bindCX then return end
self:pose(nil, 0, false)
model.bindCX, model.bindCY, model.bindCZ = centre(self, model.boneCount)
-- ------- and whether this species can be anchored at all
--
-- Decided ONCE, per model, offline, by walking its standby loop and asking
-- how far the body estimate moves between one frame and the next.
--
-- Everything the anchor does rests on that estimate being a description of
-- where the Pokemon is. For 147 species it is: the fastest real motion in
-- the set moves the body about a fifth of a body-height per 30 Hz frame.
-- Pidgeot's standby loop moves it THREE, because a few of its rotation
-- frames are junk (the worst data in the set, and a known issue in its own
-- right). There is no filter setting that both tracks a real excursion and
-- rejects that -- measured, at four time constants, either the excursions
-- came back or the shake did -- because the two are only a factor of
-- fifteen apart and a filter is a proportion.
--
-- So a species whose own idle says its estimate cannot be trusted is not
-- anchored, and plays exactly as it did before the anchor existed: it
-- travels as far as its animation says, and it does not vibrate. One
-- species trading a framing problem for no problem beats 147 trading a
-- solved framing problem for a shake.
--
-- Cheap: forty-odd poses on a model that is about to be posed sixty times
-- a second anyway.
local idle = model.ctx and model.ctx[IDLE_SLOT]
local anim = (idle and idle ~= 0xFFFF) and (idle + 1) or nil
local rec = anim and model.anims and model.anims[anim]
model.anchorOk = true
if rec and rec.frames and rec.frames > 1 then
local root = model.rootScale
if not (root and root > 0) then root = 1 end
local h = (model.height or 0) / root
if h > 0 then
local px, py, pz, worst = nil, nil, nil, 0
for f = 0, rec.frames - 1 do
self:pose(anim, f, true)
local x, y, z = centre(self, model.boneCount)
if x and px then
local d = (((x - px) ^ 2 + (y - py) ^ 2 + (z - pz) ^ 2) ^ 0.5) / h
if d > worst then worst = d end
end
px, py, pz = x, y, z
end
if worst > StadiumRig.ANCHOR_STEADY then
model.anchorOk = false
V.mod.log:info("stadium: species %s moves its own body %.1f "
.. "body-heights in one frame of its standby loop -- "
.. "not anchoring it, the measurement cannot be "
.. "trusted", tostring(model.species), worst)
end
end
end
-- and leave the bind pose behind, not the last frame of the idle
self:pose(nil, 0, false)
end
-- ------- and why the offset is SMOOTHED
--
-- A better centre is not enough on its own. Any estimate that follows the
-- pose carries the pose's own frame-to-frame wobble into it, and the anchor
-- multiplies that up into a translation of the whole Pokemon -- so a species
-- whose source data is erratic (Pidgeot's standby loop has a few frames of
-- junk in it, and no estimator can smooth data that is genuinely wrong)
-- would shake bodily rather than in the one bone that is wrong.
--
-- So the offset is low-passed. What the anchor is FOR is a slow excursion --
-- a Pokemon swimming seven body-heights away over two seconds -- and that
-- survives a filter with this time constant untouched, while anything
-- oscillating frame to frame is flattened. The correction ends up describing
-- where the Pokemon has drifted TO, never how it is shaking on the way.
--
-- HALF_LIFE is in seconds: the time the offset takes to close half of any
-- gap between where it is and where the pose says it should be. Short enough
-- that a real excursion is caught within a few frames of starting, long
-- enough that a 30 Hz wobble does not survive it.
StadiumRig.ANCHOR_HALF_LIFE = 0.05
-- ------- what this does NOT fix, and why it stops here
--
-- The filter is a proportion, so it divides the input wobble down rather than
-- bounding it -- and one species' data is bad enough to get through anyway.
-- Pidgeot's standby loop carries a few frames of junk rotation (the worst in
-- the set, and a known issue since before the anchor existed), which moves
-- the body estimate three body-heights inside a single frame; filtered, that
-- is still about three pixels a frame on a fourteen-pixel model.
--
-- Two further mechanisms were built and MEASURED against the set, and both
-- were taken back out:
--
-- a rate limit on the correction bounded the shake to a third of a pixel,
-- and cost so much tracking that 33 of the 148 entrances went back to
-- leaving the frame -- half the problem the anchor exists to solve
--
-- a rate limit on the MEASUREMENT, to tell a spike from an excursion by
-- speed, could not separate them: the fastest real excursion (Dewgong's
-- entrance, five and a half body-heights a second) is close enough to
-- Pidgeot's sustained junk that any threshold either clipped Dewgong or
-- passed Pidgeot, and freezing on distrust made both worse
--
-- So it stops here, at the setting that is right for the 147 species whose
-- data is not broken. Pidgeot is a data problem and belongs with the other
-- data problems in the CHANGELOG's Known section, not in this control loop:
-- the alternative was distorting every other Pokemon's animation to flatter
-- one whose source frames are wrong.
-- Pull the pose back toward the tile. `limit` is in the Pokemon's own
-- body-heights; nil or a non-positive value leaves the pose exactly as posed.
function StadiumRig:anchor(limit)
-- `dt` is the frame's own delta; without one the offset is applied whole,
-- which is what a still (the QA sweep, a probe) wants.
function StadiumRig:anchor(limit, dt)
if not (limit and limit > 0) then return end
local model = self.model
local n = model.boneCount
@@ -498,16 +649,36 @@ function StadiumRig:anchor(limit)
local bx, by, bz = model.bindCX, model.bindCY, model.bindCZ
if not bx then return end -- never measured; leave the pose alone
if model.anchorOk == false then return end -- and unmeasurable, at that
local x, y, z = centre(self, n)
if not x then return end
local dx, dy, dz = x - bx, y - by, z - bz
local dist = (dx * dx + dy * dy + dz * dz) ^ 0.5
local allow = limit * h
if dist <= allow or dist <= 0 then return end
-- the EXCESS only: what is inside the limit stays, so the motion keeps its
-- shape and only the part that would leave the frame is removed
local k = (dist - allow) / dist
local ox, oy, oz = dx * k, dy * k, dz * k
-- what the pose alone asks for: the EXCESS beyond the limit, so what is
-- inside it stays and the motion keeps its shape
local ox, oy, oz = 0, 0, 0
if dist > allow and dist > 0 then
local k = (dist - allow) / dist
ox, oy, oz = dx * k, dy * k, dz * k
end
-- and then toward it rather than straight to it (see ANCHOR_HALF_LIFE),
-- and never faster than ANCHOR_RATE
if dt and dt > 0 then
local half = StadiumRig.ANCHOR_HALF_LIFE
local a = (half > 0) and (1 - 0.5 ^ (dt / half)) or 1
if a > 1 then a = 1 end
local px, py, pz = self.anchorX or ox, self.anchorY or oy, self.anchorZ or oz
ox = px + (ox - px) * a
oy = py + (oy - py) * a
oz = pz + (oz - pz) * a
end
self.anchorX, self.anchorY, self.anchorZ = ox, oy, oz
if ox == 0 and oy == 0 and oz == 0 then return end
local pivot, drw = self.pivotM, self.drawM
for b = 1, n do
local o = (b - 1) * 12
+22 -8
View File
@@ -1419,15 +1419,29 @@ end)()
accX = {}, accY = {}, accZ = {}, parts = {} }, Rig)
rig:measureBind()
local function centre()
local n, xs, ys, zs = model.boneCount, {}, {}, {}
for b = 1, n do
local o = (b - 1) * 12
xs[b], ys[b], zs[b] = rig.drawM[o + 4], rig.drawM[o + 8], rig.drawM[o + 12]
-- the same quantity StadiumRig.anchor corrects -- bone origins averaged and
-- weighted by the vertices each bone moves -- written out here rather than
-- called, so this checks the behaviour and not its own arithmetic
local weight, total = {}, 0
for b = 1, model.boneCount do weight[b] = 0 end
for _, prim in ipairs(model.prims) do
for k = 1, prim.vertCount do
local b = prim.bone[k]
if weight[b] then weight[b] = weight[b] + 1; total = total + 1 end
end
table.sort(xs) table.sort(ys) table.sort(zs)
local h = math.floor(n / 2) + 1
return xs[h], ys[h], zs[h]
end
local function centre()
local x, y, z = 0, 0, 0
for b = 1, model.boneCount do
local q = weight[b]
if q > 0 then
local o = (b - 1) * 12
x = x + rig.drawM[o + 4] * q
y = y + rig.drawM[o + 8] * q
z = z + rig.drawM[o + 12] * q
end
end
return x / total, y / total, z / total
end
local raw = model.height / (model.rootScale > 0 and model.rootScale or 1)
local slot = model.ctx[Pack.SLOT.entrance]
+47 -16
View File
@@ -81,6 +81,10 @@ local UNTEXTURED = 0xFFFF + 1
-- than reported (see the texture check).
local untextured = {}
-- species the rig refuses to anchor because their own standby loop says the
-- body estimate cannot be trusted (StadiumRig.ANCHOR_STEADY)
local unanchored = {}
-- How far the mode lets an animation carry the Pokemon off its tile, in body
-- heights. Read from StadiumMon rather than repeated, so the sweep cannot go
-- on passing against a number the mode has since changed.
@@ -226,24 +230,40 @@ end
-- ------- one animation, frame by frame
-- Where the BODY of a posed rig is: the median bone origin on each axis.
-- Where the BODY of a posed rig is: the bone origins averaged, weighted by
-- how many vertices each bone moves.
--
-- The median rather than the mean or the root, for the reason StadiumRig's
-- own anchor uses it -- Farfetch'd's five-bone trail streaks three thousand
-- units out while the bird stays where it is, and a mean would follow the
-- trail. This is deliberately a second, independent implementation of the
-- same idea: a check that shared the code it is checking would agree with it
-- by construction.
-- The same QUANTITY StadiumRig.anchor corrects, written out a second time
-- rather than called: a check that shared the code it is checking would agree
-- with it by construction. It must be the same quantity, though -- an earlier
-- version of this measured the MEDIAN instead, and reported 27,965 findings
-- purely because it was asking a different question than the anchor answers.
local function bodyCentreOf(rig)
local n = rig.model.boneCount
local xs, ys, zs, d = {}, {}, {}, rig.drawM
for b = 1, n do
local o = (b - 1) * 12
xs[b], ys[b], zs[b] = d[o + 4], d[o + 8], d[o + 12]
local m = rig.model
local w, total = m.qaWeights, m.qaWeightTotal
if not w then
w, total = {}, 0
for b = 1, m.boneCount do w[b] = 0 end
for _, prim in ipairs(m.prims) do
for k = 1, prim.vertCount do
local b = prim.bone[k]
if w[b] then w[b] = w[b] + 1; total = total + 1 end
end
end
m.qaWeights, m.qaWeightTotal = w, total
end
table.sort(xs) table.sort(ys) table.sort(zs)
local h = math.floor(n / 2) + 1
return xs[h], ys[h], zs[h]
if not (total > 0) then return 0, 0, 0 end
local x, y, z, d = 0, 0, 0, rig.drawM
for b = 1, m.boneCount do
local q = w[b]
if q > 0 then
local o = (b - 1) * 12
x = x + d[o + 4] * q
y = y + d[o + 8] * q
z = z + d[o + 12] * q
end
end
return x / total, y / total, z / total
end
local function bboxOf(rig)
@@ -300,6 +320,7 @@ local function sweepSpecies(dex)
-- and where the bind pose puts the BODY, for the travel check below. The
-- tracks are in raw units, before the model_root scale model.height is
-- measured after.
if model.anchorOk == false then unanchored[dex] = true end
local bcx, bcy, bcz = bodyCentreOf(rig)
local rawHeight = (model.height or 0)
/ ((model.rootScale or 0) > 0 and model.rootScale or 1)
@@ -360,7 +381,10 @@ local function sweepSpecies(dex)
local cx, cy, cz = bodyCentreOf(rig)
local drift = (((cx - bcx) ^ 2 + (cy - bcy) ^ 2 + (cz - bcz) ^ 2) ^ 0.5)
/ rawHeight
if drift > TRAVEL * 1.05 then
-- a species the rig has judged unmeasurable is deliberately NOT
-- anchored (StadiumRig.measureBind), so of course it still travels --
-- reporting that would be reporting a decision as a defect
if model.anchorOk ~= false and drift > TRAVEL * 1.05 then
report("animation walks the Pokemon off its tile", dex, name, f,
("body centre %.1f body-heights from where it started")
:format(drift))
@@ -548,6 +572,13 @@ if nUntextured > 0 then
print(("species with flat-shaded (untextured) primitives: %d -- expected, "
.. "the source has no texture for those"):format(nUntextured))
end
local anchorList = {}
for dex in pairs(unanchored) do anchorList[#anchorList + 1] = dex end
table.sort(anchorList)
if #anchorList > 0 then
print(("not anchored (standby loop too unsteady to measure, so they travel "
.. "as authored): %s"):format(table.concat(anchorList, " ")))
end
if #staticPose > 0 then
print(("staticPose (declined by the packer, never drawn, not swept): %s")
:format(table.concat(staticPose, " ")))