Files
gen1recomp/tests/game3_corner_prize_test.lua
bryanthaboi dee935a4ea CLOSES #2430
2026-09-23 11:58:32 -04:00

516 lines
20 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_items").install()
local GameVersion = require("src.core.GameVersion")
GameVersion.set("firered")
local Prize = require("src.ui.game3.prize_corner")
local Corner = require("src.core.game3.scripting.natives_corner")
local Bag = require("src.core.game3.bag")
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local function eq(a, b, msg)
check(a == b, string.format("%s (%s == %s)", msg, tostring(a), tostring(b)))
end
local PRET_STRINGS = "../pokefirered/src/strings.c"
local PRET_MENU = "../pokefirered/src/script_menu.c"
local PRET_SPECIALS = "../pokefirered/src/field_specials.c"
local PRET_PRIZE_ROOM = "../pokefirered/data/maps/CeladonCity_GameCorner_PrizeRoom/scripts.inc"
local function slurp(path)
local f = io.open(path, "rb")
if not f then return nil end
local d = f:read("*a")
f:close()
return d
end
local strings = slurp(PRET_STRINGS)
local menu = slurp(PRET_MENU)
local prizeRoom = slurp(PRET_PRIZE_ROOM)
-- pokefirered/src/script_menu.c:520
local LIST_SYMBOL = {
[Prize.LIST_POKEMON_PRIZES] = "sMultichoiceList_GameCornerPokemonPrizes",
[Prize.LIST_COIN_PURCHASE] = "sMultichoiceList_GameCornerCoinPurchaseCounter",
[Prize.LIST_TM_PRIZES] = "sMultichoiceList_GameCornerTMPrizes",
[Prize.LIST_BATTLE_ITEM_PRIZES] = "sMultichoiceList_GameCornerBattleItemPrizes",
}
local function listSymbols(listId)
if not menu then return nil end
local decl = "static const struct MenuAction " .. LIST_SYMBOL[listId] .. "[] = {"
local i = menu:find(decl, 1, true)
if not i then return nil end
local stop = menu:find("\n};", i, true)
local body = menu:sub(i + #decl, stop)
-- pokefirered/src/script_menu.c:318
local lg = body:find("#elif defined(LEAFGREEN)", 1, true)
local head = lg and body:sub(1, lg - 1) or body
local tail = lg and body:sub((body:find("#endif", lg, true) or #body)) or ""
local out = {}
for sym in (head .. tail):gmatch("{%s*(g[%w_]+)%s*}") do out[#out + 1] = sym end
return out
end
local function flatten(literal)
local s = literal:gsub("{[^}]*}", " ")
s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
return s
end
local INVENTED_SIX = {
"WIDGET 1,234 COINS", "GIZMO 999 COINS", "DOODAD 12,345 COINS",
"GADGET 40 COINS", "THINGUMMY 7 COINS", "LAST ROW",
}
local INVENTED_TWO = { "WIDGET 1,234 COINS", "LAST ROW" }
local function stringLiteral(sym)
if not strings then return nil end
local pat = "const u8 " .. sym .. "%[%] = _%(\"(.-)\"%);"
return strings:match(pat)
end
print("[test] 1. Every Game Corner prize list is routed to the prize counter")
do
eq(Prize.LIST_POKEMON_PRIZES, 14, "MULTICHOICE_GAME_CORNER_POKEMON_PRIZES")
eq(Prize.LIST_COIN_PURCHASE, 27, "MULTICHOICE_GAME_CORNER_COIN_PURCHASE_COUNTER")
eq(Prize.LIST_TM_PRIZES, 30, "MULTICHOICE_GAME_CORNER_TMPRIZES")
eq(Prize.LIST_BATTLE_ITEM_PRIZES, 41, "MULTICHOICE_GAME_CORNER_BATTLE_ITEM_PRIZES")
for _, id in ipairs({ 14, 27, 30, 41 }) do
check(Prize.isPrizeList(id), "list " .. id .. " is a prize list")
end
check(not Prize.isPrizeList(5), "an ordinary list is left to the generic menu")
check(not Prize.isPrizeList(nil), "a missing list id is not a prize list")
end
print("[test] 2. The column stops match pret's CLEAR_TO byte for byte")
if not (strings and menu) then
print("[skip] no pret checkout at " .. PRET_STRINGS)
else
for listId, columns in pairs(Prize.COLUMNS) do
local syms = listSymbols(listId)
check(syms ~= nil and #syms > 0, "pret declares " .. LIST_SYMBOL[listId])
if syms then
for i, sym in ipairs(syms) do
local lit = stringLiteral(sym)
check(lit ~= nil, sym .. " has a string literal")
if lit then
local stop = lit:match("{CLEAR_TO 0x(%x+)}")
eq(columns[i], stop and tonumber(stop, 16) or nil,
string.format("list %d row %d column stop (%s)", listId, i, sym))
end
end
eq(#syms, #columns + 1, "list " .. listId .. " ends on a row with no price")
end
end
end
print("[test] 3. The name and the price split out of the flattened label")
if not (strings and menu) then
print("[skip] no pret checkout at " .. PRET_STRINGS)
else
for listId, columns in pairs(Prize.COLUMNS) do
local syms = listSymbols(listId)
if syms then
local labels = {}
for i, sym in ipairs(syms) do labels[i] = flatten(stringLiteral(sym) or "") end
local rows = Prize.buildRows(listId, labels)
eq(#rows, #syms, "list " .. listId .. " built every row")
for i, sym in ipairs(syms) do
local lit = stringLiteral(sym) or ""
local priceSrc = lit:match("{CLEAR_TO 0x%x+}(.*)$")
if priceSrc then
local expect = flatten(priceSrc)
eq(rows[i].amount, expect, string.format("list %d row %d price (%s)", listId, i, sym))
local nameSrc = lit:match("^(.-){CLEAR_TO")
eq(rows[i].name, flatten(nameSrc or ""), string.format("list %d row %d name (%s)", listId, i, sym))
eq(rows[i].column, columns[i], string.format("list %d row %d keeps its stop", listId, i))
else
eq(rows[i].amount, nil, string.format("list %d row %d has no price (%s)", listId, i, sym))
eq(rows[i].name, flatten(lit), string.format("list %d row %d is the whole label", listId, i))
end
end
end
end
end
print("[test] 4. The coin purchase counter prints its whole row in the small font")
if not strings then
print("[skip] no pret checkout at " .. PRET_STRINGS)
else
local lit = stringLiteral("gText_50Coins_1000")
check(lit ~= nil, "pret declares gText_50Coins_1000")
if lit then
check(lit:find("{FONT_SMALL}", 1, true) < lit:find("{CLEAR_TO", 1, true),
"the small font is selected before the column stop")
end
check(Prize.NAME_SMALL[Prize.LIST_COIN_PURCHASE] == true,
"the coin counter name column uses the small font")
check(Prize.NAME_SMALL[Prize.LIST_TM_PRIZES] == nil,
"the TM prize name column uses the normal font")
end
print("[test] 5. Window size and position are pret's")
if not menu then
print("[skip] no pret checkout at " .. PRET_MENU)
else
local heights = {}
local body = menu:match("static u8 GetMCWindowHeight%(u8 count%)%s*{(.-)\n}")
check(body ~= nil, "pret declares GetMCWindowHeight")
if body then
for c, h in body:gmatch("case (%d+):%s*return (%d+);") do
heights[tonumber(c)] = tonumber(h)
end
for c, h in pairs(heights) do
eq(Prize.WINDOW_HEIGHT[c], h, "window height for " .. c .. " rows")
end
end
local rows = Prize.buildRows(Prize.LIST_TM_PRIZES, INVENTED_SIX)
local widest = 0
for _, row in ipairs(rows) do
local w = Prize.rowWidth(row)
if w > widest then widest = w end
end
-- pokefirered/src/script_menu.c:736
local expectWidth = math.floor((widest + 9) / 8) + 1
local g = Prize.layout(Prize.LIST_TM_PRIZES, rows, 11, 0)
eq(g.width, expectWidth, "the window is as wide as pret makes it")
eq(g.height, Prize.WINDOW_HEIGHT[6], "six rows give pret's height")
-- pokefirered/src/script_menu.c:1195
eq(g.tileX, g.left + 1, "the window is one tile right of the script operand")
eq(g.tileY, 1, "the window is one tile below the script operand")
local wide = Prize.layout(Prize.LIST_TM_PRIZES, rows, 26, 0)
check(wide.left + wide.width <= 28, "a window that would run off screen is pulled left")
eq(wide.left, 28 - wide.width, "pret's clamp is 28 tiles")
end
print("[test] 6. Only a list of more than three rows wraps around")
if not menu then
print("[skip] no pret checkout at " .. PRET_MENU)
else
local at = menu:find("static void CreateMCMenuInputHandlerTask(u8 ignoreBpress, u8 count, u8 windowId, u8 mcId)\n{", 1, true)
local body = at and menu:sub(at, menu:find("\n}", at, true))
check(body ~= nil and body:find("if (count > 3)", 1, true) ~= nil
and body:find("tWrapAround = TRUE", 1, true) ~= nil,
"pret wraps only above three rows")
check(Prize.wrapsAround(6), "the six row prize lists wrap")
check(not Prize.wrapsAround(3), "the three row coin counter does not wrap")
Prize.show({ listId = Prize.LIST_COIN_PURCHASE, left = 13, top = 0,
labels = { "1,111 COINS \194\1652,222", "3,333 COINS \194\1654,444", "LAST ROW" } })
eq(Prize.cursor, 1, "the coin counter opens on the first row")
Prize.move(-1)
eq(Prize.cursor, 1, "up on the first row of a short list does nothing")
Prize.move(1)
Prize.move(1)
eq(Prize.cursor, 3, "the cursor walks down")
Prize.move(1)
eq(Prize.cursor, 3, "down on the last row of a short list does nothing")
Prize.reset()
Prize.show({ listId = Prize.LIST_TM_PRIZES, left = 11, top = 0, labels = INVENTED_SIX })
Prize.move(-1)
eq(Prize.cursor, 6, "a six row list wraps to the bottom")
Prize.move(1)
eq(Prize.cursor, 1, "and back to the top")
Prize.reset()
end
print("[test] 7. The counter reports pret's selection and pret's cancel value")
do
local picked
Prize.show({ listId = Prize.LIST_POKEMON_PRIZES, left = 11, top = 0,
labels = INVENTED_SIX,
onChoose = function(sel) picked = sel end })
check(Prize.isOpen(), "the counter is open")
Prize.move(1)
Prize.move(1)
Prize.confirm()
eq(picked, 2, "A reports the zero based row")
check(not Prize.isOpen(), "the counter closed")
picked = nil
Prize.show({ listId = Prize.LIST_POKEMON_PRIZES, left = 11, top = 0,
labels = INVENTED_TWO,
onChoose = function(sel) picked = sel end })
Prize.cancel()
eq(picked, Prize.SCR_MENU_CANCEL, "B reports SCR_MENU_CANCEL")
eq(Prize.SCR_MENU_CANCEL, 127, "SCR_MENU_CANCEL is 127")
picked = nil
Prize.show({ listId = Prize.LIST_POKEMON_PRIZES, left = 11, top = 0,
labels = INVENTED_TWO, ignoreBPress = true,
onChoose = function(sel) picked = sel end })
Prize.cancel()
eq(picked, nil, "B is ignored when the script asks for it")
check(Prize.isOpen(), "the counter stays open")
Prize.reset()
end
print("[test] 8. The prize list blocks the script through the multichoice opcode")
do
local Flags = require("src.core.game3.scripting.flags")
local Vm = require("src.core.game3.scripting.vm")
local Adapters = require("src.core.game3.scripting.adapters")
local VAR_RESULT = 0x800D
local store = Flags.newStore()
local vm = Vm.new({
store = store,
scripts = {
t = {
{ op = "multichoice", [1] = 11, [2] = 0, [3] = Prize.LIST_TM_PRIZES, [4] = 0 },
{ op = "copyvar", [1] = 0x4002, [2] = VAR_RESULT },
{ op = "setvar", [1] = 0x4001, [2] = 9 },
{ op = "end" },
},
},
adapters = Adapters.host(nil, nil, nil),
})
vm:start("t")
check(Prize.isOpen(), "the TM prize list opened the prize counter")
eq(vm.ctx.status, "waiting", "the script is blocked on the list")
eq(Flags.getVar(store, vm.ctx, 0x4001), 0, "the script has not run past the list")
vm:tick()
check(Prize.isOpen(), "the script stays blocked while the list is open")
Prize.move(1)
Prize.confirm()
vm:tick()
eq(Flags.getVar(store, vm.ctx, 0x4002), 1, "the chosen row landed in VAR_RESULT")
eq(Flags.getVar(store, vm.ctx, 0x4001), 9, "the script resumed")
end
print("[test] 9. A purchase debits coins, and a short purse and a full bag refuse")
do
local Flags = require("src.core.game3.scripting.flags")
local Vm = require("src.core.game3.scripting.vm")
local Adapters = require("src.core.game3.scripting.adapters")
local Runtime = require("src.core.game3.runtime")
local ItemsData = require("src.core.game3.items_data")
local VAR_RESULT = 0x800D
local VAR_TEMP_1, VAR_TEMP_2, VAR_TEMP_3 = 0x4001, 0x4002, 0x4003
-- pokefirered/include/constants/items.h:205
local ITEM_SMOKE_BALL = 194
local SMOKE_BALL_PRICE = 800
-- pokefirered/data/maps/CeladonCity_GameCorner_PrizeRoom/scripts.inc:312
local prizeScript = {
TryGivePrize = {
{ op = "checkcoins", [1] = VAR_RESULT },
{ op = "compare_var_to_var", [1] = VAR_RESULT, [2] = VAR_TEMP_2 },
{ op = "goto_if", [1] = 0, [2] = "NotEnoughCoins" },
{ op = "checkitemspace", [1] = VAR_TEMP_1, [2] = 1 },
{ op = "compare_var_to_value", [1] = VAR_RESULT, [2] = 0 },
{ op = "goto_if", [1] = 1, [2] = "BagFull" },
{ op = "removecoins", [1] = VAR_TEMP_2 },
{ op = "additem", [1] = VAR_TEMP_1, [2] = 1 },
{ op = "setvar", [1] = VAR_TEMP_3, [2] = 1 },
{ op = "end" },
},
NotEnoughCoins = { { op = "setvar", [1] = VAR_TEMP_3, [2] = 2 }, { op = "end" } },
-- pokefirered/data/maps/CeladonCity_GameCorner_PrizeRoom/scripts.inc:324
BagFull = { { op = "setvar", [1] = VAR_TEMP_3, [2] = 3 }, { op = "end" } },
}
local prevSession = Runtime.session
local function buy(session, item, price)
Runtime.session = session
local store = Flags.newStore()
store.vars[VAR_TEMP_1] = item
store.vars[VAR_TEMP_2] = price
local vm = Vm.new({ store = store, scripts = prizeScript, adapters = Adapters.host(nil, nil, nil) })
vm:start("TryGivePrize")
for _ = 1, 12 do vm:tick() end
return Flags.getVar(store, vm.ctx, VAR_TEMP_3)
end
local rich = { coins = SMOKE_BALL_PRICE, bag = Bag.new() }
eq(buy(rich, ITEM_SMOKE_BALL, SMOKE_BALL_PRICE), 1, "the purchase runs to the end of the script")
eq(Bag.Coins.get(rich), 0, "removecoins took exactly the price")
eq(Bag.get(rich.bag, ITEM_SMOKE_BALL), 1, "the prize landed in the bag")
local short = { coins = SMOKE_BALL_PRICE - 1, bag = Bag.new() }
eq(buy(short, ITEM_SMOKE_BALL, SMOKE_BALL_PRICE), 2, "one coin short takes the NotEnoughCoins branch")
eq(Bag.Coins.get(short), SMOKE_BALL_PRICE - 1, "a short purse loses no coins")
eq(Bag.get(short.bag, ITEM_SMOKE_BALL), 0, "a short purse gets no prize")
-- pokefirered/src/item.c:195 CheckBagHasSpace fails when every slot of the pocket is taken
local full = { coins = SMOKE_BALL_PRICE, bag = Bag.new() }
local id, added = 1, 0
while added < (ItemsData.CAPACITY.ITEMS or 42) do
if id ~= ITEM_SMOKE_BALL and ItemsData.pocketOf(id) == "ITEMS" then
if Bag.add(full.bag, id, 1) then added = added + 1 end
end
id = id + 1
if id > 400 then break end
end
eq(added, ItemsData.CAPACITY.ITEMS, "the ITEMS pocket is filled to pret's capacity")
check(not Bag.canAdd(full.bag, ITEM_SMOKE_BALL, 1), "checkitemspace fails on a full pocket")
eq(buy(full, ITEM_SMOKE_BALL, SMOKE_BALL_PRICE), 3, "a full bag takes the BagFull branch")
eq(Bag.Coins.get(full), SMOKE_BALL_PRICE, "a full bag loses no coins")
eq(Bag.get(full.bag, ITEM_SMOKE_BALL), 0, "a full bag gets no prize")
Runtime.session = prevSession
end
print("[test] 10. Every prize price in the list matches the price the script takes")
if not (strings and menu and prizeRoom) then
print("[skip] no pret checkout at " .. PRET_PRIZE_ROOM)
else
local scriptPrices = {}
for label, price in prizeRoom:gmatch("EventScript_(%w+)::%s*\n%s*%.ifdef FIRERED\n%s*setvar VAR_TEMP_1, [%w_]+\n%s*setvar VAR_TEMP_2, (%d+)") do
scriptPrices[#scriptPrices + 1] = tonumber(price)
end
for label, price in prizeRoom:gmatch("EventScript_(%w+)::%s*\n%s*setvar VAR_TEMP_1, [%w_]+\n%s*setvar VAR_TEMP_2, (%d+)") do
scriptPrices[#scriptPrices + 1] = tonumber(price)
end
local seen = {}
for _, p in ipairs(scriptPrices) do seen[p] = true end
check(next(seen) ~= nil, "the prize room script sets prices")
for _, listId in ipairs({ Prize.LIST_POKEMON_PRIZES, Prize.LIST_TM_PRIZES, Prize.LIST_BATTLE_ITEM_PRIZES }) do
local syms = listSymbols(listId)
if syms then
local labels = {}
for i, sym in ipairs(syms) do labels[i] = flatten(stringLiteral(sym) or "") end
local rows = Prize.buildRows(listId, labels)
for i, row in ipairs(rows) do
if row.amount then
local digits = row.amount:gsub("[^%d]", "")
check(seen[tonumber(digits)] == true,
string.format("list %d row %d price %s is a price the script takes", listId, i, digits))
end
end
end
end
end
print("[test] 11. CheckAddCoins guards the coin case against overflow")
do
eq(Corner.SPECIAL.CheckAddCoins, 0x15E, "CheckAddCoins is special 0x15E")
eq(Corner.MAX_COINS, 9999, "MAX_COINS is 9999")
eq(Corner.checkAddCoins(0, 10), 1, "an empty case takes ten coins")
eq(Corner.checkAddCoins(9989, 10), 1, "a case that ends exactly full takes them")
eq(Corner.checkAddCoins(9990, 10), 0, "one coin of overflow is refused")
eq(Corner.checkAddCoins(9999, 1), 0, "a full case takes nothing")
eq(Corner.checkAddCoins(9999, 0), 1, "adding nothing is always allowed")
end
print("[test] 12. CheckAddCoins is dispatched, and it reads pret's two vars")
do
local Natives = require("src.core.game3.scripting.natives")
local Flags = require("src.core.game3.scripting.flags")
local Vm = require("src.core.game3.scripting.vm")
local Adapters = require("src.core.game3.scripting.adapters")
local VAR_RESULT = 0x800D
local VAR_0x8006 = 0x8006
check(Natives.MODULES["natives_corner"] ~= nil, "natives_corner is loaded by the dispatcher")
local store = Flags.newStore()
local vm = Vm.new({
store = store,
scripts = {
-- pokefirered/data/scripts/obtain_item.inc:197
t = {
{ op = "setvar", [1] = VAR_RESULT, [2] = 9990 },
{ op = "setvar", [1] = VAR_0x8006, [2] = 10 },
{ op = "specialvar", [1] = VAR_RESULT, [2] = Corner.SPECIAL.CheckAddCoins },
{ op = "copyvar", [1] = 0x4003, [2] = VAR_RESULT },
{ op = "end" },
},
u = {
{ op = "setvar", [1] = VAR_RESULT, [2] = 40 },
{ op = "setvar", [1] = VAR_0x8006, [2] = 10 },
{ op = "specialvar", [1] = VAR_RESULT, [2] = Corner.SPECIAL.CheckAddCoins },
{ op = "copyvar", [1] = 0x4004, [2] = VAR_RESULT },
{ op = "end" },
},
},
adapters = Adapters.host(nil, nil, nil),
})
vm:start("t")
for _ = 1, 8 do vm:tick() end
eq(Flags.getVar(store, vm.ctx, 0x4003), 0, "a case that would overflow answers FALSE")
vm:start("u")
for _ = 1, 8 do vm:tick() end
eq(Flags.getVar(store, vm.ctx, 0x4004), 1, "a case with room answers TRUE")
end
print("[test] 13. The special id comes out of pret's def_special order")
if not slurp("../pokefirered/data/specials.inc") then
print("[skip] no pret checkout at ../pokefirered/data/specials.inc")
else
local src = slurp("../pokefirered/data/specials.inc")
local index, found = 0, nil
for line in src:gmatch("[^\n]+") do
local name = line:match("^%s+def_special%s+([%w_]+)")
if name then
if name == "CheckAddCoins" then found = index end
index = index + 1
end
end
eq(found, Corner.SPECIAL.CheckAddCoins, "CheckAddCoins is at pret's def_special index")
local body = slurp(PRET_SPECIALS)
if body then
local fn = body:match("bool8 CheckAddCoins%(void%)%s*{(.-)\n}")
check(fn ~= nil and fn:find("gSpecialVar_Result + gSpecialVar_0x8006 > 9999", 1, true) ~= nil,
"pret compares VAR_RESULT plus VAR_0x8006 against 9999")
end
end
print("[test] 14. The TM clerk buffers a move name, not a move number")
do
local Flags = require("src.core.game3.scripting.flags")
local Vm = require("src.core.game3.scripting.vm")
local Adapters = require("src.core.game3.scripting.adapters")
local Cache = require("tests.game3_cache")
local root = Cache.mount("pokemon/move_names.lua")
if not root then
print("[skip] no extracted cache for move names: " .. tostring(Cache.reason))
else
local Pokemon = require("src.core.game3.pokemon")
Pokemon.install(nil)
-- pokefirered/data/maps/CeladonCity_GameCorner_PrizeRoom/scripts.inc:267
local MOVE_ICE_BEAM = 58
local expect = Pokemon.moveName(MOVE_ICE_BEAM)
check(expect ~= nil and expect ~= tostring(MOVE_ICE_BEAM), "the cache knows move 58 as " .. tostring(expect))
local store = Flags.newStore()
local vm = Vm.new({
store = store,
scripts = {
t = {
{ op = "buffermovename", [1] = 1, [2] = MOVE_ICE_BEAM },
{ op = "end" },
},
},
adapters = Adapters.host(nil, nil, nil),
})
vm:start("t")
local buffered = vm.ctx.stringVars and vm.ctx.stringVars[2]
eq(buffered, expect, "STR_VAR_2 holds the move name")
check(buffered ~= tostring(MOVE_ICE_BEAM), "STR_VAR_2 is not the raw move id")
end
end
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")