DramaticShapeVoxelMod archival mirror · read-only
tests/stadium_anim_qa.lua

stadium_anim_qa.lua

tests/stadium_anim_qa.lua22.4 KB1006443a4aac213f
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622
-- STADIUM battles: every Pokemon, every animation, every frame.
--
--   luajit mods/DramaticShapeVoxelMod/tests/stadium_anim_qa.lua \
--     [--packs=DIR] [--dex=N[,N...]] [--step=0.5] [--quiet]
--
-- Run from the PROJECT ROOT.
--
-- ------- what this is for
--
-- A battle asks a species for one of its animations and then poses, skins,
-- re-textures and draws it sixty times a second. Nothing in that chain is
-- exercised by the pack probe, which reads the format and walks a bind pose,
-- and nothing in it is exercised by the shot drivers, which show one species
-- in one animation at a time. So the failures that only some species have --
-- a texture index nothing maps, an animation that throws a bone into orbit,
-- a track that indexes off its own end -- have had no way of being found
-- except by a player calling that Pokemon out.
--
-- This is that sweep, headless: the REAL StadiumPack, the REAL StadiumRig
-- and the REAL StadiumMon over stubs for the three things a graphics context
-- provides (a mesh, an image, the draw call). Every species, every animation
-- it carries, every frame of it, plus the battle state machine over every
-- one of the 165 move slots.
--
-- ------- the stubs are not lenient
--
-- The fake mesh and the fake image behave like LOVE's do in the one way that
-- matters: an object that has been released THROWS when it is used, with
-- LOVE's own message. That is deliberate -- a released texture reaching a
-- draw call is a real failure mode of this mode (see the LRU note in
-- StadiumPack), and a stub that quietly accepted one would hide exactly the
-- class of bug this sweep exists to find.

local args = {}
local only = nil
for _, a in ipairs({ ... }) do
  local k, v = a:match("^%-%-([%w_]+)=(.*)$")
  if k then args[k] = v elseif a == "--quiet" then args.quiet = "1" end
