Files
gen1recomp/tests/game3_teachy_screen_test.lua
T
bryanthaboi d920f7acdb clean up
2026-09-22 17:05:12 -04:00

503 lines
19 KiB
Lua

#!/usr/bin/env luajit
-- pokefirered/src/teachy_tv.c:450 TeachyTvMainCallback
package.path = "./?.lua;./?/init.lua;" .. package.path
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 Cache = require("tests.game3_cache")
local cacheRoot = Cache.mount("items/pack.lua")
if not cacheRoot then
print("[skip] game3_teachy_screen: " .. tostring(Cache.reason))
os.exit(0)
end
print("[info] FireRed cache at " .. cacheRoot)
local TeachyTv = require("src.core.game3.teachy_tv")
local Ui = require("src.ui.game3.teachy_tv")
local Bag = require("src.core.game3.bag")
local Stack = require("src.ui.game3.stack")
local BagMenu = require("src.ui.game3.bag_menu")
local S = TeachyTv.SCRIPT
local T = TeachyTv.TIMING
local function press(key)
return {
wasPressed = function(_, k) return k == key end,
isDown = function() return false end,
}
end
local function newSession()
return { modData = {}, bag = Bag.new(), party = {}, name = "RED" }
end
local function openWith(session)
Stack.clear()
Ui.show(session, session.bag)
end
local function ticks(n)
for _ = 1, n do Ui.update(1 / 60) end
end
local function runUntil(name, budget)
for _ = 1, budget or 4000 do
if Ui.stepName() == name then return true end
Ui.update(1 / 60)
end
return Ui.stepName() == name
end
print("[test] 1. pokefirered/src/teachy_tv.c:553 the list rows and the cursor")
do
local s = newSession()
openWith(s)
check(Ui.isOpen(), "the TV screen is open")
eq(Ui.state, "list", "it opens on the lesson list")
eq(#Ui.rows(), 5, "five rows without a TM CASE")
eq(Ui.cursor, 1, "the cursor starts on the battle lesson")
Ui.handleInput(press("down"))
Ui.handleInput(press("down"))
eq(Ui.cursor, 3, "down twice walks to the matchups lesson")
Ui.handleInput(press("up"))
eq(Ui.cursor, 2, "up walks back")
local res = TeachyTv.resources(s)
-- pokefirered/src/teachy_tv.c:722 ListMenuGetScrollAndRow
eq(res.selectedRow, 1, "the controller block tracks the row")
for _ = 1, 10 do Ui.handleInput(press("down")) end
eq(Ui.cursor, 5, "the cursor stops on CANCEL")
-- pokefirered/src/teachy_tv.c:734 case -2
Ui.handleInput(press("a"))
check(not Ui.isOpen(), "A on CANCEL closes the TV")
openWith(s)
Ui.handleInput(press("b"))
check(not Ui.isOpen(), "B closes the TV")
Bag.add(s.bag, TeachyTv.ITEM_TM_CASE, 1)
openWith(s)
eq(#Ui.rows(), 7, "six lessons plus CANCEL with a TM CASE")
Ui.handleInput(press("b"))
end
print("[test] 2. pokefirered/src/teachy_tv.c:272 the cluster runs the lesson")
do
local s = newSession()
openWith(s)
for _ = 1, 3 do Ui.handleInput(press("down")) end
eq(Ui.cursor, 4, "the cursor is on the catching lesson")
Ui.handleInput(press("a"))
eq(Ui.state, "lesson", "A starts the lesson")
eq(TeachyTv.whichScript(s), S.CATCHING, "whichScript is TTVSCR_CATCHING")
eq(Ui.stepName(), "transition_render_bg2", "the cluster starts on the transition")
check(#Ui.pages() == 0, "nothing is printed yet")
-- pokefirered/src/teachy_tv.c:759 data[2] > 63
ticks(T.TITLE - 1)
check(not Ui.title, "the title card is not up on frame 63")
ticks(1)
check(Ui.title, "the title card is up on frame 64")
eq(Ui.stepName(), "clear_bg2", "the cluster moved to the clear step")
check(Ui.hostVisible, "the POKé DUDE is on screen")
eq(Ui.hostX, T.DUDE_X_START, "he starts at x=8")
-- pokefirered/src/teachy_tv.c:773 data[2] == 134
ticks(T.CLEAR)
check(not Ui.title, "the title card is cleared after 134 frames")
eq(Ui.stepName(), "npc_move_and_setup_text_printer", "then the dude walks in")
-- pokefirered/src/teachy_tv.c:786 data[2] != 35
ticks(T.NPC_WAIT)
eq(Ui.hostX, T.DUDE_X_START, "after 35 ticks he has not moved yet")
ticks(1)
eq(Ui.hostX, T.DUDE_X_START + 1, "tick 36 is his first step")
-- pokefirered/src/teachy_tv.c:789 x2 == 0x78
ticks(T.DUDE_X_END - T.DUDE_X_START)
eq(Ui.hostX, T.DUDE_X_END, "he stops at x=0x78")
eq(Ui.phase, "hello", "and says hello")
eq(#Ui.pages(), #TeachyTv.pagesOf(TeachyTv.HELLO), "gTeachyTvText_PokedudeSaysHello is printing")
check(Ui.waitingForKey(), "the prompt is up on a \\p page")
for _ = 1, #Ui.pages() - 1 do Ui.handleInput(press("a")) end
check(not Ui.printerActive(), "the hello printer is done")
ticks(4)
eq(Ui.phase, "intro", "the intro text printer took over")
eq(#Ui.pages(), #TeachyTv.introPages(S.CATCHING), "CatchingScript1 is printing")
eq(Ui.page, 1, "on page 1")
Ui.handleInput(press("a"))
eq(Ui.page, 2, "A pages the body")
for _ = 1, #Ui.pages() - 2 do Ui.handleInput(press("a")) end
ticks(2)
-- pokefirered/src/teachy_tv.c:936 TTVcmd_EraseTextWindowIfKeyPressed
eq(Ui.stepName(), "erase_text_window_if_key_pressed", "the cluster waits for a key press")
check(Ui.waitingForKey(), "the prompt stays up while it waits")
check(#Ui.pages() > 0, "the last page is still on screen")
Ui.handleInput(press("a"))
eq(#Ui.pages(), 0, "the key press erased the window")
eq(Ui.stepName(), "start_anim_npc_walk_into_grass", "and the dude walks into the grass")
ticks(1)
eq(Ui.hostFacing, "up", "he faces up")
eq(Ui.stepName(), "dude_move_up", "moving up")
ticks(T.MOVE_UP)
eq(Ui.stepName(), "dude_move_right", "then right")
ticks(T.MOVE_RIGHT)
-- pokefirered/src/teachy_tv.c:1069 TTVcmd_TaskBattleOrFadeByOptionChosen
eq(Ui.stepName(), "battle_or_fade", "then the demonstration")
check(TeachyTv.endsInBattle(S.CATCHING), "the catching lesson ends in a battle")
ticks(1)
-- pokefirered/src/teachy_tv.c:1100 sWhereToReturnToFromBattle
eq(Ui.stepName(), "text_printer_outro", "with no pokedude battle it resumes at step 12")
ticks(2)
eq(Ui.phase, "outro", "CatchingScript2 is printing")
for _ = 1, #Ui.pages() - 1 do Ui.handleInput(press("a")) end
ticks(2)
eq(Ui.stepName(), "erase_text_window_if_key_pressed", "the outro waits for a key press too")
Ui.handleInput(press("a"))
eq(Ui.stepName(), "dude_turn_left", "then he turns left")
ticks(1)
eq(Ui.stepName(), "dude_move_left", "and walks off")
ticks(T.DUDE_X_END - T.DUDE_X_START + 1)
eq(Ui.stepName(), "render_and_remove_bg1_end_graphic", "the end graphic comes up")
ticks(1)
check(Ui.endCard, "the end card is showing")
ticks(T.END_GRAPHIC - 1)
check(not Ui.endCard, "the end card is gone after 127 frames")
eq(Ui.stepName(), "end", "the cluster is on TTVcmd_End")
ticks(T.END)
eq(Ui.state, "list", "the lesson returns to the list")
check(Ui.isOpen(), "the TV is still open")
check(TeachyTv.hasWatched(s, S.CATCHING), "the lesson is marked watched")
Ui.handleInput(press("b"))
end
print("[test] 3. pokefirered/src/teachy_tv.c:808 B aborts back to the list")
do
local s = newSession()
openWith(s)
Ui.handleInput(press("a"))
eq(Ui.state, "lesson", "the battle lesson started")
ticks(T.TITLE + 10)
Ui.handleInput(press("b"))
eq(Ui.state, "lesson", "B runs TTVcmd_End rather than snapping back")
eq(Ui.stepName(), "end", "the cluster jumped to the end command")
check(not Ui.hostVisible, "the dude is hidden")
ticks(T.END)
eq(Ui.state, "list", "and 64 frames later the list is back")
check(not TeachyTv.hasWatched(s, S.BATTLE), "an aborted lesson is not watched")
Ui.handleInput(press("b"))
end
print("[test] 4. pokefirered/src/teachy_tv.c:526 the art missing fallback")
do
Ui.reloadAssets()
local path = Ui.artPath("screen")
check(path:match("teachy_tv/screen%.rgba$") ~= nil, "the border art path is " .. path)
check(Ui.titleArt() == nil, "no title art without the importer key")
check(Ui.chrome() == nil, "no border art means the plain chrome draws")
check(Ui.bg3Art() == nil, "no BG3 map art either")
-- pokefirered/src/teachy_tv.c:122
check(Ui.chromeCutOut() == false, "an absent border is never treated as a cut out")
local f = io.open(path, "rb")
if f then
local bytes = f:read("*a")
f:close()
local at = (80 * 240 + 120) * 4 + 4
print(string.format("[info] the cache already carries %s, centre alpha %s",
path, tostring(bytes and #bytes >= at and bytes:byte(at))))
else
print("[info] no " .. path .. " in this cache yet, the fallback path is the live one")
end
end
print("[test] 5. pokefirered/src/item_menu.c:2162 the pokedude bag")
do
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TM_CASE, 1)
Bag.add(s.bag, 13, 3)
Bag.add(s.bag, 4, 2)
s.registeredItem = nil
openWith(s)
for _ = 1, 5 do Ui.handleInput(press("down")) end
local row = Ui.rows()[Ui.cursor]
eq(row and row.index, S.REGISTER, "the cursor is on the register lesson")
Ui.handleInput(press("a"))
check(runUntil("idle_if_text_printer_active", 800), "the dude walked in and said hello")
eq(Ui.phase, "hello", "gTeachyTvText_PokedudeSaysHello is printing")
for _ = 1, #Ui.pages() - 1 do Ui.handleInput(press("a")) end
ticks(4)
eq(Ui.phase, "intro", "RegisterScript1 took over")
for _ = 1, #Ui.pages() - 1 do Ui.handleInput(press("a")) end
ticks(2)
eq(Ui.stepName(), "erase_text_window_if_key_pressed", "the intro waits for a key press")
Ui.handleInput(press("a"))
-- pokefirered/src/teachy_tv.c:364 sTMsScript skips the grass walk
eq(Ui.stepName(), "battle_or_fade", "the bag cluster goes straight to the hand off")
ticks(1)
-- pokefirered/src/item_menu.c:2166 AddBagItem
check(Ui.suspended, "the TV waits while the bag runs")
check(BagMenu.isOpen(), "the pokedude bag is open")
eq(Stack.top() and Stack.top().id, "teachy_pokedude_bag", "the demo drives it")
eq(Bag.get(s.bag, 13), 1, "one POTION, not the player's three")
eq(Bag.get(s.bag, 14), 1, "one ANTIDOTE")
eq(Bag.get(s.bag, 4), 5, "five POKé BALLS")
eq(Bag.get(s.bag, 3), 1, "one GREAT BALL")
eq(Bag.get(s.bag, 8), 1, "one NEST BALL")
eq(Bag.get(s.bag, TeachyTv.ITEM_TEACHY_TV), 1, "the TEACHY TV")
local demo = Ui.bagDemo
for _ = 1, 500 do demo.update(1 / 60) end
-- pokefirered/src/item_menu.c:2232 gSaveBlock1Ptr->registeredItem
eq(s.registeredItem, TeachyTv.ITEM_TEACHY_TV, "the demo registered the TEACHY TV")
for _ = 1, 400 do demo.update(1 / 60) end
check(demo.finished, "the demo ran to its exit step")
check(not BagMenu.isOpen(), "the pokedude bag closed")
-- pokefirered/src/item_menu.c:2082 RestorePlayerBag
eq(Bag.get(s.bag, 13), 3, "the player's three POTIONS came back")
eq(Bag.get(s.bag, 4), 2, "the player's two POKé BALLS came back")
eq(Bag.get(s.bag, 14), 0, "the fake ANTIDOTE is gone")
eq(Bag.get(s.bag, 3), 0, "the fake GREAT BALL is gone")
eq(s.registeredItem, nil, "the registration was a demonstration only")
eq(Bag.get(s.bag, TeachyTv.ITEM_TM_CASE), 1, "the player's TM CASE is back")
-- pokefirered/src/teachy_tv.c:1100 sWhereToReturnToFromBattle
eq(Ui.state, "lesson", "the TV resumed the lesson")
eq(Ui.stepName(), "text_printer_outro", "at step 9, the outro")
check(not Ui.suspended, "and it is ticking again")
ticks(2)
eq(Ui.phase, "outro", "RegisterScript2 is printing")
Stack.clear()
Ui.open = false
end
print("[test] 6. pokefirered/src/item_menu.c:2061 BackUpPlayerBag keeps the bag intact")
do
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TEACHY_TV, 1)
-- pokefirered/include/constants/items.h:300 ITEM_TM01
Bag.add(s.bag, 289, 1)
-- pokefirered/include/constants/items.h:143
Bag.add(s.bag, 139, 2)
Bag.add(s.bag, 13, 3)
eq(Bag.get(s.bag, TeachyTv.ITEM_TM_CASE), 1, "the TM CASE rides the TM pocket")
local pouch = require("src.core.game3.items_data").ITEM_BERRY_POUCH
eq(Bag.get(s.bag, pouch), 1, "and the BERRY POUCH rides the berry pocket")
TeachyTv.initPokedudeBag(s, S.REGISTER)
-- pokefirered/src/item_menu.c:2167 AddBagItem(ITEM_TM_CASE, 1)
eq(Bag.get(s.bag, TeachyTv.ITEM_TM_CASE), 1, "the pokedude bag holds exactly one TM CASE")
eq(Bag.get(s.bag, pouch), 0, "and no BERRY POUCH")
eq(Bag.get(s.bag, 13), 1, "one POTION, not the player's three")
eq(Bag.get(s.bag, 289), 1, "the player's TM stays in the TM pocket")
-- pokefirered/src/item_menu.c:2082 RestorePlayerBag
TeachyTv.restorePlayerBag(s)
eq(Bag.get(s.bag, TeachyTv.ITEM_TM_CASE), 1, "one TM CASE after the restore")
eq(Bag.get(s.bag, pouch), 1, "one BERRY POUCH after the restore")
eq(Bag.get(s.bag, 13), 3, "the player's three POTIONS")
eq(Bag.get(s.bag, 14), 0, "and none of the pokedude's items")
end
print("[test] 7. pokefirered/src/item_menu.c:2069 the bag pocket and cursor survive the demo")
do
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TEACHY_TV, 1)
Bag.add(s.bag, TeachyTv.ITEM_TM_CASE, 1)
for id = 13, 22 do Bag.add(s.bag, id, 2) end
Stack.clear()
BagMenu.show(s.bag, { session = s, pocket = "ITEMS" })
BagMenu.settle()
for _ = 1, 4 do BagMenu.handleInput(press("down")) end
local wantPocket, wantCursor = BagMenu.pocketIdx, BagMenu.cursor
check(wantCursor > 1, "the player left the ITEMS cursor on row " .. tostring(wantCursor))
BagMenu.close()
openWith(s)
for _ = 1, 5 do Ui.handleInput(press("down")) end
eq(Ui.rows()[Ui.cursor].index, S.REGISTER, "the register lesson is selected")
Ui.handleInput(press("a"))
for _ = 1, 3000 do
if Ui.suspended then break end
Ui.update(1 / 60)
Ui.handleInput(press("a"))
end
check(BagMenu.isOpen(), "the pokedude bag opened")
-- pokefirered/src/item_menu.c:2079 ResetBagCursorPositions
eq(BagMenu.cursor, 1, "the pokedude bag starts on row 1")
for _ = 1, 900 do Ui.bagDemo.update(1 / 60) end
check(Ui.bagDemo.finished, "the demo ran out")
BagMenu.show(s.bag, { session = s })
-- pokefirered/src/item_menu.c:2089 gBagMenuState.pocket = sBackupPlayerBag->pocket
eq(BagMenu.pocketIdx, wantPocket, "the player's pocket came back")
eq(BagMenu.cursor, wantCursor, "and the player's cursor")
BagMenu.close()
Stack.clear()
Ui.open = false
end
print("[test] 8. pokefirered/src/teachy_tv.c:723 SELECT only quits when the bag is not under")
do
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TEACHY_TV, 1)
Stack.clear()
BagMenu.show(s.bag, { session = s })
Ui.show(s, s.bag)
Ui.handleInput(press("select"))
check(Ui.isOpen(), "SELECT is ignored when CB2_BagMenuFromStartMenu is the callback")
Ui.handleInput(press("b"))
BagMenu.close()
Stack.clear()
Ui.show(s, s.bag)
Ui.handleInput(press("select"))
check(not Ui.isOpen(), "SELECT quits when the TV came from the field")
Stack.clear()
end
print("[test] 9. pokefirered/src/teachy_tv.c:957 the grass walk scrolls BG3")
do
local s = newSession()
openWith(s)
Ui.handleInput(press("a"))
for _ = 1, 4000 do
if Ui.stepName() == "dude_move_up" then break end
Ui.update(1 / 60)
Ui.handleInput(press("a"))
end
eq(Ui.stepName(), "dude_move_up", "reached TTVcmd_DudeMoveUp")
local y0, hi0 = Ui.bg3Y, Ui.grassHi
ticks(16)
-- pokefirered/src/teachy_tv.c:961 ChangeBgY(3, 0x100, 2)
eq(Ui.bg3Y, y0 - 16, "BG3 scrolled up one pixel a frame")
-- pokefirered/src/teachy_tv.c:963
eq(Ui.grassHi, hi0 - 1, "the grass counter stepped once in 16 frames")
ticks(T.MOVE_UP - 16)
eq(Ui.stepName(), "dude_move_right", "48 frames later he turns right")
local x0, lo0 = Ui.bg3X, Ui.grassLo
ticks(16)
-- pokefirered/src/teachy_tv.c:981 ChangeBgX(3, 0x100, 1)
eq(Ui.bg3X, x0 + 16, "BG3 scrolled right one pixel a frame")
eq(Ui.grassLo, lo0 + 1, "the low grass counter stepped once")
Ui.handleInput(press("b"))
ticks(T.END + 2)
-- pokefirered/src/teachy_tv.c:1059 ChangeBgX(3, 0x0, 0)
eq(Ui.bg3X, -16, "TTVcmd_End puts BG3 back at its start offset")
eq(Ui.bg3Y, 40, "on both axes")
eq(#Ui.grass, 0, "and the grass objects are gone")
Ui.handleInput(press("b"))
Stack.clear()
end
print("[test] 10. pokefirered/src/tm_case.c:1322 the pokedude TM CASE")
do
local TmCase = require("src.ui.game3.tm_case")
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TEACHY_TV, 1)
Bag.add(s.bag, TeachyTv.ITEM_TM_CASE, 1)
-- pokefirered/include/constants/items.h:301 ITEM_TM02
Bag.add(s.bag, 290, 1)
Stack.clear()
Ui.show(s, s.bag)
for _ = 1, 4 do Ui.handleInput(press("down")) end
eq(Ui.rows()[Ui.cursor].index, S.TMS, "the TM lesson is selected")
Ui.handleInput(press("a"))
for _ = 1, 3000 do
if Ui.suspended then break end
Ui.update(1 / 60)
Ui.handleInput(press("a"))
end
check(BagMenu.isOpen(), "the pokedude bag opened for the TMs lesson")
for _ = 1, 500 do Ui.bagDemo.update(1 / 60) end
-- pokefirered/src/item_menu.c:2391
check(TmCase.isOpen(), "the bag handed off to the pokedude TM CASE")
check(not BagMenu.isOpen(), "and the bag closed")
eq(Stack.top() and Stack.top().id, "teachy_pokedude_tm_case", "the TM demo drives it")
-- pokefirered/src/tm_case.c:1334 AddBagItem(ITEM_TM01, 1)
eq(#TmCase.list(), #TeachyTv.POKEDUDE_TMS, "it holds the pokedude's four TMs")
eq(Bag.get(s.bag, 290), 0, "the player's own TM is put away")
eq(Bag.get(s.bag, 289), 1, "TM01 is in the case")
local demo = Ui.tmDemo
for _ = 1, 720 do demo.update(1 / 60) end
-- pokefirered/src/tm_case.c:1422
eq(TmCase.mode, "message", "the POKé DUDE starts talking")
eq(TmCase.messageText, TeachyTv.pagesOf(TeachyTv.TM_TYPES)[1], "gPokedudeText_TMTypes page 1")
local pages = #TeachyTv.pagesOf(TeachyTv.TM_TYPES)
for _ = 1, pages do demo.handleInput(press("a")) end
eq(TmCase.mode, "list", "A pages through and hands the list back")
for _ = 1, 720 do demo.update(1 / 60) end
eq(TmCase.mode, "message", "the second lecture starts")
eq(TmCase.messageText, TeachyTv.pagesOf(TeachyTv.TM_DESCRIPTION)[1],
"gPokedudeText_ReadTMDescription page 1")
for _ = 1, #TeachyTv.pagesOf(TeachyTv.TM_DESCRIPTION) do demo.handleInput(press("a")) end
-- pokefirered/src/tm_case.c:1446 tPokedudeState 21
check(demo.finished, "the TM CASE demo ran to its end")
check(not TmCase.isOpen(), "the TM CASE closed")
eq(Bag.get(s.bag, 290), 1, "the player's TM came back")
eq(Bag.get(s.bag, 289), 0, "the pokedude's TMs are gone")
eq(Bag.get(s.bag, TeachyTv.ITEM_TM_CASE), 1, "and still exactly one TM CASE")
-- pokefirered/src/teachy_tv.c:1100 sWhereToReturnToFromBattle
eq(Ui.state, "lesson", "the TV resumed the lesson")
eq(Ui.stepName(), "text_printer_outro", "at step 9, the outro")
Stack.clear()
Ui.open = false
end
print("[test] 11. pokefirered/src/tm_case.c:1357 B aborts the TM CASE demo to the list")
do
local TmCase = require("src.ui.game3.tm_case")
local s = newSession()
Bag.add(s.bag, TeachyTv.ITEM_TEACHY_TV, 1)
Bag.add(s.bag, TeachyTv.ITEM_TM_CASE, 1)
Bag.add(s.bag, 290, 1)
Stack.clear()
Ui.show(s, s.bag)
for _ = 1, 4 do Ui.handleInput(press("down")) end
Ui.handleInput(press("a"))
for _ = 1, 3000 do
if Ui.suspended then break end
Ui.update(1 / 60)
Ui.handleInput(press("a"))
end
for _ = 1, 500 do Ui.bagDemo.update(1 / 60) end
check(TmCase.isOpen(), "the TM CASE is up")
Ui.tmDemo.handleInput(press("b"))
check(not TmCase.isOpen(), "B closed it")
eq(Bag.get(s.bag, 290), 1, "the player's TM came back on the abort path")
-- pokefirered/src/tm_case.c:1361 SetTeachyTvControllerModeToResume
eq(Ui.state, "list", "and the TV is back on its lesson list")
Stack.clear()
Ui.open = false
end
if failed > 0 then
print(string.format("FAILED %d check(s)", failed))
os.exit(1)
end
print("All teachy tv screen checks passed.")