DramaticShapeVoxelMod archival mirror · read-only
ecb0b57d26b6f0788d1d360e2cc632c14b8dfd47

fix some issues, fail gracefully

DramaticShape · 2026-08-04 · parent 1915654a50

fix some issues, fail gracefully

Changed files

CHANGELOG.md                  |  81 ++++++++++++++++++++++++++++++++++++++++++
 lib/Stadium.lua               |  76 ++++++++++++++++++++++++++++++++++------
 lib/StadiumMon.lua            |  16 +++++++++
 lib/StadiumPack.lua           |  27 ++++++++++++++
 lib/StadiumRig.lua            | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/dramatic_shape_test.lua |  92 ++++++++++++++++++++++++++++++++++++++++++++++++
 tests/stadium_anim_qa.lua     | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 7 files changed, 505 insertions(+), 22 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index be3cb0a..50decc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -169,6 +169,37 @@
 - `tests/stadium_shots.lua`, a shot driver for both rungs, with a control
   mode and a pinned clock so two runs of it can be compared.
 
+- **`tests/stadium_anim_qa.lua`: every Pokemon, every animation, every
+  frame.** A battle asks a species for one of its animations and then poses,
+  skins, re-textures and draws it sixty times a second, and nothing exercised
+  that chain -- the pack probe reads the format and walks a bind pose, the
+  shot drivers show one species in one animation at a time. So the failures
+  only some species have had no way of being found except by a player calling
+  that Pokemon out, which is exactly how the eviction crash above was found.
+
+  This is that sweep, headless: the real StadiumPack, StadiumRig and
+  StadiumMon over stubs for the three things a graphics context provides, and
+  the stubs are not lenient -- a released object throws with LOVE's own
+  message, because a stub that quietly accepted one would hide the class of
+  bug the sweep exists to find. 151 species, 403,716 posed frames, 25
+  seconds. It also drives the state machine over all 165 move slots and
+  reproduces the cache eviction directly, which no amount of playing one
+  species' animations can reach.
+
+  What it found, and what happened to each:
+
+  | finding | count | outcome |
+  | --- | --- | --- |
+  | pack cache evicted a model still on the field | 2 | **fixed** -- see above |
+  | animation walks the Pokemon off its tile | 65 entrances, 36 hits, 35 faints | **fixed** -- see above |
+  | part has no texture | 104,728 | **not a defect.** 39 primitives across 37 species, 1.6% of the set's vertices, every one with all-zero UVs: they are flat-shaded geometry in the original. The pack stores texture indices one-based, so the packer's `0xFFFF` "untextured" sentinel arrives as 65,536 and resolves to nothing, which is correct. Counted rather than reported now |
+  | pose flies apart | 250 | **understood, left alone.** Two species in one animation each: Farfetch'd's entrance at 6.0x its bind height and Dewgong's at 6.1x, both just over the threshold, and both because the measurement is a bounding box that includes an authored trail -- Farfetch'd's is a dedicated 30-vertex primitive on a five-bone chain. The bodies are intact and, since the anchor, in frame |
+  | NaN or infinite vertex, rig would not build, loopStart out of range, missing aux animation, track indexed off its end, move slot with no animation | 0 | none |
+
+  The three species the packer already declines (Exeggutor, Tangela, Magmar)
+  are skipped rather than swept: they are never posed in a game, and sweeping
+  them anyway produced 356 of the 362 original "flies apart" findings.
+
 ### Fixed
 
 - **Idle animations snapped, and then ran at half the frame rate.** Two
@@ -255,6 +286,56 @@
   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.
 