end
if args.dex then
  only = {}
  for n in args.dex:gmatch("%d+") do only[#only + 1] = tonumber(n) end
end

local MOD = "mods/DramaticShapeVoxelMod"

-- Where the .dsm packs are. The mod builds them into LOVE's save directory
-- on first run (StadiumInstall), which is where a developer machine actually
-- has them; a checkout that has run tools/stadium_pack.py has them in the
-- mod instead. Both are tried, and --packs overrides.
local PACK_DIRS = {}
local function lookIn(dir)
  if dir and dir ~= "" then PACK_DIRS[#PACK_DIRS + 1] = dir end
end
lookIn(args.packs)
lookIn(os.getenv("APPDATA")
       and os.getenv("APPDATA") .. "/LOVE/pokemon-love2d/dramatic_shape/stadium")
lookIn(os.getenv("HOME")
       and os.getenv("HOME")
           .. "/.local/share/love/pokemon-love2d/dramatic_shape/stadium")
lookIn(MOD .. "/assets/stadium")

-- How far apart the sampled frames are, in the animation's own 30 Hz frames.
-- A half frame rather than a whole one because the rig INTERPOLATES between
-- entries, and the blend has guards of its own that only run when k > 0 --
-- sampling on whole frames alone would never execute them.
local STEP = tonumber(args.step or "0.5")

-- How much taller than its own bind pose a posed model may stand before it
-- is called broken. Generous on purpose: a Pokemon that rears up, a wing
-- that opens, a Gyarados that lunges are all genuinely several times their
-- standing height. What this catches is the other thing -- a bone thrown
-- 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 = {}

-- species the rig refuses to anchor because their own standby loop says the
-- body estimate cannot be trusted (StadiumRig.ANCHOR_STEADY)
local unanchored = {}

-- How far the mode lets an animation carry the Pokemon off its tile, in body
-- heights. Read from StadiumMon rather than repeated, so the sweep cannot go
-- on passing against a number the mode has since changed.
local TRAVEL = nil

-- ------- the harness

package.path = "./?.lua;./?/init.lua;" .. package.path

local packDir = nil
for _, dir in ipairs(PACK_DIRS) do
  if dir and dir ~= "" then
    local fp = io.open(dir .. "/001.dsm", "rb")
    if fp then fp:close() packDir = dir break end
  end
end
if not packDir then
  print("no .dsm packs found -- pass --packs=DIR")
  print("looked in:")
  for _, dir in ipairs(PACK_DIRS) do
    if dir and dir ~= "" then print("  " .. dir) end
  end
  os.exit(1)
end

local function readFile(path)
  local fp = io.open(path, "rb")
  if not fp then return nil end
  local bytes = fp:read("*a")
  fp:close()
  return bytes
end

-- ------- the three things a graphics context provides
--
-- A released object throws, exactly as LOVE's does. See the header.

local RELEASED = "Cannot use object after it has been released."

local function checkLive(obj)
  if obj and obj.released then error(RELEASED, 0) end
end

local function newFakeImage(w, h)
  local img = { w = w, h = h, released = false }
  function img:setFilter() checkLive(self) end
  function img:setWrap() checkLive(self) end
  function img:release() self.released = true return true end
  function img:type() return "Image" end
  return img
end

local function newFakeMesh(format, rows)
  local mesh = { released = false, verts = rows }
  function mesh:setVertexMap() checkLive(self) end
  function mesh:setVertices(v) checkLive(self) self.verts = v end
  function mesh:setTexture(t) checkLive(self) checkLive(t) self.tex = t end
  function mesh:release() self.released = true return true end
  function mesh:type() return "Mesh" end
  return mesh
end

love = {
  filesystem = {
    getInfo = function(rel)
      -- the mod asks for "dramatic_shape/stadium/NNN.dsm" relative to the
      -- save directory; the sweep serves that out of packDir
      local name = rel:match("([^/]+)$")
      local fp = io.open(packDir .. "/" .. name, "rb")
      if not fp then return nil end
      fp:close()
      return { type = "file" }
    end,
    read = function(rel)
      local name = rel:match("([^/]+)$")
      return readFile(packDir .. "/" .. name)
    end,
  },
  graphics = {
    newMesh = function(format, rows, mode, usage) return newFakeMesh(format, rows) end,
    newImage = function(data) return newFakeImage(data.w, data.h) end,
  },
  image = {
    newImageData = function(w, h, fmt, bytes) return { w = w, h = h } end,
  },
}

local V = {}
local modules = {}
V.mod = {
  log = {
    warn = function(_, fmt, ...) print("[warn] " .. fmt:format(...)) end,
    info = function(_, fmt, ...) print("[info] " .. fmt:format(...)) end,
  },
  read = function(_, rel) return readFile(MOD .. "/" .. rel) end,
}
function V.require(name)
  if modules[name] then return modules[name] end
  local chunk = assert(loadfile(MOD .. "/lib/" .. name .. ".lua"))
  modules[name] = chunk(V)
  return modules[name]
end

-- Voxel3D, to the extent the rig touches it. `draw` mirrors the real one's
-- first act -- binding the texture to the mesh -- because that is the line a
-- released texture dies on (Voxel3D.draw), and the whole point of the stub
-- is that it dies there here too.
local drawn, skipped = 0, 0
modules.Voxel3D = {
  FORMAT = {},
  seams = function() end,
  glass = function() end,
  blend = function() end,
  draw = function(mesh, texture, model, pull, sunModel)
    if not mesh then return end
    if texture then mesh:setTexture(texture) end
    drawn = drawn + 1
  end,
}
-- the shadow pass, same shape
local shadowMap = { draw = function(mesh, texture, model)
  if texture then mesh:setTexture(texture) end
end }

local Pack = V.require("StadiumPack")
local Rig = V.require("StadiumRig")
local Mon = V.require("StadiumMon")
TRAVEL = Mon.TRAVEL

-- ------- findings

local findings = {}      -- kind -> { {dex=, anim=, frame=, detail=} ... }
local kinds = {}         -- kind order, first seen first

local function report(kind, dex, anim, frame, detail)
  if not findings[kind] then
    findings[kind] = {}
    kinds[#kinds + 1] = kind
  end
  local list = findings[kind]
  list[#list + 1] = { dex = dex, anim = anim, frame = frame, detail = detail }
end

-- ------- one animation, frame by frame

-- Where the BODY of a posed rig is: the bone origins averaged, weighted by
-- how many vertices each bone moves.
--
-- The same QUANTITY StadiumRig.anchor corrects, written out a second time
-- rather than called: a check that shared the code it is checking would agree
-- with it by construction. It must be the same quantity, though -- an earlier
-- version of this measured the MEDIAN instead, and reported 27,965 findings
-- purely because it was asking a different question than the anchor answers.
local function bodyCentreOf(rig)
  local m = rig.model
  local w, total = m.qaWeights, m.qaWeightTotal
  if not w then
    w, total = {}, 0
    for b = 1, m.boneCount do w[b] = 0 end
    for _, prim in ipairs(m.prims) do
      for k = 1, prim.vertCount do
        local b = prim.bone[k]
        if w[b] then w[b] = w[b] + 1; total = total + 1 end
      end
    end
    m.qaWeights, m.qaWeightTotal = w, total
  end
  if not (total > 0) then return 0, 0, 0 end
  local x, y, z, d = 0, 0, 0, rig.drawM
  for b = 1, m.boneCount do
    local q = w[b]
    if q > 0 then
      local o = (b - 1) * 12
      x = x + d[o + 4] * q
      y = y + d[o + 8] * q
      z = z + d[o + 12] * q
    end
  end
  return x / total, y / total, z / total
end

local function bboxOf(rig)
  local lo, hi = math.huge, -math.huge
  local bad = false
  for _, part in ipairs(rig.parts) do
    local rows = part.rows
    for k = 1, part.prim.vertCount do
      local row = rows[k]
      local x, y, z = row[1], row[2], row[3]
      -- NaN is the only value not equal to itself; infinity survives that
      -- test and has to be asked about separately
      if x ~= x or y ~= y or z ~= z
         or x == math.huge or x == -math.huge
         or y == math.huge or y == -math.huge
         or z == math.huge or z == -math.huge then
        bad = true
      else
        if y < lo then lo = y end
        if y > hi then hi = y end
      end
    end
  end
  if lo > hi then return 0, bad end
  return hi - lo, bad
end

-- A species' worth of work. Returns the number of frames stepped.
local function sweepSpecies(dex)
  local model = Pack.load(dex)
  if not model then
    -- say WHY, or a sweep that ran out of file handles reads as 108 broken
    -- Pokemon
    local _, err = io.open(("%s/%03d.dsm"):format(packDir, dex), "rb")
    report("pack did not load", dex, nil, nil,
           ("StadiumPack.load returned nil (io.open says: %s)")
           :format(tostring(err)))
    return 0
  end

  local rig = Rig.new(model)
  if not rig then
    report("rig would not build", dex, nil, nil,
           ("%d prims, %d bones"):format(model.primCount, model.boneCount))
    return 0
  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.
  if model.anchorOk == false then unanchored[dex] = true end
  local bcx, bcy, bcz = bodyCentreOf(rig)
  local rawHeight = (model.height or 0)
                    / ((model.rootScale or 0) > 0 and model.rootScale or 1)
  if not (rawHeight > 0) then rawHeight = 1 end

  local steps = 0
  for index, anim in ipairs(model.anims) do
    local name = anim.name or ("#" .. index)
    local frames = anim.frames or 1
    if frames < 1 then
      report("animation has no frames", dex, name, nil,
             ("frames=%d"):format(frames))
    end
    -- the loop start has to be inside the animation or the wrap arithmetic
    -- lands outside the track arrays
    local loop = anim.loopStart or 0
    if loop < 0 or loop >= frames then
      report("loopStart out of range", dex, name, nil,
             ("loopStart=%d of %d frames"):format(loop, frames))
    end
    if anim.aux and not (model.auxAnims and model.auxAnims[anim.aux]) then
      report("aux animation missing", dex, name, nil,
             ("anim.aux=%s, %d aux animations")
             :format(tostring(anim.aux), model.auxCount or 0))
    end

    -- Both ways round: a standby loop WRAPS at the far end and a faint HOLDS
    -- on its last frame, and the two take different branches of the pose
    -- walk. Sampled a little past the end on purpose -- that is where a
    -- battle actually leaves them.
    for _, wrap in ipairs({ true, false }) do
      local f = 0
      while f < frames + 2 do
        local ok, err = pcall(function()
          rig:pose(index, f, wrap)
          rig:skin(0.5)
          rig:textures(anim.aux)
          rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0)
        end)
        steps = steps + 1
        if not ok then
          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
        -- a species the rig has judged unmeasurable is deliberately NOT
        -- anchored (StadiumRig.measureBind), so of course it still travels --
        -- reporting that would be reporting a decision as a defect
        if model.anchorOk ~= false and drift > TRAVEL * 1.05 then
          report("animation walks the Pokemon off its tile", dex, name, f,
                 ("body centre %.1f body-heights from where it started")
                 :format(drift))
        end

        local h, bad = bboxOf(rig)
        if bad then
          report("posed vertex is NaN or infinite", dex, name, f, "")
        elseif h > bind * EXPLODE then
          report("pose flies apart", dex, name, f,
                 ("%.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,
        -- 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
            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
      end
    end
  end

  -- ------- and the same species as a BATTLE drives it
  --
  -- The sweep above plays the animations; this plays the STATE MACHINE, which
  -- is what a fight actually touches: the context slots behind idle, the
  -- entrance, the two reactions and the collapse, and then all 165 move slots
  -- -- because a move's animation is looked up by move id in a table the
  -- species carries, and an index in that table that points nowhere is a
  -- crash on the frame that move is used.
  local mon = Mon.new("player")
  mon.rig, mon.model, mon.species = rig, model, dex
  mon.state, mon.anim, mon.time = nil, nil, 0

  for _, state in ipairs({ "idle", "entrance", "attack", "faint" }) do
    local ok, err = pcall(function()
      mon:play(state)
      -- a whole second of it at 60 Hz, which is what the fight does
      for _ = 1, 60 do
        mon:update(1 / 60)
        mon:build()
      end
    end)
    if not ok then
      report("threw while playing", dex, state, nil, tostring(err))
    end
  end

  for moveIndex = 1, Pack.N_MOVES do
    local slot = model.moveAnim[moveIndex]
    if slot and slot ~= Pack.NONE then
      if not model.anims[slot + 1] then
        report("move points at an animation that is not there", dex,
               ("move %d"):format(moveIndex), nil,
               ("anim index %d of %d"):format(slot + 1, model.animCount or 0))
      else
        local aux = model.moveAux[moveIndex]
        if aux and aux >= 0 and not (model.auxAnims and model.auxAnims[aux + 1]) then
          report("move points at an aux that is not there", dex,
                 ("move %d"):format(moveIndex), nil,
                 ("aux index %d of %d"):format(aux + 1, model.auxCount or 0))
        end
        local ok, err = pcall(function()
          mon:attack(moveIndex)
          for _ = 1, 30 do
            mon:update(1 / 60)
            mon:build()
          end
        end)
        if not ok then
          report("threw while playing", dex, ("move %d"):format(moveIndex),
                 nil, tostring(err))
        end
      end
    end
  end

  rig:release()
  return steps
end

-- ------- the sweep

local list = only
if not list then
  list = {}
  for dex = 1, 151 do list[dex] = dex end
end

local started = os.clock()
local steps = 0
local staticPose = {}
for _, dex in ipairs(list) do
  local m = Pack.load(dex)
  -- 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))
    io.flush()
  end
end

-- ------- the LRU, which is the one failure a frame sweep cannot reach
--
-- A model is evicted by SPECIES COUNT, not by whether anything is still
-- standing on it -- so a battle that has seen five species while two of them
-- are on the field releases the textures of a Pokemon that is still being
-- drawn. That is not something playing one species' animations can produce;
-- it takes a sixth load. Reproduced here directly.
local function sweepEviction()
  local held = {}
  -- two Pokemon out, as a battle has
  for _, dex in ipairs({ 1, 4 }) do
    local model = Pack.load(dex)
    local rig = model and Rig.new(model)
    if rig then held[#held + 1] = { dex = dex, model = model, rig = rig } end
  end
  if #held < 2 then return end
  for _, h in ipairs(held) do
    h.rig:pose(1, 0, true)
    h.rig:skin(0)
    h.rig:textures(nil)
  end
  -- and then the fight sees more species than the cache keeps
  for _, dex in ipairs({ 7, 10, 13, 16, 19, 25 }) do Pack.load(dex) end
  for _, h in ipairs(held) do
    local ok, err = pcall(function()
      h.rig:textures(nil)
      h.rig:draw({ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 }, 0)
    end)
    if not ok then
      report("threw after the pack cache evicted a model still on the field",
             h.dex, "idle", nil, tostring(err))
    end
  end
  for _, h in ipairs(held) do h.rig:release() end
end
sweepEviction()

local elapsed = os.clock() - started

-- ------- what it found

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
local anchorList = {}
for dex in pairs(unanchored) do anchorList[#anchorList + 1] = dex end
table.sort(anchorList)
if #anchorList > 0 then
  print(("not anchored (standby loop too unsteady to measure, so they travel "
         .. "as authored): %s"):format(table.concat(anchorList, " ")))
end
if #staticPose > 0 then
  print(("staticPose (declined by the packer, never drawn, not swept): %s")
        :format(table.concat(staticPose, " ")))
end
print("")

local total = 0
for _, kind in ipairs(kinds) do total = total + #findings[kind] end

if total == 0 then
  print("no findings")
  os.exit(0)
end

for _, kind in ipairs(kinds) do
  local list2 = findings[kind]
  print(("---- %s (%d)"):format(kind, #list2))
  -- one line per species per kind, with the first example and a count, so a
  -- fault that spans every frame of an animation reads as one fault
  local seen, order = {}, {}
  for _, f in ipairs(list2) do
    local key = ("%03d|%s"):format(f.dex or 0, tostring(f.anim))
    if not seen[key] then
      seen[key] = { n = 0, f = f }
      order[#order + 1] = key
    end
    seen[key].n = seen[key].n + 1
  end
  for _, key in ipairs(order) do
    local e = seen[key]
    print(("  %03d %-14s %s%s%s")
          :format(e.f.dex or 0, tostring(e.f.anim),
                  e.f.frame and ("frame %.1f: "):format(e.f.frame) or "",
                  e.detail or e.f.detail or "",
                  e.n > 1 and (" (x%d)"):format(e.n) or ""))
  end
  print("")
end

print(("%d findings in %d kinds"):format(total, #kinds))
os.exit(total > 0 and 1 or 0)