fix some issues, fail gracefully

This commit is contained in:
DramaticShape
2026-08-04 12:55:05 -04:00
parent 1915654a50
commit ecb0b57d26
7 changed files with 505 additions and 22 deletions
+81
View File
@@ -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
+65 -11
View File
@@ -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
+16
View File
@@ -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
+27
View File
@@ -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.
+118
View File
@@ -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
+92
View File
@@ -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
+106 -11
View File
@@ -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("")