+- **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.
+
+  The pack cache holds four models and evicts the least recently *loaded* --
+  and `load` only runs when a side's species CHANGES, so a Pokemon that has
+  been standing there for a few turns is the oldest entry in the cache. Send
+  out a fifth species and it was evicted mid-fight and **its textures
+  released**, while it was still being drawn sixty times a second.
+
+  Two things were wrong. The eviction released each texture but left the dead
+  object in its slot -- and a released Image is still a truthy value, so the
+  next `image()` handed the corpse straight back out to `mesh:setTexture`.
+  Slots are now cleared, so an evicted model simply decodes its textures
+  again. And the mode now says, every frame, which two species are actually
+  standing there (`StadiumPack.keep`), so the two in use are always the two
+  most recent and cannot reach the front of the queue at all.
+
+- **And a model that does fail now fails gracefully.** Both draws were a bare
+  loop inside the caller's single pcall, so a throw on the first side skipped
+  the second -- one broken Pokemon took its opponent off the screen with it
+  -- and nothing recorded that it had happened, so the same throw came back
+  every frame forever. Each side is now drawn, cast and posed inside its own
+  guard, and a side that throws is RETIRED: its rig is released and
+  OverworldBattle renders its flat battle pic from the next frame on, which
+  is the fallback a species with no pack has always had. The fight carries on
+  with a flat Pokemon instead of a missing one, and its opponent is
+  untouched.
+
+- **Half the set's animations walked the Pokemon out of the shot.** Found by
+  the sweep below, not by a bug report, and it is the biggest of them: 65 of
+  the 148 send-out entrances carry the body more than its own height off the
+  spot it started on -- Dewgong's reaches seven and a half, and its faint
+  nearly ten. Every one returns to exactly where it began, because Pokemon
+  Stadium framed each Pokemon with a camera of its OWN that followed the
+  performance around a stage. This mode has one camera, solved to hold two
+  fixed map cells, so a Pokemon that travels seven body-heights is simply
+  gone: sending out a Farfetch'd left an empty tile for three and a half
+  seconds while its animation played somewhere off to the left of the frame.
+
+  `StadiumRig.anchor` now takes the excess back out -- the pose is measured
+  against where the bind pose put the body, and whatever has carried it
+  further than `StadiumMon.TRAVEL` (three quarters of a body-height, which is
+  what the frame holds) is subtracted from every bone. The EXCESS only: the
+  83 species that never reach the limit are bit-for-bit what they were, and a
+  lunge, a hop or a collapse still reads as big and still comes back to the
+  tile it left. The centre is the median bone origin rather than the mean or
+  the root, so Farfetch'd's five-bone trail streaking three thousand units
+  out cannot drag the bird with it.
+
 - **FLY and DIG now take the model off the field.** The charging turn of a
   two-turn move puts the Pokemon out of reach, and the engine says so through
   `picFx[battler].hidden` -- FLY runs `SE_SLIDE_MON_OFF` and DIG
diff --git a/lib/Stadium.lua b/lib/Stadium.lua
index b574231..a580bfb 100644
--- a/lib/Stadium.lua
+++ b/lib/Stadium.lua
@@ -115,6 +115,10 @@ end
 function Stadium.begin(arena)
   Stadium.finish()
   if not Stadium.enabled() then return false end
+  -- a new fight gets its own first complaint: `reported` is a one-shot so the
+  -- console is not filled sixty times a second, but latched for the whole
+  -- process it would swallow every failure after the first one ever
+  Stadium.reported = false
   session = {
     arena = arena,
     groundY = 0,
@@ -345,6 +349,13 @@ function Stadium.update(dt, battle, groundY)
     end
 
     mon:setSpecies(dex)
+    -- and tell the pack cache this one is standing there, every frame. Its
+    -- eviction order is keyed on LOADS, and a side only loads when its
+    -- species changes -- so without this a Pokemon that has been out for a
+    -- few turns is the least recently loaded thing in the cache and gets its
+    -- textures released out from under it the moment a fifth species enters
+    -- the battle (see StadiumPack.keep).
+    if mon.species then StadiumPack.keep(mon.species) end
     mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
                   and not (battler and battler.substituteHP)
     -- cleared up front, so a side that has just lost its rig cannot leave
@@ -361,10 +372,16 @@ function Stadium.update(dt, battle, groundY)
         local cell = arena[side]
         local other = arena[side == "player" and "enemy" or "player"]
         if cell and other then
-          mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
-                                        other[1] - cell[1],
-                                        other[2] - cell[2])
-          mon:build()
+          -- posed and skinned inside the same guard the draws use: this is
+          -- where a bad track or a released texture is first touched, and a
+          -- throw here would take the OTHER side's update with it (the
+          -- caller wraps this whole function in one pcall)
+          Stadium.guard(side, mon, "build", function()
+            mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
+                                          other[1] - cell[1],
+                                          other[2] - cell[2])
+            mon:build()
+          end)
         else
           mon.model_matrix = nil
         end
@@ -384,12 +401,46 @@ end
 -- flash are all already set. StadiumRig turns the wireframe and the glass
 -- mask off around its own draws and puts them back.
 
