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