-- Crystal's animated front pics: the bitmask decode and the frame sequencer. -- luajit tests/gen2_crystal_anim_test.lua -- -- ROM-free. The fixtures are the shapes the extractor writes into -- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own -- bitmask/frames data so a failure names the bytes it disagrees with. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 crystal anim") local check, eq = S.check, S.eq local MonAnim = require("src.render.MonAnim") -- ---- bitmask decode ------------------------------------------------------- -- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1: -- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the -- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10, -- 11, 13, 15 and 16 in the pic's own column-major order. local BULBASAUR = { tiles = 5, bitmasks = { { 0x60, 0xad, 0x01, 0x00 }, { 0x20, 0xad, 0x01, 0x00 }, }, frames = { { bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } }, { bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } }, }, play = { { 1, 10 }, { 2, 10 }, { 0, 5 } }, idle = { { 2, 5 }, { 0, 5 } }, } local base = MonAnim.tileMap(BULBASAUR, 0) eq(base and #base, 25, "the base picture is 5x5 tiles") eq(base and base[1], 0, "position 0 is tile 0") eq(base and base[25], 24, "position 24 is tile 24") local first = MonAnim.tileMap(BULBASAUR, 1) eq(first and #first, 25, "frame 1 is still 5x5 tiles") local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c, [12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 } local wrong = nil for slot = 1, 25 do local want = REPLACED[slot] or (slot - 1) if first[slot] ~= want then wrong = wrong or slot end end eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions") -- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone. local second = MonAnim.tileMap(BULBASAUR, 2) eq(second and second[6], 0x21, "frame 2 replaces position 5") eq(second and second[7], 6, "and leaves position 6 at its base tile") eq(second and #BULBASAUR.frames[2].tiles, 7, "seven set bits, seven replacement tiles") eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil, "no pic size means no tile map") eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil") -- ---- durations ------------------------------------------------------------ -- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16). eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone") eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12") eq(MonAnim.duration(5, 4), 6, "and 5 to 6") eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte") -- ---- the sequencer -------------------------------------------------------- -- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of -- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm). local LOOPED = { tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } }, idle = { { 2, 2 } }, } local function timeline(data, scene, ticks) local anim = MonAnim.new(data, scene) local out = {} for _ = 1, ticks do anim:update() out[#out + 1] = anim:currentFrame() end return out, anim end local frames, anim = timeline(LOOPED, "battle", 14) eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0", "the looped script plays twice and lands back on the base picture") check(anim:finished(), "and the scene is over after PokeAnim_Finish") -- Setup costs a frame of its own before the script starts, so the first -- animation frame is on tick 2, not tick 1. eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame") -- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10. local ONESHOT = { tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, play = { { 1, 8 } }, idle = { { 2, 2 } }, } eq(table.concat(timeline(ONESHOT, "battle", 11), ","), "0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames") eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","), "0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten") -- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle. local menu = MonAnim.new(ONESHOT, "menu") local seen = {} for tick = 1, 31 do menu:update() seen[tick] = menu:currentFrame() end eq(seen[10], 0, "the entrance script ends on the base picture") local resume for tick = 11, 31 do if seen[tick] ~= 0 then resume = tick break end end eq(resume, 11 + MonAnim.SCENE_WAIT + 1, "eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script") eq(resume and seen[resume], 2, "and the idle script's first frame comes up") check(not menu:finished(), "the menu scene is longer than the battle one") local ran = 0 for tick = 1, 60 do menu:update() if menu:finished() then ran = tick break end end check(ran > 0, "but it does end") -- ../pokecrystal/engine/gfx/pic_animation.asm:65-71 local hofLine = table.concat(timeline(ONESHOT, "hof", 60), ",") eq(hofLine, table.concat(timeline(ONESHOT, "menu", 60), ","), "ANIM_MON_HOF is ANIM_MON_MENU's timeline") check(MonAnim.new(ONESHOT, "hof") ~= nil, "and the scene exists at all") -- ../pokecrystal/gfx/pokemon/bulbasaur/anim.asm, anim_idle.asm -- ../pokecrystal/gfx/pokemon/kadabra/anim.asm, anim_idle.asm local BULBASAUR_ANIM = { tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, play = { { 1, 10 }, { 2, 10 }, { 1, 8 }, { 2, 6 }, { 4, 20 }, { 3, 6 }, { 0, 5 }, { 5, 5 } }, idle = { { 5, 5 }, { 0, 5 }, { 5, 5 } }, } local KADABRA_ANIM = { tiles = 6, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames, play = { { 1, 8 }, { MonAnim.SETREPEAT, 4 }, { 2, 6 }, { 3, 6 }, { MonAnim.DOREPEAT, 2 }, { 1, 12 } }, idle = { { MonAnim.SETREPEAT, 3 }, { 0, 7 }, { 4, 7 }, { MonAnim.DOREPEAT, 1 } }, } local function scene(data, name) local cries = {} local anim = MonAnim.new(data, name, function(kind) cries[#cries + 1] = { kind = kind } end) if not anim then return nil, cries end local out, tick = {}, 0 while not anim:finished() and tick < 600 do tick = tick + 1 local before = #cries anim:update() if #cries > before then cries[#cries].tick = tick end out[tick] = anim:currentFrame() end return out, cries end check(MonAnim.new(BULBASAUR_ANIM, "trade") ~= nil, "ANIM_MON_TRADE exists") check(MonAnim.new(BULBASAUR_ANIM, "evolve") ~= nil, "ANIM_MON_EVOLVE exists") check(MonAnim.new(BULBASAUR_ANIM, "hatch") ~= nil, "ANIM_MON_HATCH exists") local bulbaTrade, bulbaCries = scene(BULBASAUR_ANIM, "trade") eq(#bulbaTrade, 126, "BULBASAUR's trade scene is 126 iterations long") -- ../pokecrystal/engine/gfx/pic_animation.asm:196-215 eq(bulbaTrade[17], 5, "Play2 leaves the idle script's last frame on screen") eq(bulbaTrade[34], 0, "the Play after it does put the base picture back") eq(#bulbaCries, 1, "the trade scene plays exactly one cry") eq(bulbaCries[1] and bulbaCries[1].kind, "cry", "and it is PokeAnim_Cry, not CryNoWait") eq(bulbaCries[1] and bulbaCries[1].tick, 53, "after Idle, Play2, Idle, Play and the eighteen SetWait frames") local kadabraTrade, kadabraCries = scene(KADABRA_ANIM, "trade") eq(#kadabraTrade, 181, "KADABRA's setrepeat/dorepeat idle runs three times") eq(kadabraTrade[45], 4, "and Play2 ends on `frame 4, 07`, its last one") eq(kadabraCries[1] and kadabraCries[1].tick, 109, "with the cry after that") local _, evolveCries = scene(BULBASAUR_ANIM, "evolve") eq(evolveCries[1] and evolveCries[1].kind, "cryNoWait", "ANIM_MON_EVOLVE's cry does not wait") eq(evolveCries[1] and evolveCries[1].tick, 36, "and lands after Idle, Play and the eighteen frames, before Setup, Play") local hatchLine, hatchCries = scene(BULBASAUR_ANIM, "hatch") eq(#hatchLine, 126, "ANIM_MON_HATCH runs the idle script at both ends") eq(hatchCries[1] and hatchCries[1].tick, 18, "its CryNoWait comes straight after the first Play") -- ---- gating --------------------------------------------------------------- eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer") eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil, "and an empty script means no sequencer, which is every Gold species") eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too") S.finish()