+-- ------- one model going wrong is not both
+--
+-- These two draws used to be a bare loop inside the caller's single pcall,
+-- which had two consequences and both were bad. A throw on the FIRST side
+-- skipped the second, so one broken Pokemon took its opponent off the screen
+-- with it. And nothing recorded that it had happened, so the same throw came
+-- back every frame for the rest of the fight -- the mode's own fallback (that
+-- side draws its flat pic instead) was sitting right there and never reached,
+-- because falling back needs somebody to decide the model is not working.
+--
+-- So each side is drawn inside its own pcall, and a side that throws is
+-- RETIRED: its rig is released, which is exactly the state a species with no
+-- pack is in, and OverworldBattle renders a billboard for it from the next
+-- frame on. The fight carries on with a flat Pokemon instead of a missing
+-- one, which is the difference the player actually sees.
+-- On the TABLE rather than a local, because Stadium.update calls it and sits
+-- above this line: a local would still be nil there.
+function Stadium.guard(side, mon, what, fn)
+  local ok, err = pcall(fn)
+  if ok then return true end
+  Stadium.report(err)
+  -- release rather than merely hide: the rig holds meshes and texture
+  -- references, and whatever went wrong with them is not going to be better
+  -- next frame. setSpecies rebuilds from scratch if this Pokemon is sent out
+  -- again later.
+  if mon.rig then pcall(mon.release, mon) end
+  mon.rig, mon.visible, mon.model_matrix = nil, false, nil
+  if session then session.broken = session.broken or {} end
+  if session then session.broken[side] = what end
+  return false
+end
+
 function Stadium.draw(pull)
   if not session then return end
   for _, side in ipairs({ "enemy", "player" }) do
     local mon = session[side]
     if mon.rig and mon.visible and mon.model_matrix then
-      mon.rig:draw(mon.model_matrix, pull)
+      Stadium.guard(side, mon, "draw", function()
+        mon.rig:draw(mon.model_matrix, pull)
+      end)
     end
   end
 end
@@ -402,7 +453,9 @@ function Stadium.cast(shadowMap)
   for _, side in ipairs({ "enemy", "player" }) do
     local mon = session[side]
     if mon.rig and mon.visible and mon.model_matrix then
-      mon.rig:caster(shadowMap, mon.model_matrix)
+      Stadium.guard(side, mon, "cast", function()
+        mon.rig:caster(shadowMap, mon.model_matrix)
+      end)
     end
   end
 end
@@ -561,13 +614,14 @@ end
 -- model is indistinguishable from an invisible one -- so the first failure
 -- of a battle says so, once, and the rest of the fight carries on without
 -- it.
-local reported = false
+Stadium.reported = false
 
 function Stadium.report(err)
-  if reported then return end
-  reported = true
-  V.mod.log:warn("stadium: a model failed to draw: %s -- this battle runs "
-                 .. "without it", tostring(err))
+  if Stadium.reported then return end
+  Stadium.reported = true
+  V.mod.log:warn("stadium: a model failed and was retired for this battle: "
+                 .. "%s -- that Pokemon falls back to its flat battle pic, "
+                 .. "and its opponent is unaffected", tostring(err))
 end
 
 -- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which
diff --git a/lib/StadiumMon.lua b/lib/StadiumMon.lua
index 8594364..09b8737 100644
--- a/lib/StadiumMon.lua
+++ b/lib/StadiumMon.lua
@@ -106,6 +106,18 @@ StadiumMon.HOVER_CAP = 0.5
 -- same rate.
 StadiumMon.FPS = StadiumPack.FPS
 
+-- How far an animation may carry the Pokemon off its tile, in the Pokemon's
+-- own body-heights, before the excess is taken back out (StadiumRig.anchor).
+--
+-- Measured against the frame rather than chosen by eye. A mon is drawn
+-- REF_HEIGHT world pixels tall and the GB frame holds about 38 world pixels
+-- at the far cell, with the foe's feet on row 56 of 144 -- so there is
+-- roughly one body-height of room above it and about one and a half either
+-- side. Three quarters of a height keeps every part of a travelling Pokemon
+-- inside that with a margin, and leaves the 83 species that never reach it
+-- untouched.
+StadiumMon.TRAVEL = 0.75
+
 -- ------- the animation the fight is asking for
 --
 -- Each entry says which context slot to look up, whether it loops, and
@@ -378,6 +390,10 @@ function StadiumMon:build()
   -- self.anim is nil while a species has nothing to play, and pose() reads
   -- that as "the bind pose", which is exactly what is wanted
   self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop)
