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

stadium_shots.lua

tests/stadium_shots.lua16.5 KB100644289af00848
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
-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where
-- the two Pokemon are the Pokemon Stadium battle models rather than the
-- game's own pics stood up on their tiles.
--
-- What has to be looked at is a SPREAD OF SPECIES, because almost everything
-- that can go wrong here is per-model: how big it comes out (the size ladder
-- compresses a sixteenfold spread of authored heights -- see StadiumMon),
-- whether its feet are on the ground (the floor rule has to tell a hovering
-- Zubat from a Tentacruel centred on its own origin), whether the eyes
-- animate, and whether the species carries generated flame prims. So the
-- list below is deliberately awkward: the smallest and the largest in the
-- set, a hoverer, a floater, and the two species whose idle animation is
-- known to be wrong in the source data.
--
--   SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \
--     POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love .
--
-- SHOT_DIR must already exist: the capture writes with io.open, which does
-- not create directories.
return function(game)
  local U = dofile("tests/drivers/util.lua")
  local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
  local Pokemon = require("src.pokemon.Pokemon")
  local BattleState = require("src.battle.BattleState")

  local exports = game.mods and game.mods.exports
  local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
  if not lib then
    U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
    return
  end
  local Battles = lib.require("OverworldBattle")
  local Stadium = lib.require("Stadium")
  local Pack = lib.require("StadiumPack")

  local Install = lib.require("StadiumInstall")

  game.save.player.name = "RED"

  -- foe, and the player's own, chosen to put two different problems in one
  -- frame wherever possible
  local CASES = {
    { "PIKACHU", "CHARIZARD" },   -- the reference pair; Charizard has flame
    { "ONIX", "DIGLETT" },        -- the tallest against nearly the shortest
    { "ZUBAT", "TENTACRUEL" },    -- hovers above its origin / hangs below it
    { "GASTLY", "CATERPIE" },     -- a floater and the smallest in the set
    -- the five hermite-keyframe species (flags & 8), which the extractor
    -- used to misread as packed streams: Exeggutor, Tangela and Magmar came
    -- out so garbled the packer declined them to flat pics, and Pidgeot and
    -- Dodrio animated garbled. All five must now stand as MODELS in a sane
    -- standby loop (see StadiumFragment's animation notes).
    { "EXEGGUTOR", "MAGMAR" },
    { "PIDGEOT", "TANGELA" },
    { "DODRIO", "PIDGEOTTO" },    -- with its packed-stream evolution as control
    { "GYARADOS", "SNORLAX" },    -- the two biggest bodies in the set
  }

  -- ONTO THE MAP FIRST, before anything below waits on anything.
  --
  -- The models are built out of the player's own ROM on first run, on a
  -- loading screen, and that screen is pushed from the mod's update hook on
  -- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush).
  -- A driver that waits for the build before it has got the player onto a map
  -- waits forever: the condition that starts the build is the very thing the
  -- waiting is postponing.
  -- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose
  -- whole claim is that it carries its stage: the shot has to be checked in a
  -- CAVE and in a SHOP as well as on a route, both because those are the
  -- places the map-staged rungs decline, and because the rung is supposed to
  -- take that place's own sky and light with it.
  local MAP = os.getenv("DS_MAP") or "ROUTE_1"
  local MX = tonumber(os.getenv("DS_X") or "") or 5
  local MY = tonumber(os.getenv("DS_Y") or "") or 8
  U.teleport(game, MAP, MX, MY, "down")

  -- Now wait it out, and say how long it took -- that number is what decides
  -- whether the loading screen is acceptable rather than merely correct.
  local waited, shotLoad = 0, false
  while (Install.pending() or Install.status.state == "building")
        and waited < 5400 do
    if not shotLoad and Install.status.state == "building"
        and (Install.status.done or 0) > 20 then
      U.shot(game, ("%s/00_loading.png"):format(DIR))
      shotLoad = true
    end
    U.wait(10)
    waited = waited + 10
  end
  if waited > 0 then
    U.log(("waited %d frames (%.1fs) for the model build -- %s")
          :format(waited, waited / 60, tostring(Install.status.state)))
    if Install.status.error then
      U.log("build error: " .. tostring(Install.status.error))
    end
  end
  U.log(("rom present: %s, packs ready: %s, stadium available: %s")
        :format(tostring(Install.romPresent()), tostring(Install.ready()),
                tostring(Install.available())))

  -- the rung under test, and the back pic OFF: BACK SPRITES would keep the
  -- player's own side on the menu, which is exactly the half of the shot
  -- this driver exists to look at
  local RUNGS = { cards = true, cardsb = "flatB", a = "stadium",
                  b = "stadiumB" }
  local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium"
  Battles.setting:setValue(rung, game)
  Battles.backSetting:setValue(false, game)
  -- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky,
  -- the tint, the weight of the shadows -- so a driver left on CYCLE
  -- photographs a different scene every run, and two runs of it cannot be
  -- compared. DS_TOD overrides for a deliberate look at dusk or night.
  lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
  U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s")
        :format(tostring(Battles.setting:get()), tostring(Stadium.enabled()),
                tostring(Stadium.discs())))

  -- did the models actually get built? one line rather than 151 silent
  -- declines
  local have, missing = 0, {}
  for dex = 1, 151 do
    if Pack.available(dex) then have = have + 1
    elseif #missing < 6 then missing[#missing + 1] = dex end
  end
  U.log(("stadium packs present: %d/151%s"):format(
        have, #missing > 0 and (" (missing " .. table.concat(missing, ",")
                                .. "...)") or ""))

  -- let the neighbourhood's meshes land before the first fight, so the
  -- opening frame is not the flat fallback
  U.wait(90)

  -- ------- DS_BLINK: how often does the eye actually change?
  --
  -- The texture animation rides the SKELETAL animation's frame and clamps
  -- past the end of its own stream (StadiumRig.textures). Get either wrong
  -- and a blink becomes a twitch: Rattata's standby loop is forty frames and
  -- its blink is five, so wrapping on the blink's own length plays it six
  -- times a second.
  --
  -- Counted rather than looked at, because "too fast" is a RATE and a
  -- screenshot has no rate in it. This walks the idle loop frame by frame and
  -- reports how many times the eye texture changes per second.
  if os.getenv("DS_BLINK") then
    local Pack = lib.require("StadiumPack")
    local Rig = lib.require("StadiumRig")
    for _, dex in ipairs({ 19, 4, 1, 25 }) do
      local model = Pack.load(dex)
      local rig = model and Rig.new(model)
      if rig then
        local idle = model.ctx[Pack.SLOT.idle]
        local anim = (idle ~= Pack.NONE) and (idle + 1) or nil
        local rec = anim and model.anims[anim]
        -- which prim carries the eye, and what it shows each frame
        local seen, changes, last = {}, 0, nil
        local SECONDS = 4
        for f = 0, SECONDS * Pack.FPS - 1 do
          rig:pose(anim, f, true)
          rig:textures(rec and rec.aux or nil)
          local key = {}
          for i, part in ipairs(rig.parts) do
            if part.prim.texAnim and part.prim.texAnim >= 0 then
              key[#key + 1] = i .. ":" .. tostring(part.texture)
            end
          end
          key = table.concat(key, ",")
          if last and key ~= last then changes = changes + 1 end
          last = key
          seen[key] = true
        end
        local looks = 0
        for _ in pairs(seen) do looks = looks + 1 end
        local period = rec and (rec.frames / Pack.FPS) or 0
        U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- "
               .. "%d texture changes in %ds, %d distinct looks; the idle "
               .. "loop comes round every %.2fs, so that is %.1f changes a "
               .. "cycle")
              :format(dex, tostring(rec and rec.frames), period,
                      tostring(rec and rec.aux
                               and model.auxAnims[rec.aux].frames),
                      changes, SECONDS, looks, period,
                      period > 0 and changes / (SECONDS / period) or 0))
        rig:release()
      end
    end
    U.log("done -- " .. DIR)
    return
  end

  -- ------- DS_FLY: does the Pokemon actually leave?
  --
  -- FLY and DIG take the user off the field for a turn, and the engine says
  -- so through `picFx[battler].hidden` rather than through fxHidden (which is
  -- the damage blink alone). A model that ignores that stands on its tile
  -- while every attack aimed at it misses into empty air.
  --
  -- A TIMELINE again, not a still: what is being checked is that the model
  -- goes when the pic goes and comes back when it comes back, which is three
  -- moments and a screenshot has one.
  if os.getenv("DS_FLY") then
    local Stadium2 = lib.require("Stadium")
    local move = os.getenv("DS_MOVE") or "FLY"
    local mine = Pokemon.new(game.data, "CHARIZARD", 60)
    -- give it the move outright: what is under test is the vanish, not
    -- whether this species learns it by level 60
    mine.moves = { { id = move, pp = 15 } }
    game.save.party = { mine }
    local battle = BattleState.newWild(game, "PIKACHU", 5)
    battle.onFinish = function() end
    game.overworld:pushBattle(battle)
    U.wait(70)
    for _ = 1, 40 do
      if battle.phase == "menu" then break end
      U.tap(game, "a")
      U.wait(10)
    end
    U.tap(game, "a")      -- FIGHT
    U.wait(12)
    U.tap(game, "a")      -- the first (only) move

    local goneAt, backAt, wasGone = nil, nil, false
    for f = 1, 400 do
      if f % 20 == 0 then U.tap(game, "a") end
      U.wait(1)
      local me = battle.player
      local pf = battle.picFx and battle.picFx[me]
      local picHidden = (pf and pf.hidden) and true or false
      local showing = Stadium2.showing("player")
      if f % 25 == 0 or (picHidden ~= wasGone) then
        U.log(("  f=%3d picHidden=%-5s model=%-5s anim=%s")
              :format(f, tostring(picHidden), tostring(showing),
                      tostring(Stadium2.animOf("player"))))
      end
      if picHidden and not goneAt then goneAt = f end
      if picHidden and not showing and not wasGone then
        wasGone = true
        U.shot(game, ("%s/fly_1_gone.png"):format(DIR))
      end
      if wasGone and not picHidden and not backAt then
        backAt = f
        U.shot(game, ("%s/fly_2_back.png"):format(DIR))
        break
      end
    end
    U.log(("%s: the pic hid at frame %s, the model was gone with it (%s), "
           .. "and both came back at %s")
          :format(move, tostring(goneAt), tostring(wasGone), tostring(backAt)))
    U.log(wasGone and "  OK -- the model leaves the field with the pic"
          or "  WRONG -- the model stood there while the game said it was gone")
    while game.stack:top() and game.stack:top() ~= game.overworld do
      game.stack:pop()
    end
    U.wait(10)
    U.log("done -- " .. DIR)
    return
  end

  -- ------- DS_FAINT: does the collapse wait for the bar?
  --
  -- The faint animation must not start until the foe's HP bar has finished
  -- emptying: `onFaint` fires the instant HP reaches zero, but the engine
  -- queues the visible collapse behind the move animation and the drain, and
  -- a Pokemon that lies down while its own health is still draining above it
  -- reads as a bug (see Stadium's faintReady).
  --
  -- A TIMELINE rather than a screenshot, because what is being checked is an
  -- ORDER: the frame the bar reaches zero against the frame the animation
  -- changes. One printed line per frame settles that; two stills cannot.
  if os.getenv("DS_FAINT") then
    local Stadium2 = lib.require("Stadium")
    game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
    local battle = BattleState.newWild(game, "PIKACHU", 5)
    battle.onFinish = function() end
    game.overworld:pushBattle(battle)
    U.wait(70)
    for _ = 1, 40 do
      if battle.phase == "menu" then break end
      U.tap(game, "a")
      U.wait(10)
    end
    U.tap(game, "a")
    U.wait(12)
    U.tap(game, "a")

    local barZeroAt, animAt, shot, stood = nil, nil, false, 0
    for f = 1, 700 do
      -- keep advancing the text, as a player would. Without this the queue
      -- blocks on the damage message and the HP bar never drains at all --
      -- which is a perfectly good demonstration that the collapse now waits,
      -- and no demonstration whatever that it eventually arrives.
      if f % 20 == 0 then U.tap(game, "a") end
      U.wait(1)
      local foe = battle.enemy
      local shown = foe and foe.shownHP
      local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil
      if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then
        U.log(("  f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s")
              :format(f, tostring(battle.phase), tostring(shown),
                      tostring(foe and foe.mon and foe.mon.hp),
                      tostring(foe and foe.faintQueued), tostring(anim)))
      end
      if shown and shown <= 0 and not barZeroAt then barZeroAt = f end
      if anim == "faint" and not animAt then animAt = f end
      -- a still from the middle of the drain: the foe must still be standing
      if shown and shown > 0 and not shot and barZeroAt == nil
          and foe and foe.faintQueued then
        U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
        shot = true
      end
      -- and how long the model then STAYS. The engine takes the flat pic off
      -- after a fourteen-frame slide; the animation runs for one to eight
      -- seconds, and being cut off mid-fall is worse than not falling at all.
      if animAt and Stadium2.showing("enemy") then stood = f - animAt end
      if animAt and f == animAt + 20 then
        U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR))
      end
      if animAt and f == animAt + 90 then
        U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR))
      end
      if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then
        U.shot(game, ("%s/faint_4_gone.png"):format(DIR))
        break
      end
    end
    U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s")
          :format(tostring(barZeroAt), tostring(animAt)))
    if barZeroAt and animAt then
      U.log((animAt >= barZeroAt)
            and "  OK -- the collapse waits for the bar"
            or "  WRONG -- the collapse ran while the bar was still draining")
    end
    U.log(("  the model stayed on the field for %d frames (%.2fs) after the "
           .. "collapse began; the engine's own pic slide is 14 (%.2fs)")
          :format(stood, stood / 60, 14 / 60))
    while game.stack:top() and game.stack:top() ~= game.overworld do
      game.stack:pop()
    end
    U.wait(10)
    U.log("done -- " .. DIR)
    return
  end

  for i, case in ipairs(CASES) do
    local foe, mine = case[1], case[2]
    local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower())
    game.save.party = { Pokemon.new(game.data, mine, 40) }

    local battle = BattleState.newWild(game, foe, 30)
    battle.onFinish = function() end
    game.overworld:pushBattle(battle)

    -- The wipe, then the intro chatter, and -- the part that matters --
    -- far enough past it that the player's own Pokemon has been SENT OUT.
    -- Before that the player's side is legitimately the trainer's back
    -- sprite, with no Pokemon on the field to model at all; a shot taken
    -- there looks exactly like a bug and is not one.
    U.wait(70)
    -- tap until the FIGHT menu is up, which is the first moment BOTH
    -- Pokemon are on the field in their standby loop
    for _ = 1, 40 do
      if battle.phase == "menu" then break end
      U.tap(game, "a")
      U.wait(10)
    end
    U.wait(45)
    U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag))

    -- FIGHT -> the first move, which is where the attack animation is
    U.tap(game, "a")
    U.wait(12)
    U.tap(game, "a")
    U.wait(22)
    U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag))
    U.wait(45)
    U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag))

    while game.stack:top() and game.stack:top() ~= game.overworld do
      game.stack:pop()
    end
    U.wait(10)
  end

  U.log("done -- " .. DIR)
end