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

435 lines
18 KiB
Lua

-- Text no script pointer reaches: the Day-Care and breeding block, the whole
-- POKeMART conversation, and the Hall of Fame's three flavour strings.
--
-- The extractor's text walker only decodes what some `writetext` names, and
-- all three of these blocks are printed by engine asm instead, so they used to
-- exist in the port only as hand transcriptions at their call sites.
-- RomExtractorGen2's NAMED_TEXT seeds the walker at them by symbol and writes
-- text.labels[label] -> the "bank:addr" key the string landed on; the screens
-- read that and keep their transcriptions as the fallback.
--
-- This pins both sides against each other, the way tests/gen2_phone_test.lua
-- pins the phone tables:
--
-- PINNED is the cart's own decoded characters, one entry per label.
-- Every screen is then driven with a text.lua built out of PINNED, and what
-- it says has to equal what its transcription says -- so a transcription
-- that drifts from the ROM fails here rather than on screen.
-- The cache section at the bottom asserts the real import produced exactly
-- these strings, and SKIPs when there is no Gold cache or when the cache
-- predates the seed.
--
-- ROM-free: `luajit tests/gen2_common_text_test.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
-- The UI modules require love-side helpers at load time. Stub the pieces they
-- touch during construction; nothing here draws.
love = love or {}
love.graphics = love.graphics or {
getColor = function() return 1, 1, 1, 1 end,
setColor = function() end,
rectangle = function() end,
print = function() end,
printf = function() end,
draw = function() end,
newQuad = function() return {} end,
newImage = function() return nil end,
getShader = function() return nil end,
setShader = function() end,
newShader = function() error("no shaders in this harness") end,
getDimensions = function() return 160, 144 end,
push = function() end, pop = function() end,
translate = function() end, scale = function() end,
circle = function() end, clear = function() end,
}
love.math = love.math or { random = function(a, b) return b and a or 1 end }
love.image = love.image or {}
love.filesystem = love.filesystem or {
load = function() return nil end,
getInfo = function() return nil end,
read = function() return nil end,
write = function() return true end,
remove = function() return true end,
}
love.timer = love.timer or { getTime = function() return 0 end }
local S = require("tests.harness").suite("gen2 common text")
local check, eq = S.check, S.eq
require("src.core.Logger").warn = function() end
local CommonText = require("src.core.gen2.CommonText")
local DayCareMenu = require("src.ui.gen2.DayCareMenu")
local HallOfFame = require("src.ui.gen2.HallOfFame")
local MartMenu = require("src.ui.gen2.MartMenu")
-- The cart's own characters, decoded out of the ROM by the extractor's
-- decodeGen2Text: \n is `line` / `next`, \f is `para`, \v is `cont` (the box
-- scrolls one row), {STRBUF} is a TX_RAM name and {NUM} a TX_DECIMAL field.
-- data/text/common_1.asm and common_2.asm, plus the three `db` strings inside
-- engine/events/halloffame.asm.
local PINNED = {
["_DayCareManIntroText"] =
"I'm the DAY-CARE\nMAN. Want me to\vraise a POKéMON?",
["_DayCareManIntroEggText"] =
"I'm the DAY-CARE\nMAN. Do you know\vabout EGGS?\fI was raising\nPOKéMON with my\vwife, you see.\fWe were shocked to\nfind an EGG!\fHow incredible is\nthat?\fSo, want me to\nraise a POKéMON?",
["_DayCareLadyIntroText"] =
"I'm the DAY-CARE\nLADY.\fShould I raise a\nPOKéMON for you?",
["_DayCareLadyIntroEggText"] =
"I'm the DAY-CARE\nLADY. Do you know\vabout EGGS?\fMy husband and I\nwere raising some\vPOKéMON, you see.\fWe were shocked to\nfind an EGG!\fHow incredible\ncould that be?\fShould I raise a\nPOKéMON for you?",
["_WhatShouldIRaiseText"] =
"What should I\nraise for you?",
["_OnlyOneMonText"] =
"Oh? But you have\njust one POKéMON.",
["_CantAcceptEggText"] =
"Sorry, but I can't\naccept an EGG.",
["_RemoveMailText"] =
"Remove MAIL before\nyou come see me.",
["_LastHealthyMonText"] =
"If you give me\nthat, what will\vyou battle with?",
["_IllRaiseYourMonText"] =
"OK. I'll raise\nyour {STRBUF}.",
["_ComeBackLaterText"] =
"Come back for it\nlater.",
["_AreWeGeniusesText"] =
"Are we geniuses or\nwhat? Want to see\vyour {STRBUF}?",
["_YourMonHasGrownText"] =
"Your {STRBUF}\nhas grown a lot.\fBy level, it's\ngrown by {NUM}.\fIf you want your\nPOKéMON back, it\vwill cost ¥{NUM}.",
["_PerfectHeresYourMonText"] =
"Perfect! Here's\nyour POKéMON.",
["_GotBackMonText"] =
"{PLAYER} got back\n{STRBUF}.",
["_BackAlreadyText"] =
"Huh? Back already?\nYour {STRBUF}\fneeds a little\nmore time with us.\fIf you want your\nPOKéMON back, it\vwill cost ¥100.",
["_HaveNoRoomText"] =
"You have no room\nfor it.",
["_NotEnoughMoneyText"] =
"You don't have\nenough money.",
["_OhFineThenText"] =
"Oh, fine then.",
["_ComeAgainText"] =
"Come again.",
["_NotYetText"] =
"Not yet…",
["_FoundAnEggText"] =
"Ah, it's you!\fWe were raising\nyour POKéMON, and\fmy goodness, were\nwe surprised!\fYour POKéMON had\nan EGG!\fWe don't know how\nit got there, but\fyour POKéMON had\nit. You want it?",
["_ReceivedEggText"] =
"{PLAYER} received\nthe EGG!",
["_TakeGoodCareOfEggText"] =
"Take good care of\nit.",
["_IllKeepItThanksText"] =
"Well then, I'll\nkeep it. Thanks!",
["_NoRoomForEggText"] =
"You have no room\nin your party.\vCome back later.",
["_BreedEggHatchText"] =
"{STRBUF} came\nout of its EGG!",
["_BreedAskNicknameText"] =
"Give a nickname to\n{STRBUF}?",
["_LeftWithDayCareManText"] =
"It's {STRBUF}\nthat was left with\vthe DAY-CARE MAN.",
["_LeftWithDayCareLadyText"] =
"It's {STRBUF}\nthat was left with\vthe DAY-CARE LADY.",
["_BreedBrimmingWithEnergyText"] =
"It's brimming with\nenergy.",
["_BreedNoInterestText"] =
"It has no interest\nin {STRBUF}.",
["_BreedAppearsToCareForText"] =
"It appears to care\nfor {STRBUF}.",
["_BreedFriendlyText"] =
"It's friendly with\n{STRBUF}.",
["_BreedShowsInterestText"] =
"It shows interest\nin {STRBUF}.",
["_MartWelcomeText"] =
"Welcome! How may I\nhelp you?",
["_MartAskMoreText"] =
"Can I do anything\nelse for you?",
["_MartComeAgainText"] =
"Please come again!",
["_MartHowManyText"] =
"How many?",
["_MartFinalPriceText"] =
"{NUM} {STRBUF}(S)\nwill be ¥{NUM}.",
["_MartThanksText"] =
"Here you are.\nThank you!",
["_MartNoMoneyText"] =
"You don't have\nenough money.",
["_MartPackFullText"] =
"You can't carry\nany more items.",
["_HerbShopLadyIntroText"] =
"Hello, dear.\fI sell inexpensive\nherbal medicine.\fThey're good, but\na trifle bitter.\fYour POKéMON may\nnot like them.\fHehehehe…",
["_HerbalLadyHowManyText"] =
"How many?",
["_HerbalLadyFinalPriceText"] =
"{NUM} {STRBUF}(S)\nwill be ¥{NUM}.",
["_HerbalLadyThanksText"] =
"Thank you, dear.\nHehehehe…",
["_HerbalLadyPackFullText"] =
"Oh? Your PACK is\nfull, dear.",
["_HerbalLadyNoMoneyText"] =
"Hehehe… You don't\nhave the money.",
["_HerbalLadyComeAgainText"] =
"Come again, dear.\nHehehehe…",
["_BargainShopIntroText"] =
"Hiya! Care to see\nsome bargains?\fI sell rare items\nthat nobody else\fcarries--but only\none of each item.",
["_BargainShopFinalPriceText"] =
"{STRBUF} costs\n¥{NUM}. Want it?",
["_BargainShopThanksText"] =
"Thanks.",
["_BargainShopPackFullText"] =
"Uh-oh, your PACK\nis chock-full.",
["_BargainShopSoldOutText"] =
"You bought that\nalready. I'm all\vsold out of it.",
["_BargainShopNoFundsText"] =
"Uh-oh, you're\nshort on funds.",
["_BargainShopComeAgainText"] =
"Come by again\nsometime.",
["_PharmacyIntroText"] =
"What's up? Need\nsome medicine?",
["_PharmacyHowManyText"] =
"How many?",
["_PharmacyFinalPriceText"] =
"{NUM} {STRBUF}(S)\nwill cost ¥{NUM}.",
["_PharmacyThanksText"] =
"Thanks much!",
["_PharmacyPackFullText"] =
"You don't have any\nmore space.",
["_PharmacyNoMoneyText"] =
"Huh? That's not\nenough money.",
["_PharmacyComeAgainText"] =
"All right.\nSee you around.",
["_NothingToSellText"] =
"You don't have\nanything to sell.",
["_MartSellHowManyText"] =
"How many?",
["_MartSellPriceText"] =
"I can pay you\n¥{NUM}.\fIs that OK?",
["_MartCantBuyText"] =
"Sorry, I can't buy\nthat from you.",
["_MartBoughtText"] =
"Got ¥{NUM} for\n{STRBUF}(S).",
["AnimateHallOfFame.String_NewHallOfFamer"] =
"New Hall of Famer!",
["_HallOfFamePC.TimeFamer"] =
" -Time Famer",
["_HallOfFamePC.HOFMaster"] =
" HOF Master!",
}
-- Text_BreedHuh is the "Huh?" the hatch opens on, and the two empty strings
-- are real: `text_start / done` with nothing between them. They are seeded
-- too, so a label with no body is still a label the cache has.
PINNED["Text_BreedHuh"] = "Huh?\f"
local EMPTY_LABELS = { "_DaycareDummyText", "_BreedClearboxText" }
-- The transcriptions spell the four-tile POKé compression byte as `#`, which
-- is the same sixteen columns the extractor's expansion draws.
local function norm(s) return (tostring(s):gsub("#", "POKé")) end
-- One eq per page: concatenating the lines compares the line count too.
local function samePages(got, want, what)
eq(type(got) == "table" and #got or -1, #want, what .. ": page count")
for i = 1, #want do
eq(table.concat((got or {})[i] or {}, "|"),
norm(table.concat(want[i] or {}, "|")), ("%s: page %d"):format(what, i))
end
end
-- A text.lua shaped like the extractor's output, built out of PINNED so the
-- screens can be driven with it. The keys are made up (nothing reads an
-- address, only text.labels), which is the whole point of the labels table.
local TEXT = { generation = 2, labels = {} }
do
local n = 0
for label, body in pairs(PINNED) do
n = n + 1
local key = ("64:%04x"):format(0x4000 + n)
TEXT.labels[label] = key
TEXT[key] = body
end
end
-- ---- CommonText itself ----------------------------------------------------
do
eq(CommonText.get(TEXT, "_MartHowManyText"), "How many?",
"a label resolves through text.labels to its string")
check(CommonText.get(TEXT, "_NoSuchText") == nil, "an unknown label is nil")
check(CommonText.get({}, "_MartHowManyText") == nil,
"and so is a cache with no labels table at all")
check(CommonText.of(nil, "_MartHowManyText") == nil, "as is no text at all")
-- `line` fills the box's second row, `para` clears it, `cont` scrolls it --
-- so a cont page opens on the previous page's second line.
local pages = CommonText.pages("one\ntwo\vthree\ffour")
eq(#pages, 3, "para and cont each end a page")
eq(table.concat(pages[1], "|"), "one|two", "line is the second row")
eq(table.concat(pages[2], "|"), "two|three", "cont scrolls the row up")
eq(table.concat(pages[3], "|"), "four", "para starts an empty box")
eq(#CommonText.pages("just one line"), 1, "a bare string is one page")
-- The markers are consumed in the order they appear, not by kind: the mart
-- clerk leads with the quantity and the bargain shop with the item name.
local price = CommonText.of(TEXT, "_MartFinalPriceText", { 2, "POTION", 600 })
eq(table.concat(price[1], "|"), "2 POTION(S)|will be ¥600.",
"{NUM} then {STRBUF} then {NUM}")
local bargain = CommonText.of(TEXT, "_BargainShopFinalPriceText",
{ "POTION", 600 })
eq(table.concat(bargain[1], "|"), "POTION costs|¥600. Want it?",
"{STRBUF} then {NUM}")
local egg = CommonText.of(TEXT, "_ReceivedEggText", { player = "GOLD" })
eq(table.concat(egg[1], "|"), "GOLD received|the EGG!", "{PLAYER} by name")
-- A marker with no value left prints nothing, the way the cart's freshly
-- `@`-filled buffer does.
local unset = CommonText.of(TEXT, "_IllRaiseYourMonText", {})
eq(table.concat(unset[1], "|"), "OK. I'll raise|your .",
"an unfilled marker leaves an empty span")
end
-- ---- the Day-Care ---------------------------------------------------------
--
-- The six entries that take arguments, with the values their markers name.
local DAY_CARE_ARGS = {
deposit = { "NICK" },
geniuses = { "NICK" },
hasGrown = { "NICK", 2, 300 },
backAlready = { "NICK" },
gotBack = { "GOLD", "NICK" },
receivedEgg = { "GOLD" },
}
do
local screen = DayCareMenu.new(nil,
{ save = { daycare = {} }, side = "man", text = TEXT })
local seen = 0
for key, label in pairs(DayCareMenu.LABELS) do
seen = seen + 1
check(PINNED[label] ~= nil, label .. " is pinned")
check(rawget(screen.TEXT, key) ~= nil,
key .. " comes from the cache, not from the transcription")
local got, want = screen.TEXT[key], DayCareMenu.TEXT[key]
if type(want) == "function" then
local args = DAY_CARE_ARGS[key]
check(args ~= nil, key .. " has arguments pinned")
got, want = got(unpack(args)), want(unpack(args))
end
samePages(got, want, label)
end
eq(seen, 26, "every Day-Care string the screen says is extracted")
-- With no cache the screen still talks: the transcription is the fallback.
local bare = DayCareMenu.new(nil, { save = { daycare = {} }, side = "man" })
eq(bare.TEXT.comeAgain, DayCareMenu.TEXT.comeAgain,
"and a cache with no labels falls back to the transcription")
end
-- ---- the POKeMART ---------------------------------------------------------
--
-- welcome / askMore / howMany are ONE screenful of lines rather than a list of
-- pages, because a menu sits over them instead of paging them.
local MART_SINGLE = { welcome = true, askMore = true, howMany = true }
local MART_ARGS = { finalPrice = { 2, "POTION", 600 } }
local SELL_ARGS = { price = { 600 }, bought = { "POTION", 600 } }
do
local seen = 0
for kind, labels in pairs(MartMenu.LABELS) do
if kind ~= "SELL" then
local screen = MartMenu.new(nil,
{ martType = kind, text = TEXT, save = { money = 1000 } })
for key, label in pairs(labels) do
seen = seen + 1
check(PINNED[label] ~= nil, label .. " is pinned")
check(rawget(screen.text, key) ~= nil,
("%s.%s comes from the cache"):format(kind, key))
local got, want = screen.text[key], MartMenu.TEXTS[kind][key]
if type(want) == "function" then
got, want = got(unpack(MART_ARGS[key])), want(unpack(MART_ARGS[key]))
elseif MART_SINGLE[key] then
got, want = { got }, { want }
end
samePages(got, want, label)
end
end
end
-- SellMenu's own four, which only MARTTYPE_STANDARD ever reaches.
local standard = MartMenu.new(nil,
{ martType = "STANDARD", text = TEXT, save = { money = 1000 } })
local sell = MartMenu.new(nil, { martType = "STANDARD", save = {} })
for key, label in pairs(MartMenu.LABELS.SELL) do
seen = seen + 1
check(PINNED[label] ~= nil, label .. " is pinned")
check(rawget(standard.sellText, key) ~= nil,
("SELL.%s comes from the cache"):format(key))
local got, want = standard.sellText[key], sell.sellText[key]
if type(want) == "function" then
got, want = got(unpack(SELL_ARGS[key])), want(unpack(SELL_ARGS[key]))
elseif MART_SINGLE[key] then
got, want = { got }, { want }
end
samePages(got, want, label)
end
eq(seen, 33, "every clerk line the screen says is extracted")
end
-- ---- the Hall of Fame -----------------------------------------------------
do
local induct = HallOfFame.headerPlacements("induct", 1, TEXT)
eq(HallOfFame.at(induct, 1, 2),
PINNED["AnimateHallOfFame.String_NewHallOfFamer"],
"the induction header is the cart's own string")
eq(HallOfFame.at(induct, 1, 2), HallOfFame.NEW_FAMER,
"and the transcription says the same thing")
local viewed = HallOfFame.headerPlacements("view", 12, TEXT)
eq(HallOfFame.at(viewed, 1, 2), PINNED["_HallOfFamePC.TimeFamer"],
"the PC's header keeps its four leading spaces")
eq(HallOfFame.at(viewed, 1, 2), HallOfFame.TIME_FAMER, "same transcription")
-- The count is written OVER those spaces at (2,2), which is why they exist.
eq(HallOfFame.at(viewed, 2, 2), " 12", "the win count lands on top of them")
local master = HallOfFame.headerPlacements("view", 100000, TEXT)
eq(HallOfFame.at(master, 1, 2), PINNED["_HallOfFamePC.HOFMaster"],
"and the unreachable HOF Master! title is extracted too")
eq(HallOfFame.at(master, 1, 2), HallOfFame.HOF_MASTER, "same transcription")
eq(HallOfFame.at(HallOfFame.headerPlacements("induct", 1), 1, 2),
HallOfFame.NEW_FAMER, "with no cache the constant is the fallback")
end
-- ---- against the cache ----------------------------------------------------
--
-- The strings above were transcribed from the decomp; this is what says the
-- import agrees. A cache built before the NAMED_TEXT seed has no labels
-- table at all, and needs a re-import rather than a fix here.
do
local cacheDir = os.getenv("GOLD_CACHE")
if not cacheDir then
cacheDir = (os.getenv("HOME") or "") ..
"/Library/Application Support/LOVE/gold-dev/gold"
end
local textFile = loadfile(cacheDir .. "/data/generated/text.lua")
if not textFile then
check(true, "no Gold cache (SKIP)")
else
local text = textFile()
if type(text.labels) ~= "table" then
check(true, "cache predates the NAMED_TEXT seed; re-import (SKIP)")
else
local missing, wrong = {}, {}
for label, want in pairs(PINNED) do
local key = text.labels[label]
if not key then
missing[#missing + 1] = label
elseif text[key] ~= want then
wrong[#wrong + 1] = label
end
end
eq(table.concat(missing, ", "), "", "every seeded label is in the cache")
eq(table.concat(wrong, ", "), "",
"and every string matches the transcription exactly")
for _, label in ipairs(EMPTY_LABELS) do
check(text.labels[label] ~= nil,
label .. " is seeded even though it is empty")
eq(text[text.labels[label]], "", label .. " really is empty")
end
end
end
end
S.finish()