+  -- 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: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/StadiumPack.lua b/lib/StadiumPack.lua
index 3285fa9..41ebe4b 100644
--- a/lib/StadiumPack.lua
+++ b/lib/StadiumPack.lua
@@ -477,11 +477,38 @@ local function touch(species)
         if slot.image and slot.image.release then
           pcall(slot.image.release, slot.image)
         end
+        -- CLEARED, not just released. A released Image is still a truthy
+        -- value, and `image()` below hands back whatever is in this field
+        -- without looking at it -- so leaving the corpse here meant the next
+        -- ask returned a dead object, which reached mesh:setTexture and threw
+        -- "Cannot use object after it has been released" from inside the
+        -- scene pass. Nil means the next ask decodes it again, which is the
+        -- whole point of the slot being lazy.
+        slot.image = nil
       end
     end
   end
 end
 
+-- Say that this species is IN USE, so the cache does not evict it.
+--
+-- The eviction order above is a least-recently-LOADED list, not a
+-- least-recently-used one: `touch` runs from `load`, and `load` is only
+-- reached when a side's species CHANGES (StadiumMon.setSpecies returns early
+-- otherwise). A Pokemon that stands on the field for several turns therefore
+-- never refreshes its position, drifts to the front of the queue, and is
+-- evicted -- its textures released -- while it is still being drawn sixty
+-- times a second. That is what a fifth species entering a battle did: call
+-- out a Clefairy and whatever had been standing longest lost its textures
+-- mid-fight.
+--
+-- So the mode says, every frame, which two species are actually standing
+-- there (see Stadium.update). With KEEP at 4 and two sides, the two in use
+-- are always the two most recent and cannot reach the front of the queue.
+function StadiumPack.keep(species)
+  if species and cache[species] then touch(species) end
+end
+
 -- Whether a pack for this species is on disk at all. Cheap enough to ask
 -- before a battle commits to the mode, and the honest test: a mod
 -- installed without its assets folder must decline rather than error.
diff --git a/lib/StadiumRig.lua b/lib/StadiumRig.lua
index 57f5f48..31a1922 100644
--- a/lib/StadiumRig.lua
+++ b/lib/StadiumRig.lua
@@ -110,6 +110,9 @@ function StadiumRig.new(model)
     -- what the pose walk last answered, so a frame that neither moved the
     -- animation nor turned the model can skip the whole thing
     poseKey = nil,
+    -- scratch for the body-centre estimate (see anchor), kept on the rig so
+    -- a per-frame measurement allocates nothing
+    cx = {}, cy = {}, cz = {},
   }, StadiumRig)
 
   -- One mesh per primitive: a primitive is already "the triangles sharing
@@ -131,6 +134,9 @@ function StadiumRig.new(model)
     pcall(mesh.setVertexMap, mesh, prim.index)
     self.parts[i] = { mesh = mesh, rows = rows, prim = prim }
   end
+  -- the spot the animations are measured against, taken while there is no
+  -- pose to overwrite (see measureBind)
+  pcall(self.measureBind, self)
   return self
 end
 
@@ -402,6 +408,118 @@ function StadiumRig:pose(anim, frame, wrap)
   end
 end
 
