stadium_shots.lua
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