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