+-- ------- keeping the Pokemon on its own tile
+--
+-- Stadium's animations MOVE the Pokemon, and they move it a long way. Half
+-- the set's send-out entrances walk the body more than its own height off
+-- the spot it started on; Dewgong's faint travels nearly ten body-heights,
+-- and its entrance seven and a half. Every one of them ends exactly where it
+-- began, because that game framed each Pokemon with a camera of its OWN that
+-- followed the performance around a stage.
+--
+-- This mode has one camera, solved to put two named map cells at two fixed
+-- points in a 160x144 frame (BattleCam), and a Pokemon that travels seven
+-- body-heights out of that frame is simply GONE -- which is what sending out
+-- a Farfetch'd looked like: an empty tile for three and a half seconds,
+-- while its animation played somewhere off to the left of the shot.
+--
+-- So the bulk travel is taken back out. The pose is measured, and whatever
+-- has carried the body further than `limit` from where the bind pose put it
+-- is subtracted from every bone.
+--
+-- ------- why a LIMIT and not an anchor
+--
+-- Pinning the body outright would flatten the animations into mime: a lunge,
+-- a hop, a recoil and a collapse are all the body moving, and they are the
+-- part worth having. What breaks the shot is not motion, it is EXCURSION --
+-- and the two are told apart by how far. Inside the limit nothing is touched
+-- at all, so the 83 species whose animations stay put are bit-for-bit what
+-- 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.
+
+-- 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 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]
+  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]
+end
+
+-- Where the BIND pose puts it -- the spot every animation is measured
+-- against. Cached on the shared MODEL, because it is a fact about the model
+-- and not about this instance of it.
+--
+-- Called once, from new(), and deliberately not lazily from anchor(): taking
+-- this measurement means POSING the bind pose, which would overwrite the
+-- animated pose anchor() was called to correct. Doing it while the rig is
+-- still being built is the one moment there is no pose to lose.
+function StadiumRig:measureBind()
+  local model = self.model
+  if model.bindCX then return end
+  self:pose(nil, 0, false)
+  model.bindCX, model.bindCY, model.bindCZ = centre(self, model.boneCount)
+end
+
+-- 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)
+  if not (limit and limit > 0) then return end
+  local model = self.model
+  local n = model.boneCount
+  -- the vertices are in RAW units, before the model_root scale that
+  -- model.height is measured after
+  local root = model.rootScale
+  if not (root and root > 0) then root = 1 end
+  local h = (model.height or 0) / root
+  if not (h > 0) then return end
+
+  local bx, by, bz = model.bindCX, model.bindCY, model.bindCZ
+  if not bx then return end          -- never measured; leave the pose alone
+  local x, y, z = centre(self, n)
+  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
+  local pivot, drw = self.pivotM, self.drawM
+  for b = 1, n do
+    local o = (b - 1) * 12
+    pivot[o + 4] = pivot[o + 4] - ox
+    pivot[o + 8] = pivot[o + 8] - oy
+    pivot[o + 12] = pivot[o + 12] - oz
+    drw[o + 4] = drw[o + 4] - ox
+    drw[o + 8] = drw[o + 8] - oy
+    drw[o + 12] = drw[o + 12] - oz
+  end
+end
+
 -- ------- the skin
 --
 -- Every vertex through its one bone's draw matrix, and its normal through
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 54cd770..f158486 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -1361,6 +1361,98 @@ end)()
     "and a whole frame is that frame exactly, with nothing blended into it")
 end)()
 
