Files
gen1recomp/tests/gen2_trade_gfx_test.lua
bryanthaboi ae6cac89e1 G2 support
2026-08-11 11:53:30 -04:00

435 lines
18 KiB
Lua

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