DramaticShapeVoxelMod archival mirror · read-only
7ce268e5a200471491bdbc27e1282e91390e7a51

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, " ")))