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

420 lines
18 KiB
Lua

#!/usr/bin/env luajit
package.path = "./?.lua;./?/init.lua;" .. package.path
require("tests.fixture_data.game3_items").install()
local failed = 0
local function check(cond, msg)
if cond then
print("[ok] " .. msg)
else
failed = failed + 1
print("[FAIL] " .. msg)
end
end
local Ctx = require("src.core.game3.scripting.ctx")
local Flags = require("src.core.game3.scripting.flags")
local Std = require("src.core.game3.scripting.stdscripts")
local Natives = require("src.core.game3.scripting.natives")
local Ops = require("src.core.game3.scripting.ops_a")
local Dex = require("src.core.game3.dex")
local Storage = require("src.core.game3.storage")
local store = Flags.newStore()
local keyText = setmetatable({}, {
__index = function(_, key) return require("src.core.game3.scripting.text_ir").fromAscii(key) end,
})
package.loaded["src.core.game3.scripting.space"] = {
store = store,
ensureBundle = function() return { text = keyText } end,
}
local function newCtx()
local ctx = Ctx.new({})
ctx.mode = "bytecode"
ctx.status = "running"
return ctx
end
local function session(tbl)
package.loaded["src.core.game3.runtime"] = {
getSession = function() return tbl end,
isActive = function() return true end,
}
return tbl
end
local a = { log = function() end }
local function exec(ctx, row)
return Ops.dispatch({ ctx = ctx, store = store, adapters = a, setPc = function() end }, row)
end
local function specialvar(ctx, dest, id)
exec(ctx, { op = "specialvar", [1] = dest, [2] = id })
return Flags.getVar(store, ctx, dest)
end
local function getVar(ctx, id) return Flags.getVar(store, ctx, id) end
local function setVar(ctx, id, v) Flags.setVar(store, ctx, id, v) end
local function newDex(caught)
local dex = Dex.new()
for _, sp in ipairs(caught or {}) do Dex.setCaught(dex, sp) end
return dex
end
print("[test] 1. GetPokedexCount fills VAR_0x8005 (seen) and VAR_0x8006 (caught)")
local s = session({
name = "RED",
gender = 0,
money = 3000,
trainerId = 4242,
party = {},
bag = require("src.core.game3.bag").new(),
dex = newDex({ 1, 4, 7, 10, 13, 16, 19, 21, 25, 29, 32 }),
map = "FR_ROUTE_2_EAST_BUILDING",
x = 4,
y = 7,
})
Dex.setSeen(s.dex, 41)
Dex.setSeen(s.dex, 43)
local ctx = newCtx()
setVar(ctx, 0x8004, 0) -- pokefirered/data/maps/Route2_EastBuilding/scripts.inc:13
local nat = specialvar(ctx, 0x800D, Std.SPECIAL.GetPokedexCount)
check(getVar(ctx, 0x8005) == 13, "VAR_0x8005 = 13 seen (got " .. tostring(getVar(ctx, 0x8005)) .. ")")
check(getVar(ctx, 0x8006) == 11, "VAR_0x8006 = 11 caught (got " .. tostring(getVar(ctx, 0x8006)) .. ")")
check(nat == 0, "VAR_RESULT = IsNationalPokedexEnabled = FALSE (got " .. tostring(nat) .. ")")
-- pokefirered/data/maps/Route2_EastBuilding/scripts.inc:12
local function aideBranch(dexTable)
s.dex = dexTable
local c = newCtx()
local jumped = nil
local vm = { ctx = c, store = store, adapters = a,
setPc = function(_, key) jumped = key end }
Ops.dispatch(vm, { op = "setvar", var = 0x8004, value = 0 })
Ops.dispatch(vm, { op = "specialvar", [1] = 0x800D, [2] = Std.SPECIAL.GetPokedexCount })
Ops.dispatch(vm, { op = "compare_var_to_value", var = 0x8006, value = 10 })
Ops.dispatch(vm, { op = "goto_if", cond = 0, target = "Aide_HaventCaughtEnough" })
return jumped, Flags.getVar(store, c, 0x8006)
end
print("[test] 2. the Route 2 aide branch clears REQUIRED_SEEN_MONS with 11 caught")
local jumped, caught = aideBranch(newDex({ 1, 4, 7, 10, 13, 16, 19, 21, 25, 29, 32 }))
check(caught == 11, "VAR_0x8006 = 11 at the gate (got " .. tostring(caught) .. ")")
check(jumped == nil, "no jump to Aide_HaventCaughtEnough, so HM05 is handed over")
print("[test] 3. only 9 caught keeps the aide's gift locked")
local jumped3, caught3 = aideBranch(newDex({ 1, 4, 7, 10, 13, 16, 19, 21, 25 }))
check(caught3 == 9, "VAR_0x8006 = 9 at the gate (got " .. tostring(caught3) .. ")")
check(jumped3 == "Aide_HaventCaughtEnough", "9 caught jumps to the refusal branch")
print("[test] 4. national mode counts the whole national range")
s.dex = newDex({ 1, 4, 7 })
Dex.setCaught(s.dex, 300)
local ctx4 = newCtx()
setVar(ctx4, 0x8004, 1)
specialvar(ctx4, 0x800D, Std.SPECIAL.GetPokedexCount)
check(getVar(ctx4, 0x8006) == 4, "national caught = 4 (got " .. tostring(getVar(ctx4, 0x8006)) .. ")")
local ctx4b = newCtx()
setVar(ctx4b, 0x8004, 0)
specialvar(ctx4b, 0x800D, Std.SPECIAL.GetPokedexCount)
check(getVar(ctx4b, 0x8006) == 3, "kanto caught skips species 300 (got "
.. tostring(getVar(ctx4b, 0x8006)) .. ")")
print("[test] 5. SetSeenMon registers the species in VAR_0x8004")
s.dex = newDex({})
local ctx5 = newCtx()
setVar(ctx5, 0x8004, 138) -- pokefirered/include/constants/species.h:142
Natives.special(ctx5, Std.SPECIAL.SetSeenMon, a)
check(Dex.isSeen(s.dex, 138), "species 138 is seen")
check(not Dex.isCaught(s.dex, 138), "SetSeenMon does not mark it caught")
print("[test] 6. SetHiddenItemFlag sets the flag id in VAR_0x8004")
local ctx6 = newCtx()
setVar(ctx6, 0x8004, 0x3E9)
Natives.special(ctx6, Std.SPECIAL.SetHiddenItemFlag, a)
check(Flags.getFlag(store, ctx6, 0x3E9) == true, "flag 0x3E9 is set")
print("[test] 7. party counts")
s.party = {
{ species = 1, hp = 20, otId = 4242, nickname = "BULBA", otName = "RED" },
{ species = 4, hp = 0, otId = 4242 },
{ species = 7, hp = 11, otId = 999, otName = "BLUE" },
{ species = 0, isEgg = true, hp = 5 },
}
s.party[4].species = 1
local ctx7 = newCtx()
check(specialvar(ctx7, 0x800D, Std.SPECIAL.CalculatePlayerPartyCount) == 4,
"CalculatePlayerPartyCount = 4")
check(specialvar(ctx7, 0x800D, Std.SPECIAL.CountPartyNonEggMons) == 3,
"CountPartyNonEggMons = 3")
setVar(ctx7, 0x8004, 0)
check(specialvar(ctx7, 0x800D, Std.SPECIAL.CountPartyAliveNonEggMons_IgnoreVar0x8004Slot) == 1,
"alive non-egg mons ignoring slot 1 = 1")
setVar(ctx7, 0x8004, 2)
check(specialvar(ctx7, 0x800D, Std.SPECIAL.CountPartyAliveNonEggMons_IgnoreVar0x8004Slot) == 1,
"alive non-egg mons ignoring slot 3 = 1")
print("[test] 8. species / OT / egg queries on the VAR_0x8004 slot")
local ctx8 = newCtx()
setVar(ctx8, 0x8004, 0)
check(specialvar(ctx8, 0x800D, Std.SPECIAL.GetPartyMonSpecies) == 1, "slot 1 species = 1")
setVar(ctx8, 0x8004, 3)
check(specialvar(ctx8, 0x800D, Std.SPECIAL.GetPartyMonSpecies) == 412,
"the egg slot reads back SPECIES_EGG")
Natives.special(ctx8, Std.SPECIAL.IsSelectedMonEgg, a)
check(getVar(ctx8, 0x800D) == 1, "IsSelectedMonEgg = TRUE for the egg")
setVar(ctx8, 0x8004, 0)
Natives.special(ctx8, Std.SPECIAL.IsSelectedMonEgg, a)
check(getVar(ctx8, 0x800D) == 0, "IsSelectedMonEgg = FALSE for BULBASAUR")
check(specialvar(ctx8, 0x800D, Std.SPECIAL.IsMonOTNameNotPlayers) == 0,
"own-OT mon: IsMonOTNameNotPlayers = FALSE")
setVar(ctx8, 0x8004, 2)
check(specialvar(ctx8, 0x800D, Std.SPECIAL.IsMonOTNameNotPlayers) == 1,
"traded mon: IsMonOTNameNotPlayers = TRUE")
check(ctx8.stringVars[1] == "BLUE", "the OT name landed in STR_VAR_1")
print("[test] 9. species-in-party queries")
local ctx9 = newCtx()
setVar(ctx9, 0x8004, 7)
check(specialvar(ctx9, 0x800D, Std.SPECIAL.DoesPlayerPartyContainSpecies) == 1,
"DoesPlayerPartyContainSpecies = TRUE for SQUIRTLE")
check(specialvar(ctx9, 0x800D, Std.SPECIAL.PlayerPartyContainsSpeciesWithPlayerID) == 0,
"the SQUIRTLE is traded, so the player-ID form is FALSE")
setVar(ctx9, 0x8004, 1)
check(specialvar(ctx9, 0x800D, Std.SPECIAL.PlayerPartyContainsSpeciesWithPlayerID) == 1,
"the own-OT BULBASAUR answers the player-ID form")
setVar(ctx9, 0x8004, 25)
check(specialvar(ctx9, 0x800D, Std.SPECIAL.DoesPlayerPartyContainSpecies) == 0,
"no PIKACHU in the party")
print("[test] 10. money")
local ctx10 = newCtx()
setVar(ctx10, 0x8005, 3000)
check(specialvar(ctx10, 0x800D, Std.SPECIAL.IsEnoughForCostInVar0x8005) == 1,
"3000 covers a 3000 cost")
setVar(ctx10, 0x8005, 3001)
check(specialvar(ctx10, 0x800D, Std.SPECIAL.IsEnoughForCostInVar0x8005) == 0,
"3000 does not cover 3001")
setVar(ctx10, 0x8005, 1200)
Natives.special(ctx10, Std.SPECIAL.SubtractMoneyFromVar0x8005, a)
check(s.money == 1800, "money is 1800 after the 1200 charge (got " .. tostring(s.money) .. ")")
print("[test] 11. HasAllKantoMons / HasAllMons exclude Mew")
local full = Dex.new()
for sp = 1, 150 do Dex.setCaught(full, sp) end
s.dex = full
local ctx11 = newCtx()
check(specialvar(ctx11, 0x800D, Std.SPECIAL.HasAllKantoMons) == 1,
"1..150 caught is HasAllKantoMons = TRUE without Mew")
check(specialvar(ctx11, 0x800D, Std.SPECIAL.HasAllMons) == 0,
"HasAllMons still wants the Johto and Hoenn ranges")
Dex.setCaught(full, 149)
full.caught[150] = nil
full.owned[150] = nil
check(specialvar(ctx11, 0x800D, Std.SPECIAL.HasAllKantoMons) == 0,
"one missing Kanto mon makes it FALSE")
print("[test] 12. GetStarterSpecies maps VAR_STARTER_MON")
local ctx12 = newCtx()
Flags.setVar(store, ctx12, 0x4031, 0)
check(specialvar(ctx12, 0x800D, Std.SPECIAL.GetStarterSpecies) == 1, "starter 0 = BULBASAUR")
Flags.setVar(store, ctx12, 0x4031, 1)
check(specialvar(ctx12, 0x800D, Std.SPECIAL.GetStarterSpecies) == 7, "starter 1 = SQUIRTLE")
Flags.setVar(store, ctx12, 0x4031, 2)
check(specialvar(ctx12, 0x800D, Std.SPECIAL.GetStarterSpecies) == 4, "starter 2 = CHARMANDER")
Flags.setVar(store, ctx12, 0x4031, 9)
check(specialvar(ctx12, 0x800D, Std.SPECIAL.GetStarterSpecies) == 1,
"an out-of-range index falls back to index 0")
print("[test] 13. GetRandomSlotMachineId stays inside the cart table")
local Rng = require("src.core.game3.rng")
Rng.SeedRng(0x1234)
local ctx13 = newCtx()
local seen = {}
for _ = 1, 200 do
local id = specialvar(ctx13, 0x800D, Std.SPECIAL.GetRandomSlotMachineId)
seen[id] = (seen[id] or 0) + 1
if id < 0 or id > 5 then
check(false, "slot machine id out of range: " .. tostring(id))
break
end
end
check(seen[0] and seen[0] > 0, "id 0 is the most common draw (11 of 22 table slots)")
check((seen[5] or 0) < (seen[0] or 0), "id 5 is rarer than id 0")
print("[test] 14. BufferBigGuyOrBigGirlString follows the player's gender")
local ctx14 = newCtx()
s.gender = 0
Natives.special(ctx14, Std.SPECIAL.BufferBigGuyOrBigGirlString, a)
check(ctx14.stringVars[1] == "gText_BigGuy", "boy: STR_VAR_1 = gText_BigGuy (got "
.. tostring(ctx14.stringVars[1]) .. ")")
s.gender = 1
Natives.special(ctx14, Std.SPECIAL.BufferBigGuyOrBigGirlString, a)
check(ctx14.stringVars[1] == "gText_BigGirl", "girl: STR_VAR_1 = gText_BigGirl (got "
.. tostring(ctx14.stringVars[1]) .. ")")
s.gender = 0
print("[test] 15. position queries")
local ctx15 = newCtx()
s.map, s.x = "FR_VERMILION_CITY", 23
check(specialvar(ctx15, 0x800D, Std.SPECIAL.IsPlayerLeftOfVermilionSailor) == 1,
"x = 23 in Vermilion is left of the sailor")
s.x = 24
check(specialvar(ctx15, 0x800D, Std.SPECIAL.IsPlayerLeftOfVermilionSailor) == 0,
"x = 24 is not")
s.map = "FR_ONE_ISLAND_HARBOR"
check(specialvar(ctx15, 0x800D, Std.SPECIAL.IsPlayerLeftOfVermilionSailor) == 0,
"another harbour is not Vermilion")
check(specialvar(ctx15, 0x800D, Std.SPECIAL.IsPlayerNotInTrainerTowerLobby) == 1,
"outside the tower lobby = TRUE")
s.map = "FR_TRAINER_TOWER_LOBBY"
check(specialvar(ctx15, 0x800D, Std.SPECIAL.IsPlayerNotInTrainerTowerLobby) == 0,
"inside the tower lobby = FALSE")
s.map = "FR_ROUTE_2_EAST_BUILDING"
print("[test] 16. IsThereRoomInAnyBoxForMorePokemon walks all 14 boxes")
s.storage = Storage.new()
local ctx16 = newCtx()
check(specialvar(ctx16, 0x800D, Std.SPECIAL.IsThereRoomInAnyBoxForMorePokemon) == 1,
"an empty PC has room")
for b = 1, Storage.TOTAL_BOXES_COUNT do
for slot = 1, Storage.IN_BOX_COUNT do
s.storage.boxes[b].mons[slot] = { species = 16, level = 5 }
end
end
check(specialvar(ctx16, 0x800D, Std.SPECIAL.IsThereRoomInAnyBoxForMorePokemon) == 0,
"420 boxed mons leaves no room")
s.storage.boxes[14].mons[30] = nil
check(specialvar(ctx16, 0x800D, Std.SPECIAL.IsThereRoomInAnyBoxForMorePokemon) == 1,
"one free slot in the last box is enough")
print("[test] 17. berry queries")
local Bag = require("src.core.game3.bag")
local ctx17 = newCtx()
check(specialvar(ctx17, 0x800D, Std.SPECIAL.HasAtLeastOneBerry) == 0,
"no Berry Pouch means no berries")
Bag.add(s.bag, 365, 1) -- pokefirered/include/constants/items.h:437
check(specialvar(ctx17, 0x800D, Std.SPECIAL.HasAtLeastOneBerry) == 0,
"an empty Berry Pouch is still FALSE")
Bag.add(s.bag, 133, 1) -- pokefirered/include/constants/items.h:137
check(specialvar(ctx17, 0x800D, Std.SPECIAL.HasAtLeastOneBerry) == 1,
"a CHERI BERRY answers TRUE")
check(specialvar(ctx17, 0x800D, Std.SPECIAL.DoesPartyHaveEnigmaBerry) == 0,
"nobody is holding an ENIGMA BERRY")
s.party[1].heldItem = 175
check(specialvar(ctx17, 0x800D, Std.SPECIAL.DoesPartyHaveEnigmaBerry) == 1,
"a held ENIGMA BERRY answers TRUE")
s.party[1].heldItem = nil
print("[test] 18. IsDodrioInParty and IsBadEggInParty")
local ctx18 = newCtx()
check(specialvar(ctx18, 0x800D, Std.SPECIAL.IsDodrioInParty) == 0, "no DODRIO in the party")
s.party[2].species = 85
check(specialvar(ctx18, 0x800D, Std.SPECIAL.IsDodrioInParty) == 1, "a DODRIO answers TRUE")
s.party[2].species = 4
check(specialvar(ctx18, 0x800D, Std.SPECIAL.IsBadEggInParty) == 0, "no bad egg")
print("[test] 19. ShouldShowBoxWasFullMessage only fires on a box change")
local ctx19 = newCtx()
s.storage.currentBox = 1
Flags.setVar(store, ctx19, 0x4037, 0) -- pokefirered/include/constants/vars.h:105
check(specialvar(ctx19, 0x800D, Std.SPECIAL.ShouldShowBoxWasFullMessage) == 0,
"same box: no box-was-full message")
s.storage.currentBox = 3
check(specialvar(ctx19, 0x800D, Std.SPECIAL.ShouldShowBoxWasFullMessage) == 1,
"the mon spilled into another box: message shown once")
check(Flags.getFlag(store, ctx19, 0x843) == true, "FLAG_SHOWN_BOX_WAS_FULL_MESSAGE is set")
check(specialvar(ctx19, 0x800D, Std.SPECIAL.ShouldShowBoxWasFullMessage) == 0,
"the flag keeps it from firing twice")
print("[test] 20. every new query is bound and nothing logs as unknown")
local unknown = {}
Natives.resetLog()
local logAdapter = { log = function(msg) unknown[#unknown + 1] = msg end }
local ids = {
"GetPokedexCount", "SetSeenMon", "SetHiddenItemFlag", "CalculatePlayerPartyCount",
"CountPartyNonEggMons", "CountPartyAliveNonEggMons_IgnoreVar0x8004Slot",
"DoesPlayerPartyContainSpecies", "PlayerPartyContainsSpeciesWithPlayerID",
"GetPartyMonSpecies", "IsSelectedMonEgg", "IsMonOTNameNotPlayers",
"NameRaterWasNicknameChanged", "GetSelectedMonNicknameAndSpecies",
"IsEnoughForCostInVar0x8005", "SubtractMoneyFromVar0x8005", "HasAllKantoMons",
"HasAllMons", "GetStarterSpecies", "GetRandomSlotMachineId",
"BufferBigGuyOrBigGirlString", "GetPlayerFacingDirection",
"IsPlayerLeftOfVermilionSailor", "IsPlayerNotInTrainerTowerLobby",
"IsBadEggInParty", "IsThereRoomInAnyBoxForMorePokemon", "IsDodrioInParty",
"HasAtLeastOneBerry", "DoesPartyHaveEnigmaBerry", "ShouldShowBoxWasFullMessage",
"GetPCBoxToSendMon", "BufferUnionRoomPlayerName",
}
for _, name in ipairs(ids) do
local id = Std.SPECIAL[name]
if not id then
check(false, name .. " has no Std.SPECIAL id")
else
Natives.special(newCtx(), id, logAdapter)
end
end
check(#unknown == 0, "no handler logged as unknown (got " .. tostring(#unknown) .. ")")
check(Std.SPECIAL.GetPokedexCount == 0xD4, "GetPokedexCount is special 0xD4")
print("[test] 21. a specialvar to another destination leaves VAR_RESULT alone")
-- pokefirered/src/scrcmd.c:109
local ctx21 = newCtx()
setVar(ctx21, 0x800D, 5)
setVar(ctx21, 0x8004, 25)
check(specialvar(ctx21, 0x8008, Std.SPECIAL.DoesPlayerPartyContainSpecies) == 0,
"the answer lands in VAR_0x8008")
check(getVar(ctx21, 0x800D) == 5, "VAR_RESULT is untouched (got "
.. tostring(getVar(ctx21, 0x800D)) .. ")")
-- pokefirered/src/party_menu_specials.c:102
setVar(ctx21, 0x8004, 3)
Natives.special(ctx21, Std.SPECIAL.IsSelectedMonEgg, a)
check(getVar(ctx21, 0x800D) == 1, "IsSelectedMonEgg still writes VAR_RESULT on its own")
-- pokefirered/data/scripts/pkmn_center_nurse.inc:31
print("[test] 22. the nurse rows after the heal stay out of the union-room branch")
local function nurseTail(mapId)
s.map = mapId
local c = newCtx()
local jumped = nil
local vm = { ctx = c, store = store, adapters = a,
setPc = function(_, key) jumped = key end }
Flags.setVar(store, c, 0x800D, 1)
-- pokefirered/data/scripts/pkmn_center_nurse.inc:32
Ops.dispatch(vm, { op = "specialvar", [1] = 0x800D,
[2] = Std.SPECIAL.IsPlayerNotInTrainerTowerLobby })
Ops.dispatch(vm, { op = "compare_var_to_value", var = 0x800D, value = 0 })
Ops.dispatch(vm, { op = "goto_if", cond = 1, target = "ReturnPkmn" })
if jumped then return jumped, Flags.getVar(store, c, 0x8008) end
-- pokefirered/data/scripts/pkmn_center_nurse.inc:34
Ops.dispatch(vm, { op = "specialvar", [1] = 0x800D,
[2] = Std.SPECIAL.BufferUnionRoomPlayerName })
Ops.dispatch(vm, { op = "copyvar", [1] = 0x8008, [2] = 0x800D })
Ops.dispatch(vm, { op = "compare_var_to_value", var = 0x8008, value = 0 })
Ops.dispatch(vm, { op = "goto_if", cond = 1, target = "ReturnPkmn" })
Ops.dispatch(vm, { op = "compare_var_to_value", var = 0x8008, value = 1 })
Ops.dispatch(vm, { op = "goto_if", cond = 1, target = "PlayerWaitingInUnionRoom" })
return jumped, Flags.getVar(store, c, 0x8008)
end
local nurseJump, nurseVar = nurseTail("FR_VIRIDIAN_CITY_POKEMON_CENTER_1F")
check(nurseVar == 0, "VAR_0x8008 = FALSE with no union-room name buffered (got "
.. tostring(nurseVar) .. ")")
check(nurseJump == "ReturnPkmn", "the nurse goes to ReturnPkmn (got "
.. tostring(nurseJump) .. ")")
local lobbyJump = nurseTail("FR_TRAINER_TOWER_LOBBY")
check(lobbyJump == "ReturnPkmn", "the Trainer Tower lobby nurse also returns the mons (got "
.. tostring(lobbyJump) .. ")")
s.map = "FR_ROUTE_2_EAST_BUILDING"
print("[test] 23. an unbound specialvar writes 0 instead of leaving the old value")
local ctx23 = newCtx()
setVar(ctx23, 0x800D, 1)
check(specialvar(ctx23, 0x800D, 0x182) == 0,
"unbound special 0x182 leaves VAR_RESULT at 0 (got " .. tostring(getVar(ctx23, 0x800D)) .. ")")
if failed > 0 then
print("[test] FAILED " .. failed)
os.exit(1)
end
print("[test] all passed")