From 7ce268e5a200471491bdbc27e1282e91390e7a51 Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Tue, 4 Aug 2026 13:22:41 -0400 Subject: [PATCH] fix arrival animation, fix pidgey regression --- CHANGELOG.md | 51 +++++++- lib/Stadium.lua | 23 ++++ lib/StadiumMon.lua | 6 +- lib/StadiumRig.lua | 227 +++++++++++++++++++++++++++++----- tests/dramatic_shape_test.lua | 30 +++-- tests/stadium_anim_qa.lua | 63 +++++++--- 6 files changed, 346 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50decc4..d5ec494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/Stadium.lua b/lib/Stadium.lua index a580bfb..64a80d9 100644 --- a/lib/Stadium.lua +++ b/lib/Stadium.lua @@ -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 diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua index 09b8737..a4fb1e4 100644 --- a/lib/StadiumMon.lua +++ b/lib/StadiumMon.lua @@ -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 diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua index 31a1922..bfc79f6 100644 --- a/lib/StadiumRig.lua +++ b/lib/StadiumRig.lua @@ -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 diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index f158486..4be35b8 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -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] diff --git a/tests/stadium_anim_qa.lua b/tests/stadium_anim_qa.lua index 569ae19..afc2fcd 100644 --- a/tests/stadium_anim_qa.lua +++ b/tests/stadium_anim_qa.lua @@ -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, " ")))