mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
544 lines
23 KiB
Lua
544 lines
23 KiB
Lua
-- The battle-animation tables, read out of a Gold cache and checked against
|
|
-- the decomp they came from.
|
|
--
|
|
-- Every expectation below is a literal from pokegold, so a failure names the
|
|
-- file it disagrees with rather than "a number changed":
|
|
-- data/moves/animations.asm BattleAnim_Tackle
|
|
-- data/battle_anims/objects.asm battleanimobj rows
|
|
-- data/battle_anims/framesets.asm oamframe lists
|
|
-- data/battle_anims/oam.asm battleanimoam + dbsprite rows
|
|
-- data/battle_anims/object_gfx.asm anim_obj_gfx counts
|
|
--
|
|
-- luajit tests/gen2_battle_anims_test.lua
|
|
-- Also dofile'd by tests/run_tests.lua. Skips when there is no Gold cache.
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
|
|
local S = require("tests.harness").suite("gen2 battle anims")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local cache = os.getenv("GOLD_CACHE")
|
|
if not cache then
|
|
local home = os.getenv("HOME") or ""
|
|
cache = home .. "/Library/Application Support/LOVE/gold-dev/gold"
|
|
end
|
|
|
|
local path = cache .. "/data/generated/battle_anims.lua"
|
|
local file = io.open(path, "r")
|
|
if not file then
|
|
check(true, "no Gold cache at " .. path .. " (SKIP)")
|
|
S.finish()
|
|
return
|
|
end
|
|
file:close()
|
|
|
|
local data = assert(loadfile(path))()
|
|
|
|
-- A cache from before the anims were extracted still has the Phase 2 stub.
|
|
if not data.scripts then
|
|
check(true, "battle_anims.lua is still the stub -- re-import Gold (SKIP)")
|
|
S.finish()
|
|
return
|
|
end
|
|
|
|
-- ---- the per-move scripts -------------------------------------------------
|
|
|
|
check(type(data.moves) == "table", "a move -> script map")
|
|
eq(data.bank, 50, "the animations live in bank $32")
|
|
|
|
local function countKeys(t)
|
|
local n = 0
|
|
for _ in pairs(t or {}) do n = n + 1 end
|
|
return n
|
|
end
|
|
|
|
eq(countKeys(data.moves), 251, "one animation per move (NUM_MOVES)")
|
|
check(countKeys(data.scripts) > 300,
|
|
("%d scripts in the pool, including the ones only anim_call reaches")
|
|
:format(countKeys(data.scripts)))
|
|
eq(countKeys(data.scripts), #data.scriptOrder,
|
|
"scriptOrder lists every script exactly once")
|
|
|
|
-- BattleAnim_Tackle, row for row. This one is worth pinning: it uses a gfx
|
|
-- load, a call, a BG effect, a wait, a sound, an object and a return, which is
|
|
-- most of the command set a move animation actually reaches for.
|
|
local tackle = data.scripts[data.moves.TACKLE]
|
|
check(tackle ~= nil, "TACKLE has a script")
|
|
if tackle then
|
|
eq(tackle[1][1], "1gfx", "anim_1gfx first")
|
|
eq(tackle[1][2], 1, "...loading BATTLE_ANIM_GFX_HIT")
|
|
eq(tackle[2][1], "call", "then anim_call BattleAnim_TargetObj_1Row")
|
|
check(type(tackle[2][2]) == "string",
|
|
"and a branch target is an address key, not two loose bytes")
|
|
check(data.scripts[tackle[2][2]] ~= nil,
|
|
"which resolves to a script in the pool")
|
|
eq(tackle[3][1], "bgeffect", "then anim_bgeffect")
|
|
eq(tackle[3][2], 36, "...BATTLE_BG_EFFECT_TACKLE")
|
|
eq(tackle[3][4], 1, "...on BG_EFFECT_USER")
|
|
eq(tackle[4][1], "wait", "then anim_wait 4")
|
|
eq(tackle[4][2], 4, "...four frames")
|
|
eq(tackle[5][1], "sound", "then anim_sound")
|
|
eq(tackle[6][1], "obj", "then anim_obj")
|
|
eq(tackle[6][2], 0, "...BATTLE_ANIM_OBJ_HIT_BIG_YFIX")
|
|
eq(tackle[6][3], 136, "...at x 136")
|
|
eq(tackle[6][4], 48, "...and y 48")
|
|
eq(tackle[7][1], "wait", "then anim_wait 8")
|
|
eq(tackle[7][2], 8, "...eight frames")
|
|
eq(tackle[8][1], "call", "then anim_call BattleAnim_ShowMon_0")
|
|
eq(tackle[9][1], "ret", "then anim_ret")
|
|
eq(#tackle, 9, "and nothing after it")
|
|
end
|
|
|
|
-- ---- BattleAnimObjects ----------------------------------------------------
|
|
--
|
|
-- A row is SIX bytes: BATTLEANIMOBJ_LENGTH is `_RS - 1`, discounting the
|
|
-- struct's runtime INDEX byte. Reading seven walks every row off the end.
|
|
|
|
eq(countKeys(data.objects), 188, "188 animation objects")
|
|
local ember = data.objects.BATTLE_ANIM_OBJ_EMBER
|
|
check(ember ~= nil, "EMBER is one of them")
|
|
if ember then
|
|
eq(ember.fixY, 0xaa, "EMBER's enemy y-fix is $aa")
|
|
eq(ember.frameset, "BATTLE_ANIM_FRAMESET_EMBER", "its own frameset")
|
|
eq(ember.func, "BATTLE_ANIM_FUNC_EMBER", "its own per-frame function")
|
|
eq(ember.palette, "PAL_BATTLE_OB_RED",
|
|
"an OBJ palette, not the BG palette of the same number")
|
|
eq(ember.gfx, "BATTLE_ANIM_GFX_FIRE", "off the fire sheet")
|
|
end
|
|
local hit = data.objects.BATTLE_ANIM_OBJ_HIT_BIG_YFIX
|
|
if hit then
|
|
eq(hit.fixY, 0xff, "HIT_BIG_YFIX is the $ff y-fix variant")
|
|
eq(hit.func, "BATTLE_ANIM_FUNC_NULL", "and does not move itself")
|
|
eq(hit.palette, "PAL_BATTLE_OB_GRAY", "grey")
|
|
end
|
|
|
|
-- ---- framesets ------------------------------------------------------------
|
|
--
|
|
-- oam_anims.asm counts DOWN from $ff: oamend $ff, oamrestart $fe, oamwait
|
|
-- $fd, oamdelete $fc. Getting that order wrong runs one frameset into the
|
|
-- next and every list comes out dozens of frames long.
|
|
|
|
eq(countKeys(data.framesets), 185, "185 framesets")
|
|
local hitBig = data.framesets.BATTLE_ANIM_FRAMESET_HIT_BIG
|
|
check(hitBig ~= nil, "HIT_BIG is one")
|
|
if hitBig then
|
|
eq(#hitBig, 2, "Frameset_HitBig is one frame and a delete")
|
|
eq(hitBig[1][1], "frame", "the frame")
|
|
eq(hitBig[1][2], "BATTLE_ANIM_OAMSET_00", "...is OAM set 00")
|
|
eq(hitBig[1][3], 6, "...held six frames")
|
|
eq(hitBig[2][1], "delete", "then oamdelete")
|
|
end
|
|
local ember2 = data.framesets.BATTLE_ANIM_FRAMESET_EMBER
|
|
if ember2 then
|
|
eq(#ember2, 3, "Frameset_Ember is two frames and a restart")
|
|
eq(ember2[3][1], "restart", "oamrestart, not oamend")
|
|
end
|
|
local shake = data.framesets.BATTLE_ANIM_FRAMESET_PUNCH_SHAKE
|
|
if shake then
|
|
eq(#shake, 9, "Frameset_PunchShake alternates four times, then deletes")
|
|
eq(shake[9][1], "delete", "ending on oamdelete")
|
|
end
|
|
|
|
-- ---- OAM sets -------------------------------------------------------------
|
|
--
|
|
-- dbsprite emits y FIRST even though the macro reads x first, and a negative
|
|
-- tile column wraps: `dbsprite -2, -2, 0, 0, $00, $0` is y $f0, x $f0.
|
|
|
|
eq(countKeys(data.oamsets), 216, "216 OAM sets")
|
|
local set00 = data.oamsets.BATTLE_ANIM_OAMSET_00
|
|
check(set00 ~= nil, "OAM set 00 is one")
|
|
if set00 then
|
|
eq(set00.vtile, 0, "at vtile offset 0")
|
|
eq(#set00.sprites, 16, "with 16 sprites")
|
|
eq(set00.sprites[1].y, 0xf0, "the first one's y wraps to $f0")
|
|
eq(set00.sprites[1].x, 0xf0, "and its x to $f0")
|
|
eq(set00.sprites[1].tile, 0, "tile $00")
|
|
eq(set00.sprites[2].tile, 1, "then tile $01")
|
|
end
|
|
|
|
-- ---- object graphics ------------------------------------------------------
|
|
--
|
|
-- BATTLE_ANIM_GFX_* starts at 1, because AnimObjGFX row 0 is the empty
|
|
-- AnimObj00GFX -- so the name list carries a placeholder and every id indexes
|
|
-- it the same way.
|
|
|
|
check(countKeys(data.gfx) >= 39, "the object sheets extracted")
|
|
local hitGfx = data.gfx.BATTLE_ANIM_GFX_HIT
|
|
check(hitGfx ~= nil, "the hit sheet is one")
|
|
if hitGfx then
|
|
eq(hitGfx.tiles, 21, "AnimObjHitGFX is 21 tiles")
|
|
check(hitGfx.image:match("battle_anims/"), "written under battle_anims/")
|
|
end
|
|
local cutGfx = data.gfx.BATTLE_ANIM_GFX_CUT
|
|
if cutGfx then eq(cutGfx.tiles, 6, "AnimObjCutGFX is 6 tiles") end
|
|
check(data.gfx.BATTLE_ANIM_GFX_NONE == nil,
|
|
"and the empty row 0 is not written as a sheet")
|
|
|
|
-- ---- the runtime -----------------------------------------------------------
|
|
--
|
|
-- src/battle/gen2/AnimRunner.lua + AnimObjects.lua + BgEffects.lua: the
|
|
-- command interpreter, the ten object structs and the five BG-effect structs,
|
|
-- stepped one 60 Hz frame at a time with no window. The expectations below
|
|
-- come from engine/battle_anims/{anim_commands,core,functions,bg_effects}.asm
|
|
-- rather than from a recorded run, so a failure names the routine it
|
|
-- disagrees with.
|
|
|
|
local consts
|
|
do
|
|
local file = io.open(cache .. "/data/generated/constants.lua", "r")
|
|
if file then
|
|
file:close()
|
|
consts = assert(loadfile(cache .. "/data/generated/constants.lua"))()
|
|
end
|
|
end
|
|
|
|
if not (consts and consts.battleAnimObjectOrder) then
|
|
check(true, "no constants.lua in the cache -- skipping the runtime (SKIP)")
|
|
S.finish()
|
|
return
|
|
end
|
|
|
|
local AnimObjects = require("src.battle.gen2.AnimObjects")
|
|
local AnimRunner = require("src.battle.gen2.AnimRunner")
|
|
|
|
-- The sine BattleAnim_Sine computes is the same quarter wave the sprite anims
|
|
-- use, and entry 16 is exactly $100 -- so sin(pi/2) * d is d itself.
|
|
eq(AnimObjects.sine(16, 0x40), 0x40, "sine at a quarter turn is the amplitude")
|
|
eq(AnimObjects.sine(0, 0x40), 0, "and zero at zero")
|
|
eq(AnimObjects.sine(48, 0x40), 0xc0, "the lower half comes back negative")
|
|
eq(AnimObjects.cosine(0, 0x40), 0x40, "cosine leads it by eight steps")
|
|
|
|
-- `sra` keeps the sign, which is what every "an eighth of the width" does.
|
|
eq(AnimObjects.sra(0xff), 0xff, "sra of -1 is -1")
|
|
eq(AnimObjects.sra(0xfd), 0xfe, "sra of -3 is -2")
|
|
eq(AnimObjects.sra(0x08), 0x04, "and of 8 is 4")
|
|
|
|
local function runner(key, opts)
|
|
opts = opts or {}
|
|
local r = AnimRunner.new({
|
|
data = data, constants = consts,
|
|
battleTurn = opts.turn or 0, animId = opts.animId,
|
|
param = opts.param or 0, sfxOrder = consts.sfxOrder,
|
|
hooks = opts.hooks,
|
|
})
|
|
r:start(key)
|
|
return r
|
|
end
|
|
|
|
-- BattleAnim_Tackle: anim_1gfx HIT, anim_call, anim_bgeffect TACKLE,
|
|
-- anim_wait 4, anim_sound, anim_obj HIT_BIG_YFIX, anim_wait 8, anim_call,
|
|
-- anim_ret. One step runs commands up to the first wait.
|
|
local sounds = {}
|
|
local tackle = runner(data.moves.TACKLE, {
|
|
animId = "TACKLE",
|
|
hooks = { sound = function(name) sounds[#sounds + 1] = name end },
|
|
})
|
|
check(tackle ~= nil, "a runner for Tackle")
|
|
tackle:step()
|
|
eq(tackle:tileOffsetFor("BATTLE_ANIM_GFX_HIT"), 0,
|
|
"anim_1gfx puts the hit sheet at the base tile")
|
|
check(tackle.bg:activeCount() >= 1, "and queued a BG effect before waiting")
|
|
local steps = 0
|
|
while tackle:step() and steps < 600 do steps = steps + 1 end
|
|
check(steps > 0 and steps < 600, "Tackle runs to its anim_ret in " .. steps .. " frames")
|
|
check(#sounds >= 1, "and played at least one SFX (" .. tostring(sounds[1]) .. ")")
|
|
|
|
-- The pic-swap commands all branch on hBattleTurn with the same `and a / jr z,
|
|
-- .player`, and every .player arm writes vTiles2 tile $31, the 6x6 backpic:
|
|
-- turn 0 repaints the PLAYER, the fall-through (tile $00, a 7x7 frontpic) the
|
|
-- enemy. engine/battle_anims/anim_commands.asm BattleAnimCmd_Transform,
|
|
-- _RaiseSub, _DropSub, _MinimizeOpp, _BeatUp.
|
|
local CMD = AnimRunner.COMMANDS
|
|
local function overrideSide(name, turn)
|
|
local r = runner(nil, { turn = turn })
|
|
CMD[name](r)
|
|
if r.picOverride.player ~= nil then return "player" end
|
|
if r.picOverride.enemy ~= nil then return "enemy" end
|
|
return nil
|
|
end
|
|
for _, name in ipairs({ "transform", "raisesub", "minimizeopp", "beatup" }) do
|
|
eq(overrideSide(name, 0), "player", "anim_" .. name .. " on turn 0 is the player's own pic")
|
|
eq(overrideSide(name, 1), "enemy", "anim_" .. name .. " on turn 1 is the enemy's")
|
|
end
|
|
-- $e9 is one of the six labels stacked on BattleAnimCmd_E8's single `ret`, so
|
|
-- the opcode named `minimize` draws nothing; $e2 minimizeopp is the real one.
|
|
eq(overrideSide("minimize", 0), nil, "anim_minimize ($e9) is a cart dummy")
|
|
|
|
-- Every one of the 251 move animations has to terminate, from both sides.
|
|
-- A script that never reaches an anim_ret outside a subroutine would hang the
|
|
-- battle screen, and the branch-heavy ones (Metronome, Bide, the multi-hit
|
|
-- loops) are exactly where that would happen.
|
|
local longest, longestName, stuck = 0, nil, 0
|
|
for move, key in pairs(data.moves) do
|
|
for _, turn in ipairs({ 0, 1 }) do
|
|
local r = runner(key, { animId = move, turn = turn, param = 3 })
|
|
local frames = 0
|
|
local ok = pcall(function()
|
|
while r:step() and frames < 1500 do frames = frames + 1 end
|
|
end)
|
|
if not ok or frames >= 1500 then stuck = stuck + 1 end
|
|
if frames > longest then longest, longestName = frames, move end
|
|
end
|
|
end
|
|
eq(stuck, 0, "all 251 move animations terminate on both turns")
|
|
check(longest > 0 and longest < 1500,
|
|
("the longest is %s at %d frames"):format(tostring(longestName), longest))
|
|
|
|
-- BattleAnimObjects rows are six bytes and the struct's TILEID comes from the
|
|
-- tile dict, not the row -- so an object queued before its sheet is loaded
|
|
-- draws at offset 0 rather than reading a seventh byte that is not there.
|
|
local objects = AnimObjects.new(data, consts, { battleTurn = 0 })
|
|
local st = objects:queue("BATTLE_ANIM_OBJ_HIT_BIG_YFIX", 0x88, 0x30, 0)
|
|
check(st ~= nil, "an object queues into the first free struct")
|
|
if st then
|
|
eq(st.index, 1, "and takes wLastAnimObjectIndex 1")
|
|
eq(st.x, 0x88, "at the x it was given")
|
|
eq(st.frame, 0xff, "with FRAME initialised to -1, not 0")
|
|
eq(st.tileId, 0, "and no tile offset without a dict entry")
|
|
end
|
|
|
|
-- BattleAnimCmd_ClearObjs only reaches six and two thirds structs, so 8, 9
|
|
-- and 10 survive it (docs/bugs_and_glitches.md).
|
|
local pool = AnimObjects.new(data, consts, { battleTurn = 0 })
|
|
for _ = 1, 10 do pool:queue("BATTLE_ANIM_OBJ_HIT", 0x40, 0x40, 0) end
|
|
eq(pool:activeCount(), 10, "ten structs is the whole pool")
|
|
pool:clearObjs()
|
|
eq(pool:activeCount(), 3, "anim_clearobjs leaves the last three running")
|
|
|
|
-- BATTLE_ANIM_FUNC_NULL only deletes once anim_incobj has walked it past
|
|
-- .zero; before that it does nothing at all.
|
|
local nullObj = AnimObjects.new(data, consts, { battleTurn = 0 })
|
|
local nst = nullObj:queue("BATTLE_ANIM_OBJ_ABSORB_CENTER", 0x40, 0x40, 0)
|
|
AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_NULL(nullObj, nst)
|
|
eq(nst.index, 1, "FUNC_NULL at jumptable 0 is a no-op")
|
|
nst.jt = 1
|
|
AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_NULL(nullObj, nst)
|
|
eq(nst.index, 0, "and deletes the object at 1")
|
|
|
|
-- BattleAnim_StepToTarget: the lower nybble is the x step and half of it the
|
|
-- y step, and a y step of 0 walks the coordinate 256 times -- back to where it
|
|
-- started, not once.
|
|
local step = AnimObjects.new(data, consts, { battleTurn = 0 })
|
|
local sst = step:queue("BATTLE_ANIM_OBJ_HIT", 0x40, 0x40, 4)
|
|
sst.jt = 0
|
|
AnimObjects.FUNCTIONS.BATTLE_ANIM_FUNC_USER_TO_TARGET(step, sst)
|
|
eq(sst.x, 0x44, "a param of 4 moves four right")
|
|
eq(sst.y, 0x3e, "and two up")
|
|
|
|
-- BgEffects: the shake's amplitude is the struct's BATTLE_TURN field, which
|
|
-- flips sign every time the PARAM countdown reloads.
|
|
local BgEffects = require("src.battle.gen2.BgEffects")
|
|
local bg = BgEffects.new(consts, { battleTurn = 0 })
|
|
bg:queue("BATTLE_BG_EFFECT_SHAKE_SCREEN_X", 0x10, 2, 0x22)
|
|
bg:playFrame()
|
|
eq(bg.scx, 2, "the first frame shakes by the amplitude it was given")
|
|
for _ = 1, 2 do bg:playFrame() end
|
|
eq(bg.scx, 0xfe, "and flips to -2 once the period runs out")
|
|
for _ = 1, 40 do bg:playFrame() end
|
|
eq(bg.scx, 0, "the shake ends flat")
|
|
eq(bg:activeCount(), 0, "and frees its struct")
|
|
|
|
-- Tackle's BG effect writes the attacker's own scanlines: on the player's
|
|
-- turn that is $2f-$5e, and SCX goes NEGATIVE because the player's mon steps
|
|
-- to the right.
|
|
local tack = BgEffects.new(consts, { battleTurn = 0 })
|
|
tack:queue("BATTLE_BG_EFFECT_TACKLE", 0, 1, 0)
|
|
tack:playFrame()
|
|
eq(tack.lyStart, 0x2f, "the player's window starts at scanline $2f")
|
|
eq(tack.lyEnd, 0x5f, "and ends one past $5e")
|
|
eq(tack.lcdc, "SCX", "through rSCX")
|
|
tack:playFrame()
|
|
eq(tack.lyBackup[0x30], 0,
|
|
"Tackle_MoveForward writes the distance so far BEFORE stepping it")
|
|
tack:playFrame()
|
|
eq(tack.lyBackup[0x30], 0xfe,
|
|
"so the step only shows on the frame after: two pixels right")
|
|
|
|
-- The intro slide is 72 frames from $90 and $70 to nothing at all.
|
|
local BattleAnimView = require("src.ui.gen2.BattleAnimView")
|
|
eq(BattleAnimView.SLIDE_FRAMES, 72, "BattleIntroSlidingPics runs 72 frames")
|
|
local top, middle = BattleAnimView.slideOffsets(0)
|
|
eq(top, 0x90, "the enemy's half starts a screen and a half to the right")
|
|
eq(middle, 0x70, "and the player's to the left")
|
|
top, middle = BattleAnimView.slideOffsets(72)
|
|
eq(top, 0, "both land on zero")
|
|
eq(middle, 0, "at the same frame")
|
|
|
|
|
|
-- ------------------------------------------------ the screen deformations
|
|
--
|
|
-- The thirteen effects that used to be BgEffects.UNMODELLED. Each one is a
|
|
-- DeformScreen / DeformWater / surf-ring shape, so the assertions here are
|
|
-- about the ARRAY the effect writes rather than what it looks like.
|
|
|
|
check(#BgEffects.UNMODELLED == 0,
|
|
"every BG effect is modelled (" .. #BgEffects.UNMODELLED .. " left)")
|
|
|
|
-- DeformScreen writes only lyStart < row <= lyEnd, but advances its phase on
|
|
-- every one of its $80 iterations -- so WHERE the window sits changes which
|
|
-- part of the wave lands on it.
|
|
local deform = BgEffects.new(consts, { battleTurn = 0 })
|
|
deform.lyStart, deform.lyEnd = 0x10, 0x20
|
|
deform:deformScreen(2, 2)
|
|
eq(deform.lyBackup[0x10], 0, "DeformScreen leaves the row AT lyStart alone")
|
|
eq(deform.lyBackup[0x21], 0, "and the row past lyEnd")
|
|
-- Row n takes sine(2n) at amplitude 2, which is the phase after n iterations.
|
|
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
|
|
eq(deform.lyBackup[0x18], SpriteAnims.sine(0x18 * 2, 2),
|
|
"and the value on a row is the phase that iteration had reached")
|
|
|
|
-- WavyScreenFX rotates the window up one, the top row wrapping to the bottom.
|
|
local wavy = BgEffects.new(consts, { battleTurn = 0 })
|
|
wavy.lyStart, wavy.lyEnd = 4, 8
|
|
for row = 4, 8 do wavy.lyBackup[row] = row end
|
|
wavy:wavyScreenFX()
|
|
eq(wavy.lyBackup[4], 5, "the wave travels up a scanline a frame")
|
|
eq(wavy.lyBackup[7], 8, "...all the way to the end of the window")
|
|
eq(wavy.lyBackup[8], 4, "...and the top row wraps around to the bottom")
|
|
|
|
-- Teleport: one DeformScreen, then a rotation a frame, then the registers go
|
|
-- back. The player's window is $2f-$5e.
|
|
local tele = BgEffects.new(consts, { battleTurn = 0 })
|
|
tele:queue("BATTLE_BG_EFFECT_TELEPORT", 0, 1, 0)
|
|
tele:playFrame()
|
|
eq(tele.lcdc, "SCX", "Teleport warps horizontally")
|
|
eq(tele.lyStart, 0x2f, "over the user's own rows")
|
|
local before = tele.lyBackup[0x40]
|
|
tele:playFrame()
|
|
eq(tele.lyBackup[0x3f], before, "and the wave moves up one row a frame")
|
|
tele:incEffect("BATTLE_BG_EFFECT_TELEPORT")
|
|
tele:playFrame()
|
|
eq(tele.lcdc, nil, "the third state puts the registers back")
|
|
eq(tele:activeCount(), 0, "and frees the struct")
|
|
|
|
-- Psychic covers the whole screen whichever side used it, and only travels
|
|
-- every fourth frame.
|
|
local psy = BgEffects.new(consts, { battleTurn = 0 })
|
|
psy:queue("BATTLE_BG_EFFECT_PSYCHIC", 0, 1, 0)
|
|
psy:playFrame()
|
|
eq(psy.lyStart, 0, "Psychic is hardcoded to the whole screen")
|
|
eq(psy.lyEnd, 0x5f, "...all $5f scanlines of it")
|
|
-- `ld a, [hl] / inc [hl] / and $3 / ret nz`: the PRE-increment counter, so the
|
|
-- first frame in state 1 does travel and the next three do not. Compare the
|
|
-- whole window rather than one row: at amplitude 6 plenty of neighbouring
|
|
-- rows happen to hold the same byte.
|
|
local function snapshot(pool)
|
|
local out = {}
|
|
for row = pool.lyStart, pool.lyEnd - 1 do out[row] = pool.lyBackup[row] end
|
|
return out
|
|
end
|
|
local function rotatedBy(before, pool, steps)
|
|
for row = pool.lyStart, pool.lyEnd - 1 - steps do
|
|
if pool.lyBackup[row] ~= before[row + steps] then return false end
|
|
end
|
|
return true
|
|
end
|
|
local laid = snapshot(psy)
|
|
psy:playFrame()
|
|
check(rotatedBy(laid, psy, 1), "the first frame of state 1 travels one row")
|
|
local held = snapshot(psy)
|
|
for _ = 1, 3 do psy:playFrame() end
|
|
check(rotatedBy(held, psy, 0), "then three frames pass with it standing still")
|
|
psy:playFrame()
|
|
check(rotatedBy(held, psy, 1), "and it moves again on the fourth")
|
|
|
|
-- Whirlpool rolls VERTICALLY, over both battlers at once.
|
|
local whirl = BgEffects.new(consts, { battleTurn = 0 })
|
|
whirl:queue("BATTLE_BG_EFFECT_WHIRLPOOL", 0, 0, 0)
|
|
whirl:playFrame()
|
|
eq(whirl.lcdc, "SCY", "Whirlpool scrolls vertically")
|
|
eq(whirl.lyEnd, 0x5e, "over the whole screen, not one pic box")
|
|
|
|
-- Surf: the ring is laid down on the first frame, and `.one` refuses to paint
|
|
-- anything until some other effect has opened the LCD-STAT window (which is
|
|
-- what BATTLE_BG_EFFECT_START_WATER is for).
|
|
local surf = BgEffects.new(consts, { battleTurn = 0 })
|
|
surf:queue("BATTLE_BG_EFFECT_SURF", 0, 0, 0)
|
|
surf:playFrame()
|
|
check(surf.surfWave ~= nil, "Surf builds its $40-byte wave ring")
|
|
eq(surf.lyBackup[0x20], 0, "but paints nothing without an LCDC pointer")
|
|
surf.lcdc = "SCY"
|
|
surf.lyStart, surf.lyEnd = 0, 0x36
|
|
surf:playFrame()
|
|
-- At amplitude 2 most rows of the ring round to zero; what matters is that
|
|
-- the window is painted at all.
|
|
local painted = false
|
|
for row = 1, 0x5e do
|
|
if (surf.lyBackup[row] or 0) ~= 0 then painted = true end
|
|
end
|
|
check(painted, "once one is set the water is on screen")
|
|
eq(surf.lyBackup[0], 0, "and the row at lyStart stays flat")
|
|
|
|
-- The three water effects as a set: START opens the window and ends itself,
|
|
-- WATER grows two scanlines a frame until its counter passes $20, END resets.
|
|
local water = BgEffects.new(consts, { battleTurn = 0 })
|
|
water:queue("BATTLE_BG_EFFECT_START_WATER", 0, 0, 0)
|
|
water:playFrame()
|
|
eq(water.lcdc, "SCY", "START_WATER opens a vertical window")
|
|
eq(water:activeCount(), 0, "and frees its struct on the same frame")
|
|
water:queue("BATTLE_BG_EFFECT_WATER", 0x18, 0, 0)
|
|
water:playFrame()
|
|
eq(water.effects[1].turn, 2, "WATER's counter climbs two a frame")
|
|
for _ = 1, 20 do water:playFrame() end
|
|
eq(water:activeCount(), 0, "and it ends once the counter passes $20")
|
|
|
|
-- Double Team's afterimage: alternate rows +n and -n.
|
|
local dt = BgEffects.new(consts, { battleTurn = 0 })
|
|
dt:queue("BATTLE_BG_EFFECT_DOUBLE_TEAM", 0, 1, 0)
|
|
dt:playFrame() -- state 0: open the window
|
|
dt:playFrame() -- state 1: first split, at 0
|
|
dt:playFrame() -- ...and again, at 1
|
|
eq(dt.lyBackup[0x2f], 1, "one copy of the pic goes right")
|
|
eq(dt.lyBackup[0x30], 0xff, "and the row under it goes left by the same")
|
|
for _ = 1, 20 do dt:playFrame() end
|
|
eq(dt.effects[1].jt, 2, "the split stops at $10 and waits for the script")
|
|
|
|
-- Acid Armor scrolls the window down a row a frame with a blank fed in on top.
|
|
local acid = BgEffects.new(consts, { battleTurn = 0 })
|
|
acid:queue("BATTLE_BG_EFFECT_ACID_ARMOR", 0, 1, 4)
|
|
acid:playFrame()
|
|
local sank = acid.lyBackup[0x40]
|
|
acid:playFrame()
|
|
eq(acid.lyBackup[0x41], sank, "the wave sinks a scanline a frame")
|
|
eq(acid.lyBackup[0x2f], 0x90, "with a blank row fed in at the top")
|
|
|
|
-- Wave Deform ramps its amplitude up to $20 and, in state 2, back to nothing.
|
|
local wd = BgEffects.new(consts, { battleTurn = 0 })
|
|
wd:queue("BATTLE_BG_EFFECT_WAVE_DEFORM_MON", 0, 1, 0)
|
|
wd:playFrame()
|
|
for _ = 1, 40 do wd:playFrame() end
|
|
eq(wd.effects[1].param, 0x20, "the ramp stops at $20 rather than wrapping")
|
|
wd:incEffect("BATTLE_BG_EFFECT_WAVE_DEFORM_MON")
|
|
for _ = 1, 0x21 do wd:playFrame() end
|
|
eq(wd:activeCount(), 0, "and the way back down ends the effect")
|
|
|
|
-- The two unused beta send-outs still run to completion rather than sitting
|
|
-- in the pool forever.
|
|
local beta1 = BgEffects.new(consts, { battleTurn = 0 })
|
|
beta1:queue("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1", 0, 1, 0)
|
|
beta1:playFrame()
|
|
eq(beta1.lcdc, "BGP", "the beta send-out writes a PALETTE per scanline")
|
|
beta1:incEffect("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1")
|
|
for _ = 1, 40 do beta1:playFrame() end
|
|
eq(beta1.effects[1].jt, 3, "the first blind pass hands on to the second")
|
|
for _ = 1, 40 do beta1:playFrame() end
|
|
eq(beta1.effects[1].jt, 4, "and that one ends on the script's own state")
|
|
beta1:incEffect("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1")
|
|
beta1:playFrame()
|
|
eq(beta1:activeCount(), 0, "state five frees the struct")
|
|
|
|
local beta2 = BgEffects.new(consts, { battleTurn = 0 })
|
|
beta2:queue("BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2", 0, 1, 0)
|
|
beta2:playFrame()
|
|
eq(beta2.effects[1].turn, 0x40, "MON2 counts down from $40")
|
|
for _ = 1, 0x42 do beta2:playFrame() end
|
|
eq(beta2:activeCount(), 0, "and unwinds to nothing on its own")
|
|
|
|
-- A per-scanline BGP entry reads as a brightness: $00 is white, $ff black,
|
|
-- $e4 the identity.
|
|
local AnimView = require("src.ui.gen2.BattleAnimView")
|
|
eq(AnimView.palVeil(0xe4), 0, "$e4 is no veil at all")
|
|
eq(AnimView.palVeil(0xff), 1, "$ff is solid black")
|
|
eq(AnimView.palVeil(0x00), -1, "$00 is solid white")
|
|
|
|
S.finish()
|