+-- ------- the pack cache must not evict a Pokemon that is standing there
+--
+-- The eviction order is keyed on LOADS, and a side only loads when its
+-- species changes -- so a Pokemon that has been out for a few turns is the
+-- least recently loaded thing in the cache. A fifth species entering the
+-- battle evicted it and RELEASED ITS TEXTURES mid-fight, and the next draw
+-- threw "Cannot use object after it has been released" from inside the scene
+-- pass, which took both models off the screen for the rest of the battle.
+;(function()
+  if not HAVE_STADIUM_PACKS then return end
+  local Pack = run.loader.exports.DRAMATIC_SHAPE.lib.require("StadiumPack")
+  local keep = Pack.KEEP
+  Pack.forget()
+  Pack.KEEP = 2
+
+  local held = Pack.load(1)
+  T.check(held ~= nil, "a model loads")
+  local slot = held.textures and held.textures[1]
+  T.check(slot ~= nil, "and carries at least one texture slot")
+
+  -- more species than the cache holds, WITHOUT saying the first is in use
+  Pack.load(4) Pack.load(7) Pack.load(10)
+  T.eq(slot.image, nil,
+    "an evicted model's texture slot is CLEARED, not left holding a released "
+    .. "object -- a released Image is still truthy, so the corpse came back "
+    .. "out of image() and died at mesh:setTexture")
+
+  -- and with `keep` said every frame, as the mode does, it is never evicted
+  Pack.forget()
+  local live = Pack.load(1)
+  for _, dex in ipairs({ 4, 7, 10, 13 }) do
+    Pack.keep(1)
+    Pack.load(dex)
+  end
+  T.eq(Pack.load(1), live,
+    "a species the mode keeps saying is on the field is still the same "
+    .. "cached model after four others have loaded past it")
+
+  Pack.KEEP = keep
+  Pack.forget()
+end)()
+
+-- ------- and an animation must not walk the Pokemon out of the shot
+--
+-- Stadium's animations were authored for a camera that followed the Pokemon;
+-- this one holds two fixed cells. 65 of the 148 send-out entrances travel
+-- more than a body-height off the spot, up to seven and a half -- which is
+-- not drama here, it is an empty tile.
+;(function()
+  if not HAVE_STADIUM_PACKS then return end
+  local lib = run.loader.exports.DRAMATIC_SHAPE.lib
+  local Pack, Rig, Mon = lib.require("StadiumPack"), lib.require("StadiumRig"),
+                         lib.require("StadiumMon")
+  local model = Pack.load(87)                    -- Dewgong, the worst of them
+  local rig = setmetatable({ model = model, pivotM = {}, drawM = {},
+                             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]
+    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 raw = model.height / (model.rootScale > 0 and model.rootScale or 1)
+  local slot = model.ctx[Pack.SLOT.entrance]
+  local anim = slot + 1
+
+  local function driftAt(frame, limit)
+    rig:pose(anim, frame, false)
+    rig:anchor(limit)
+    local x, y, z = centre()
+    return (((x - model.bindCX) ^ 2 + (y - model.bindCY) ^ 2
+             + (z - model.bindCZ) ^ 2) ^ 0.5) / raw
+  end
+
+  T.check(driftAt(40, nil) > 5,
+    "unanchored, Dewgong's entrance carries it more than five body-heights "
+    .. "off its tile -- straight out of a frame that holds about one")
+  T.check(driftAt(40, Mon.TRAVEL) <= Mon.TRAVEL * 1.001,
+    "anchored, it stays inside the travel limit")
+  -- and the animations that never travel are left completely alone
+  local before = driftAt(0, nil)
+  T.eq(driftAt(0, Mon.TRAVEL), before,
+    "a frame already inside the limit is not moved at all -- the anchor takes "
+    .. "out the EXCESS, so a lunge is still a lunge")
+end)()
+
 -- ------- the three species the extraction cannot read stand as PICS
 --
 -- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that
diff --git a/tests/stadium_anim_qa.lua b/tests/stadium_anim_qa.lua
index 659191d..569ae19 100644
--- a/tests/stadium_anim_qa.lua
+++ b/tests/stadium_anim_qa.lua
@@ -73,6 +73,19 @@ local STEP = tonumber(args.step or "0.5")
 -- hundreds of units off the body, which comes out in the dozens.
 local EXPLODE = 6.0
 
+-- The texture index of a primitive the source says has no texture. The pack
+-- stores indices one-based, so the packer's 0xFFFF sentinel arrives as this.
+local UNTEXTURED = 0xFFFF + 1
+
+-- species -> true, for the ones that carry such a primitive. Counted rather
+-- than reported (see the texture check).
+local untextured = {}
+
+-- 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.
+local TRAVEL = nil
+
 -- ------- the harness
 
 package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -195,6 +208,7 @@ end }
 local Pack = V.require("StadiumPack")
 local Rig = V.require("StadiumRig")
 local Mon = V.require("StadiumMon")
+TRAVEL = Mon.TRAVEL
 
 -- ------- findings
 
@@ -212,6 +226,26 @@ end
 
 -- ------- one animation, frame by frame
 
+-- Where the BODY of a posed rig is: the median bone origin on each axis.
+--
+-- 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.
+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]
+  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 bboxOf(rig)
   local lo, hi = math.huge, -math.huge
   local bad = false
@@ -258,10 +292,18 @@ local function sweepSpecies(dex)
   end
 
   -- the bind pose, as the yardstick every posed frame is measured against
+  rig:measureBind()
   rig:pose(nil, 0, false)
   rig:skin(0)
   local bind = bboxOf(rig)
   if not (bind > 0) then bind = 1 end
+  -- 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.
+  local bcx, bcy, bcz = bodyCentreOf(rig)
+  local rawHeight = (model.height or 0)
+                    / ((model.rootScale or 0) > 0 and model.rootScale or 1)
+  if not (rawHeight > 0) then rawHeight = 1 end
 
   local steps = 0
   for index, anim in ipairs(model.anims) do
@@ -302,6 +344,28 @@ local function sweepSpecies(dex)
           report("threw while playing", dex, name, f, tostring(err))
           break
         end
