-- The trade animation's art (engine/movie/trade_animation.asm, gfx/trade/). -- GOLD_CACHE="..." luajit tests/gen2_trade_gfx_test.lua -- -- The animation itself is pinned in tests/gen2_events_test.lua; what is here -- is the art it draws with, which used to be nothing at all -- src/ui/gen2/ -- TradeAnim.lua drew the shapes those tiles are. Four things are worth -- holding down: that the extractor stage exists AND is called from run(), that -- the nine labels it reads are in the curated manifest set (a missing symbol -- is a silently empty stage), that what it wrote matches ../pokegold/gfx/ -- trade/ byte for byte, and that the screen actually blits it rather than -- carrying dead cache data. -- -- ROM-free. The decomp section SKIPs with no ../pokegold beside the repo and -- the cache section SKIPs (or asks for a re-import) with no Gold cache. package.path = "./?.lua;./?/init.lua;" .. package.path local S = require("tests.harness").suite("gen2 trade gfx") local check, eq = S.check, S.eq love = require("tests.love_stub") local Chrome = require("src.ui.gen2.Chrome") local TradeAnimView = require("src.ui.gen2.TradeAnim") -- TradeGameBoyLZ decompresses to 49 tiles into vTiles2 tile $31: 34 of -- game_boy.2bpp (--remove-duplicates) then 15 of link_cable.2bpp. local SCENE_TILES = 49 local SCENE_SHEET_TILES = 7 local BASE_TILE = 0x31 local GAMEBOY_W, GAMEBOY_H = 6, 8 local TUBE_W, TUBE_H = 12, 3 -- ---- the extractor stage -------------------------------------------------- -- A stage nothing calls writes nothing, and the cache checks below would then -- SKIP forever without anyone noticing, so the call site is read out of the -- source rather than assumed. local extractorSource do local f = assert(io.open("src/import/RomExtractorGen2.lua", "r")) extractorSource = f:read("*a") f:close() end local RomExtractorGen2 = require("src.import.RomExtractorGen2") check(type(RomExtractorGen2.extractTrade) == "function", "RomExtractorGen2:extractTrade exists") check(extractorSource:find("results.trade = self:extractTrade()", 1, true) ~= nil, "and RomExtractorGen2:run calls it") check(extractorSource:find("local STAGE_COUNT = 26", 1, true) ~= nil, "STAGE_COUNT counts the new stage, so the progress bar still ends at 1") -- pokegold.sym, bank $0a. These are also what the cache is checked against -- below, so a manifest that drifts shows up here rather than as a blank -- screen. local SYMBOLS = { TradeGameBoyTilemap = { 0x0a, 0x5713 }, TradeLinkTubeTilemap = { 0x0a, 0x5743 }, TradeArrowRightGFX = { 0x0a, 0x5767 }, TradeArrowLeftGFX = { 0x0a, 0x5777 }, TradeCableGFX = { 0x0a, 0x5787 }, TradeBubbleGFX = { 0x0a, 0x57a7 }, TradeGameBoyLZ = { 0x0a, 0x57e7 }, TradeBallGFX = { 0x0a, 0x5927 }, TradePoofGFX = { 0x0a, 0x5987 }, } do local f = assert(io.open("tools/make_gold_manifest.py", "r")) local manifestSource = f:read("*a") f:close() for label in pairs(SYMBOLS) do check(manifestSource:find('"' .. label .. '"', 1, true) ~= nil, label .. " is in make_gold_manifest.py's REQUIRED_SYMBOLS") end end local Json = require("src.link.Json") do local f = io.open("tools/rom_manifest_gold.json", "r") if not f then check(true, "no tools/rom_manifest_gold.json (SKIP)") else local manifest = Json.decode(f:read("*a")) f:close() local symbols = manifest.symbols or {} for label, location in pairs(SYMBOLS) do local got = symbols[label] if not got then check(false, "the generated manifest carries " .. label) else eq(got[1], location[1], label .. " is in the bank pokegold.sym says") eq(got[2], location[2], label .. " is at the address pokegold.sym says") end end end end -- ---- the decomp ----------------------------------------------------------- local pokegold = "../pokegold" local function readFile(path) local f = io.open(path, "rb") if not f then return nil end local body = f:read("*a") f:close() return body end local decompGameBoy = readFile(pokegold .. "/gfx/trade/game_boy.tilemap") local decompTube = readFile(pokegold .. "/gfx/trade/link_cable.tilemap") if not (decompGameBoy and decompTube) then check(true, "no ../pokegold: the two tilemaps are not pinned (SKIP)") else eq(#decompGameBoy, GAMEBOY_W * GAMEBOY_H, "gfx/trade/game_boy.tilemap is the 6x8 the INCBIN comment says") eq(#decompTube, TUBE_W * TUBE_H, "and link_cable.tilemap is 12x3") local highest = 0 for _, body in ipairs({ decompGameBoy, decompTube }) do for i = 1, #body do local id = body:byte(i) if id > highest then highest = id end end end check(highest < BASE_TILE + SCENE_TILES, "every id in them, and the loose cable ids, index the one 49-tile sheet") end -- ---- the cache ------------------------------------------------------------ -- Same default every other gen2 suite uses, so a run with no GOLD_CACHE set -- still reads the cache instead of skipping silently. local cache = os.getenv("GOLD_CACHE") or ((os.getenv("HOME") or "") .. "/Library/Application Support/LOVE/gold-dev/gold") local function loadCache(name) local chunk = loadfile(cache .. "/data/generated/" .. name .. ".lua") return chunk and chunk() or nil end -- PNG IHDR: width and height are big-endian at bytes 17 and 21. local function pngSize(relative) local png = readFile(cache .. "/" .. relative) if not png then return nil end local function be32(s, i) local a, b, c, d = s:byte(i, i + 3) return ((a * 256 + b) * 256 + c) * 256 + d end return be32(png, 17), be32(png, 21) end local trade = loadCache("trade") if not trade then check(true, "cache predates the Trade animation stage : re-import for gfx/trade (SKIP)") else eq(trade.generation, 2, "trade.lua is a Gen 2 table") eq(trade.tiles, SCENE_TILES, "TradeGameBoyLZ decompressed to 49 tiles") eq(trade.sheetTiles, SCENE_SHEET_TILES, "written 7 across, so nothing pads") eq(trade.baseTile, BASE_TILE, "with the vTiles2 tile the BG ids count from, not 0") eq(trade.image, "assets/generated/trade/scene.png", "the sheet went where the screen looks for it") local w, h = pngSize("assets/generated/trade/scene.png") eq(w, SCENE_SHEET_TILES * 8, "the sheet is 56px wide") eq(h, SCENE_SHEET_TILES * 8, "and 56px tall, which is 49 tiles exactly") local function tilemap(key, width, height, decomp, file) local map = trade[key] if type(map) ~= "table" or type(map.tiles) ~= "table" then check(false, "trade.lua carries the " .. key .. " tilemap") return end eq(map.width, width, key .. " is " .. width .. " tiles across") eq(map.height, height, "and " .. height .. " down") eq(#map.tiles, width * height, "flat, in TradeAnim_CopyBoxFromDEtoHL order") if not decomp then check(true, "no ../pokegold: " .. file .. " is not diffed (SKIP)") return end local mismatch for i = 1, width * height do if map.tiles[i] ~= decomp:byte(i) then mismatch = i break end end check(mismatch == nil, mismatch and (key .. " differs from the decomp at byte " .. mismatch) or ("it is ../pokegold/gfx/trade/" .. file .. " byte for byte")) end tilemap("gameBoy", GAMEBOY_W, GAMEBOY_H, decompGameBoy, "game_boy.tilemap") tilemap("tube", TUBE_W, TUBE_H, decompTube, "link_cable.tilemap") -- The object sheets. ball is 6 tiles and not 8 because gfx/trade/ball.2bpp -- is built with --remove-whitespace: pret's 16x32 PNG holds the blank right -- half of the first wobble frame, which is X-flipped at draw time, and the -- ROM does not carry it. local SHEETS = { { "ball", 6, 1, "trade/ball.png" }, { "poof", 12, 2, "trade/poof.png" }, { "bulge", 2, 1, "trade/bulge.png" }, { "bubble", 4, 2, "trade/bubble.png" }, { "arrows", 2, 1, "trade/arrows.png" }, } for _, sheet in ipairs(SHEETS) do local key, tiles, across, relative = sheet[1], sheet[2], sheet[3], sheet[4] local entry = trade[key] if type(entry) ~= "table" then check(false, "trade.lua carries the " .. key .. " sheet") else eq(entry.image, "assets/generated/" .. relative, key .. " went where the screen looks for it") eq(entry.tiles, tiles, key .. " is " .. tiles .. " tiles") eq(entry.sheetTiles, across, "laid out " .. across .. " across") local pw, ph = pngSize("assets/generated/" .. relative) eq(pw, across * 8, relative .. " is " .. (across * 8) .. "px wide") eq(ph, tiles / across * 8, "and " .. (tiles / across * 8) .. "px tall") end end end -- ---- the way in ----------------------------------------------------------- -- Written data nothing mounts is written data nobody sees, so the loader line -- is read out of the source the same way the extractor's call site was. do local f = assert(io.open("src/core/Game2.lua", "r")) local source = f:read("*a") f:close() check(source:find('self.data.gen2Trade = loadGenerated' .. '("data/generated/trade.lua")', 1, true) ~= nil, "Game2 mounts trade.lua as data.gen2Trade") end do local f = assert(io.open("src/ui/gen2/TradeMenu.lua", "r")) local source = f:read("*a") f:close() check(source:find('Screens.push(game, "Gen2TradeAnim"', 1, true) ~= nil, "and TradeMenu:playAnim still opens the screen that reads it") end -- ---- the screen ----------------------------------------------------------- -- The whole point of the stage: a screen that reads the cache instead of -- drawing shapes. love.graphics.draw is recorded so a blit can be checked -- against the tilemap byte and the OAM offset it came from. local DATA = { pokemon = { DROWZEE = { name = "DROWZEE", dex = 96 }, MACHOP = { name = "MACHOP", dex = 66 } } } local SAVE = { player = { name = "SILVER", id = 999 } } local ROW = { otName = "MIKE", otId = 1234 } local GIVEN = { species = "DROWZEE", otName = "GOLD", otId = 12345 } local RECEIVED = { species = "MACHOP", otName = "MIKE", otId = 1234 } -- A tilemap whose cells are their own index plus the base tile, so a blit's -- expected sheet position is arithmetic rather than a lookup. local function fakeTilemap(width, height) local tiles = {} for index = 0, width * height - 1 do tiles[index + 1] = BASE_TILE + index end return { width = width, height = height, tiles = tiles } end local FAKE = { image = "assets/generated/trade/scene.png", tiles = SCENE_TILES, sheetTiles = SCENE_SHEET_TILES, baseTile = BASE_TILE, gameBoy = fakeTilemap(GAMEBOY_W, GAMEBOY_H), tube = fakeTilemap(TUBE_W, TUBE_H), ball = { image = "assets/generated/trade/ball.png", tiles = 6, sheetTiles = 1 }, poof = { image = "assets/generated/trade/poof.png", tiles = 12, sheetTiles = 2 }, bulge = { image = "assets/generated/trade/bulge.png", tiles = 2, sheetTiles = 1 }, bubble = { image = "assets/generated/trade/bubble.png", tiles = 4, sheetTiles = 2 }, arrows = { image = "assets/generated/trade/arrows.png", tiles = 2, sheetTiles = 1 }, } local function newView(gfx) return TradeAnimView.new({ data = DATA, save = SAVE }, { row = ROW, given = GIVEN, received = RECEIVED, save = SAVE, gfx = gfx, }) end -- Records every draw as { path, quadX, quadY, x, y, sx, sy }. local drawn = {} local realDraw = love.graphics.draw local function record(image, quad, x, y, _r, sx, sy) if type(quad) == "table" and quad.w == 8 then drawn[#drawn + 1] = { path = image and image.path, qx = quad.x, qy = quad.y, x = x, y = y, sx = sx or 1, sy = sy or 1 } end end local function capture(view, body) drawn = {} love.graphics.draw = record local ok, err = pcall(body, view) love.graphics.draw = realDraw check(ok, "the screen draws: " .. tostring(err)) return drawn end local function countFrom(list, path) local n = 0 for _, entry in ipairs(list) do if entry.path == path then n = n + 1 end end return n end do local view = newView(FAKE) -- The tube beat: TradeLinkTubeTilemap at hlcoord 8, 2, home once the -- $a0 scroll has drained. local tube = capture(view, function(v) v:drawTube(0) end) eq(countFrom(tube, "assets/generated/trade/scene.png"), TUBE_W * TUBE_H, "the tube is 36 tile blits, not a rounded rectangle") local first = tube[1] eq(first.x, 64, "cell 0 lands at hlcoord 8, 2") eq(first.y, 16, "which is (64, 16)") eq(first.qx, 0, "reading sheet column 0") eq(first.qy, 0, "of sheet row 0") -- Cell 8 is tile $39: sheet column 8 % 7 = 1, row 1. local ninth = tube[9] eq(ninth.x, 64 + 8 * 8, "cell 8 is eight tiles along") eq(ninth.qx, 8 % SCENE_SHEET_TILES * 8, "reading the sheet column its id says") eq(ninth.qy, math.floor(8 / SCENE_SHEET_TILES) * 8, "and the sheet row") -- A scrolled tube: EnterLinkTube2 moves hSCX, and hSCX scrolls the -- BACKGROUND, so the offset comes off the tube's x. local scrolled = capture(view, function(v) v:drawTube(0x20) end) eq(scrolled[1].x, 64 - 0x20, "the offset slides the whole stamp left") -- The Game Boy: 48 tiles at the pan's own unrolled position. local gb = capture(view, function(v) v:drawGameBoy(24, 16) end) eq(#gb, GAMEBOY_W * GAMEBOY_H, "the Game Boy is its 6x8 tilemap") eq(gb[1].x, 24, "stamped at hlcoord 3, 2") eq(gb[1].y, 16, "in state 0") eq(gb[GAMEBOY_W + 1].y, 24, "the second row is one tile down") eq(gb[GAMEBOY_W + 1].x, 24, "back at the left edge") -- The scene: both Game Boys plus the cable the jumptable ByteFills. The -- second one is a whole 256-pixel wrap further along. local scene = capture(view, function(v) v:drawScene(0) end) eq(#scene, GAMEBOY_W * GAMEBOY_H * 2 + 46, "two Game Boys and the 46 cable cells around them") local far for _, entry in ipairs(scene) do if entry.x == 0x100 + 80 and entry.y == 48 then far = entry end end check(far ~= nil, "the second Game Boy sits at hlcoord 10, 6 of the map after the wrap") -- The ball: .OAMData_TradePokeBall1 is the left half twice, the right one -- X-flipped, centred on the sprite anim's origin. local ball = capture(view, function(v) v:drawBall(80, 68, false) end) eq(#ball, 4, "four OAM entries") eq(ball[1].x, 72, "the first hangs 8 pixels left of the origin") eq(ball[1].y, 60, "and 8 above it") eq(ball[2].sx, -1, "the right half is the same tile X-flipped") eq(ball[2].x, 80 + 8, "whose anchor moves a tile along to compensate") eq(ball[1].qy, 0, "reading tile 0") eq(ball[3].qy, 8, "and tile 1 underneath it") -- The poof and the bubble are 2x2 quadrants mirrored into 32x32. local poof = capture(view, function(v) v:drawPoof(80, 68, 0) end) eq(#poof, 16, ".OAMData_TradePoofBubble is sixteen entries") eq(poof[1].x, 80 - 16, "the top-left quadrant starts two tiles out") eq(poof[1].y, 68 - 16, "in both axes") local flippedX, flippedY, both = 0, 0, 0 for _, entry in ipairs(poof) do if entry.sx < 0 and entry.sy < 0 then both = both + 1 elseif entry.sx < 0 then flippedX = flippedX + 1 elseif entry.sy < 0 then flippedY = flippedY + 1 end end eq(flippedX, 4, "one quadrant X-flipped") eq(flippedY, 4, "one Y-flipped") eq(both, 4, "and one both ways") -- Frame 2 of the poof reads the next four tiles, not the same four. local later = capture(view, function(v) v:drawPoof(80, 68, 4) end) eq(later[1].qy, 2 * 8, "the second frame is four tiles further into the sheet") -- The bulge is one tile mirrored, which is why gfx/trade/cable.png is 8x16. local bulge = capture(view, function(v) v:drawBulge(80, 24, 0) end) eq(#bulge, 4, ".OAMData_TradeTubeBulge is one tile drawn four ways") eq(bulge[1].x, 72, "top-left of a 16x16 centred on the origin") eq(bulge[1].y, 16, "which puts it inside the tube") -- The strip under the pan: the rule, the two trainers and the six arrows. -- Chrome.print is stubbed out because the headless font has no glyphs, and -- what it was handed is worth reading anyway. local printed = {} local realPrint, realPrintRight = Chrome.print, Chrome.printRight Chrome.print = function(text, tx, ty) printed[#printed + 1] = { text = text, x = tx, y = ty } return 0 end Chrome.printRight = function(text, txEnd, ty) printed[#printed + 1] = { text = text, right = txEnd, y = ty } return 0 end local strip = capture(view, function(v) v:drawTubeStrip(true) end) local back = capture(view, function(v) v:drawTubeStrip(false) end) Chrome.print, Chrome.printRight = realPrint, realPrintRight eq(#printed, 6, "the rule and the two trainers, on each pan") eq(#printed[1].text, #("─"):rep(20), "row 0 is SCREEN_WIDTH of the rule") eq(printed[1].y, 0, "on the window's first row") eq(printed[2].text, "SILVER", "wLinkPlayer1Name is the player, which TradeAnimation loads from" .. " wPlayerTrademonSenderName") eq(printed[2].y, 1, "at hlcoord 0, 1") eq(printed[3].text, "MIKE", "wLinkPlayer2Name is the OT") eq(printed[3].right, 20, "right-aligned, which is what `hlcoord 0, 4 / add hl, de` lands on") eq(printed[3].y, 3, "one row up from the hlcoord, across the row boundary") eq(countFrom(strip, "assets/generated/trade/arrows.png"), 6, "six arrows, the ByteFill's own count") eq(strip[1].x, 7 * 8, "the first at hlcoord 7, 2 of the window") eq(strip[1].y, 2 * 8, "on the window's third row") eq(strip[1].qy, 0, "sending draws TradeArrowRightGFX") eq(back[1].qy, 8, "and receiving draws TradeArrowLeftGFX") end -- A cache without the stage still runs the animation: the shapes, not a crash. do local bare = newView(nil) check(bare.gfx == nil, "no gen2Trade in the cache means no sheets") local ok = pcall(function() bare:drawTube(0) bare:drawScene(0) bare:drawBall(80, 68, true) bare:drawPoof(80, 68, 0) bare:drawBulge(80, 24, 0) end) check(ok, "and every routine falls back to the shape it used to draw") end S.finish()