fix arrival animation, fix pidgey regression
DramaticShape · 2026-08-04 ·
parent ecb0b57d26
fix arrival animation, fix pidgey regression
Changed files
CHANGELOG.md | 51 +++++++++++++-
lib/Stadium.lua | 23 +++++++
lib/StadiumMon.lua | 6 +-
lib/StadiumRig.lua | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
tests/dramatic_shape_test.lua | 28 ++++++--
tests/stadium_anim_qa.lua | 63 ++++++++++++-----
6 files changed, 346 insertions(+), 54 deletions(-)
Diff
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
---
--- 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.
+-- ------- where the body IS, and why it is not the median
+--
+-- 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()
+ -- 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
+ end
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]
+ 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
- table.sort(xs) table.sort(ys) table.sort(zs)
- local h = math.floor(n / 2) + 1
- return xs[h], ys[h], zs[h]
+ 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, " ")))