+        -- ------- does it stay on its tile?
+        --
+        -- Stadium's animations were authored for a camera that FOLLOWED the
+        -- Pokemon around a stage; this mode's camera is solved to hold two
+        -- fixed map cells and does not move. So an animation that walks the
+        -- body several of its own heights away does not look dramatic here,
+        -- it looks like the Pokemon is missing -- which is what sending out
+        -- a Farfetch'd did for three and a half seconds.
+        --
+        -- StadiumRig.anchor takes the excess back out, and this is the check
+        -- that it did: measured AFTER the anchor, the same way the mode
+        -- draws it, so what is reported is what a player would actually see.
+        rig:anchor(TRAVEL)
+        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
+          report("animation walks the Pokemon off its tile", dex, name, f,
+                 ("body centre %.1f body-heights from where it started")
+                 :format(drift))
+        end
+
         local h, bad = bboxOf(rig)
         if bad then
           report("posed vertex is NaN or infinite", dex, name, f, "")
@@ -310,13 +374,29 @@ local function sweepSpecies(dex)
                  ("%.0f units tall against a %.0f-unit bind pose (%.1fx)")
                  :format(h, bind, h / bind))
         end
-        -- every piece of the Pokemon has to have a texture to be drawn with;
-        -- one that resolves to nothing is a limb that is simply not there
+        -- Every piece of the Pokemon has to have a texture to be drawn with,
+        -- and one that resolves to nothing is a limb that is simply not
+        -- there -- EXCEPT where the source says it has none.
+        --
+        -- StadiumPack stores the texture index one-based (`u16 + 1`), so the
+        -- packer's 0xFFFF "this primitive is untextured" sentinel arrives
+        -- here as 65536. That is 39 primitives across 37 species, 1.6% of the
+        -- set's vertices, and every one of them has all-zero UVs -- they are
+        -- flat-shaded geometry in the original, not a texture that went
+        -- missing. Reported as a finding they were 104,728 lines of noise
+        -- (one per prim per sampled frame) burying two real bugs.
+        --
+        -- Counted rather than dropped: "how much of this set is untextured"
+        -- is worth knowing, and a number that suddenly moves is worth seeing.
         for i, part in ipairs(rig.parts) do
           if not part.texture then
-            report("part has no texture", dex, name, f,
-                   ("prim %d wants texture %s of %d")
-                   :format(i, tostring(part.prim.tex), model.texCount or 0))
+            if part.prim.tex == UNTEXTURED then
+              untextured[dex] = true
+            else
+              report("part has no texture", dex, name, f,
+                     ("prim %d wants texture %s of %d")
+                     :format(i, tostring(part.prim.tex), model.texCount or 0))
+            end
           end
         end
         f = f + STEP
@@ -395,12 +475,21 @@ local started = os.clock()
 local steps = 0
 local staticPose = {}
 for _, dex in ipairs(list) do
-  local ok, err = pcall(function() steps = steps + sweepSpecies(dex) end)
-  if not ok then
-    report("the sweep itself threw", dex, nil, nil, tostring(err))
-  end
   local m = Pack.load(dex)
-  if m and m.staticPose then staticPose[#staticPose + 1] = dex end
+  -- SKIPPED, not swept. The packer measures each species' standby loop
+  -- against its own bind pose and flags the ones whose animation data is
+  -- corrupt at source, and StadiumMon declines those outright -- they stand
+  -- as flat battle pics and no frame of them is ever posed in a game. Sweeping
+  -- them anyway produced 356 of the 362 "pose flies apart" findings, which is
+  -- a report that is mostly about Pokemon this mode does not draw.
+  if m and m.staticPose then
+    staticPose[#staticPose + 1] = dex
+  else
+    local ok, err = pcall(function() steps = steps + sweepSpecies(dex) end)
+    if not ok then
+      report("the sweep itself threw", dex, nil, nil, tostring(err))
+    end
+  end
   if not args.quiet and dex % 10 == 0 then
     io.write(("  ... %d/%d  %.0f MB\n")
              :format(dex, #list, collectgarbage("count") / 1024))
@@ -453,8 +542,14 @@ print("")
 print(("stadium animation QA: %d species, %d posed frames, %.1fs")
       :format(#list, steps, elapsed))
 print(("packs: %s"):format(packDir))
+local nUntextured = 0
+for _ in pairs(untextured) do nUntextured = nUntextured + 1 end
+if nUntextured > 0 then
+  print(("species with flat-shaded (untextured) primitives: %d -- expected, "
+         .. "the source has no texture for those"):format(nUntextured))
+end
 if #staticPose > 0 then
-  print(("staticPose (declined by the packer, never drawn): %s")
+  print(("staticPose (declined by the packer, never drawn, not swept): %s")
         :format(table.concat(staticPose, " ")))
 end
 print("")