From 2c1e411e3cd8d13688a10f0006fa76ea685e3b00 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Fri, 21 Aug 2026 19:37:41 -0400 Subject: [PATCH 1/2] Print the Gen 2 battle lines the cart actually writes Five messages in the Gen 2 battle code are written by hand rather than taken from data/text/battle.asm, and each has drifted from what the game prints: SuperEffectiveText lost its hyphen and its line break NotVeryEffectiveText ended on three periods, not the ellipsis glyph the charmap carries BattleText_TheresNoPPLeftForThisMove dropped "There's" PlayerHitTimesText/EnemyHitTimesText printed "Hit 3 time(s)!", showing the parenthetical on screen; Gen 1 already prints "Hit 3 times!" via _HitXTimesText StartPerishText printed a sentence no cart prints The break is \n, which is what RomExtractorGen2 decodes the cart's own $4e into, so these read as an extracted line would. Each goes through Strings now, which is what the rest of this file already does with its messages. SpikesText is left alone and the reason is written down beside it: its third row is a `cont`, and engine messages set self.message directly rather than going through showPages, so printMessage would cut the row carrying . --- src/battle/gen2/Battle.lua | 35 ++++++++++++++++++++++++++++------- tests/gen2_battle_ui_test.lua | 2 +- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 9346f9dd..3c7b46b9 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -1234,10 +1234,15 @@ function Battle:dealDamage(attacker, defender, damage, opts) if opts.critical then self:emit({ kind = "message", text = "A critical hit!" }) end + -- SuperEffectiveText / NotVeryEffectiveText (data/text/battle.asm:603,608). + -- The cart breaks both across the box's two lines and hyphenates "super-" + -- to do it, and the not-very line ends on the single ellipsis glyph Gold's + -- charmap carries at $75, not three periods. if opts.effectiveness and opts.effectiveness > 10 then - self:emit({ kind = "message", text = "It's super effective!" }) + self:emit({ kind = "message", text = Strings("It's super-\neffective!") }) elseif opts.effectiveness and opts.effectiveness < 10 then - self:emit({ kind = "message", text = "It's not very effective..." }) + self:emit({ kind = "message", + text = Strings("It's not very\neffective…") }) end if endured then self:emit({ kind = "message", @@ -1391,7 +1396,9 @@ function Battle:useMove(attacker, defender, moveId) if not (charging or rampaging or rolling) then if move and (move.pp or 0) <= 0 then - self:emit({ kind = "message", text = "No PP left for this move!" }) + -- BattleText_TheresNoPPLeftForThisMove (data/text/battle.asm:315). + self:emit({ kind = "message", + text = Strings("There's no PP left\nfor this move!") }) return end if move then move.pp = (move.pp or 1) - 1 end @@ -1747,8 +1754,12 @@ function Battle:useMove(attacker, defender, moveId) landed = landed + 1 end if landed > 1 then - self:emit({ kind = "message", - text = ("Hit %d time(s)!"):format(landed) }) + -- PlayerHitTimesText / EnemyHitTimesText (data/text/battle.asm:749,755) + -- are "Hit @ times!". Gen 2 has no singular form of this line, so the + -- plural stands even at one hit rather than the "(s)" this printed. + -- Gen 1 already says it this way (src/battle/EffectRegistry.lua, + -- _HitXTimesText). + self:emit({ kind = "message", text = Strings("Hit %d times!", landed) }) end -- move_effects/pay_day.asm:13 @@ -1968,8 +1979,11 @@ Battle.MOVE_EFFECTS.EFFECT_PERISH_SONG = function(self) if mine.perish and theirs.perish then return fail(self) end if not mine.perish then mine.perish = Effects.PERISH_TURNS end if not theirs.perish then theirs.perish = Effects.PERISH_TURNS end + -- StartPerishText (data/text/battle.asm:986). What shipped here was a + -- sentence no cart prints; the Gen 2 line names both sides and counts in + -- digits. self:emit({ kind = "message", - text = "All POKéMON hearing the song will faint in three turns!" }) + text = Strings("Both POKéMON will\nfaint in 3 turns!") }) end -- BattleCommand_Encore: 3-6 turns locked into the move the target last used. @@ -2099,6 +2113,11 @@ Battle.MOVE_EFFECTS.EFFECT_SPIKES = function(self, attacker, defender) local side = self:sideOf(defender) if self.spikes[side] then return fail(self) end self.spikes[side] = true + -- SpikesText (data/text/battle.asm:974) is three rows, the third scrolled + -- (`cont`) and carrying . The battle message path has no `cont`: + -- src/ui/gen2/BattleState.lua sets self.message straight from the event and + -- printMessage cuts past two rows, so the cart's line cannot be told here + -- yet without the name being dropped on screen. Left as it stands. self:emit({ kind = "message", text = "Spikes were scattered all around!" }) end @@ -2334,7 +2353,9 @@ Battle.MOVE_EFFECTS.EFFECT_BEAT_UP = function(self, attacker, defender, def) { move = def, moveId = def and def.id }) landed = landed + 1 end - self:emit({ kind = "message", text = ("Hit %d time(s)!"):format(landed) }) + -- BattleCommand_EndLoop prints the same line for Beat Up, and Beat Up can + -- land exactly once, which is the case the cart still prints as "times". + self:emit({ kind = "message", text = Strings("Hit %d times!", landed) }) end -- BattleCommand_Heal (effect_commands.asm:5986): Recover and Rest are both diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index d88c636f..549e84e7 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -175,7 +175,7 @@ do dealt = event.amount end if event.kind == "message" - and event.text == "It's not very effective..." then + and event.text == "It's not very\neffective…" then sawNve = true end end From 61f42cea2bb91abd8cef7993d27bce1e4caa4ed7 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Fri, 21 Aug 2026 19:37:41 -0400 Subject: [PATCH 2/2] Cover the Gen 2 battle lines against pokegold's own labels Drives a real turn per case and compares the emitted line to the label in data/text/battle.asm. Six of the eleven checks fail against the text as it stood. The tail case wraps each changed line through Chrome.wrap at the box's own width and holds it to the two rows printMessage draws. That is the check that caught SpikesText needing a third row, which is why Spikes is not in this change. --- .../engine/gen2_battle_text_matches_cart.lua | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 tests/engine/gen2_battle_text_matches_cart.lua diff --git a/tests/engine/gen2_battle_text_matches_cart.lua b/tests/engine/gen2_battle_text_matches_cart.lua new file mode 100644 index 00000000..ddef31be --- /dev/null +++ b/tests/engine/gen2_battle_text_matches_cart.lua @@ -0,0 +1,197 @@ +-- Five Gen 2 battle messages printed something the cart does not say. Each +-- case below drives a real turn and compares the emitted line against the +-- label in pokegold's data/text/battle.asm: +-- +-- SuperEffectiveText :603 +-- NotVeryEffectiveText :608 +-- BattleText_TheresNoPPLeftForThisMove :315 +-- PlayerHitTimesText / EnemyHitTimesText:749, :755 +-- StartPerishText :986 +-- +-- The marker is the one src/import/RomExtractorGen2.lua decodes the cart's own +-- $4e (`line`) into, so a line written here reads exactly as an extracted one +-- would. The tail case pins the other half of that: printMessage draws at +-- most TEXT_ROWS rows and cuts the rest, so a line has to fit two of them. +-- +-- luajit tests/engine/gen2_battle_text_matches_cart.lua +-- +-- ROM-free: the fixtures below are the extractor's shapes. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +love = require("tests.love_stub") + +local T = require("tests.harness") +local Battle = require("src.battle.gen2.Battle") +local Mon = require("src.battle.gen2.Mon") + +local check = T.check + +-- ---------------------------------------------------------------- fixtures + +local TYPES = { + NORMAL = { id = "NORMAL", index = 0, category = "physical" }, + GROUND = { id = "GROUND", index = 4, category = "physical" }, + ROCK = { id = "ROCK", index = 5, category = "physical" }, + FIRE = { id = "FIRE", index = 20, category = "special" }, + WATER = { id = "WATER", index = 21, category = "special" }, +} + +local MATCHUPS = { + { attacker = "NORMAL", defender = "ROCK", multiplier = 5 }, + { attacker = "WATER", defender = "FIRE", multiplier = 20 }, +} + +local MOVES = { + TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL", + accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" }, + WATER_GUN = { id = "WATER_GUN", name = "WATER GUN", power = 40, + type = "WATER", accuracy = 100, pp = 25, effect = "EFFECT_NORMAL_HIT" }, + DOUBLESLAP = { id = "DOUBLESLAP", name = "DOUBLESLAP", power = 15, + type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_MULTI_HIT" }, + PERISH_SONG = { id = "PERISH_SONG", name = "PERISH SONG", power = 0, + type = "NORMAL", accuracy = 100, pp = 5, effect = "EFFECT_PERISH_SONG" }, +} + +local GROWTH = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, + linear = 100, constant = 140 }, +} + +local function species(id, index, types) + return { id = id, index = index, name = id, + baseStats = { hp = 50, attack = 60, defense = 50, speed = 50, + specialAttack = 50, specialDefense = 50 }, + types = types, catchRate = 255, baseExp = 60, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 31, + levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {} } +end + +local POKEMON = { + growthRates = GROWTH, + CYNDAQUIL = species("CYNDAQUIL", 155, { "FIRE", "FIRE" }), + GEODUDE = species("GEODUDE", 74, { "ROCK", "GROUND" }), +} + +local DATA = { pokemon = POKEMON, moves = MOVES, + type_chart = { types = TYPES, matchups = MATCHUPS }, items = {} } + +local perfect = { attack = 15, defense = 15, speed = 15, special = 15 } +perfect.hp = Mon.hpDV(perfect) + +-- The smallest roll that is neither a critical hit nor a miss. +local function detRandom(n) + if (n or 1) <= 1 then return 0 end + return 1 +end + +-- One turn of the player's move, returning every message line it printed. +local function linesFrom(playerSpecies, playerMoves, wildSpecies, moveId) + local player = Mon.new(DATA, playerSpecies, 20, { dvs = perfect }) + player.moves = playerMoves + local wild = Mon.new(DATA, wildSpecies, 20, { dvs = perfect }) + wild.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } } + local battle = Battle.new({ data = DATA, party = { player }, wild = wild, + random = detRandom }) + local said = {} + for _, event in ipairs(battle:takeTurn({ kind = "move", move = moveId })) do + if event.kind == "message" and event.text then + said[#said + 1] = event.text + end + end + return said +end + +local function saw(said, text) + for _, line in ipairs(said) do + if line == text then return true end + end + return false +end + +local function shown(said) + return "printed: " .. table.concat(said, " | ") +end + +-- ---- the two effectiveness lines ------------------------------------------ +-- Both break across the box's two lines on the cart, and "super-" is +-- hyphenated to make the break. The not-very line ends on the single +-- ellipsis glyph the Gold charmap carries at $75, not on three periods. +do + local said = linesFrom("CYNDAQUIL", + { { id = "TACKLE", pp = 35, maxPp = 35 } }, "GEODUDE", "TACKLE") + check(saw(said, "It's not very\neffective…"), + "NotVeryEffectiveText prints as the cart writes it. " .. shown(said)) + + said = linesFrom("GEODUDE", + { { id = "WATER_GUN", pp = 25, maxPp = 25 } }, "CYNDAQUIL", "WATER_GUN") + check(saw(said, "It's super-\neffective!"), + "SuperEffectiveText keeps its hyphen and its break. " .. shown(said)) +end + +-- ---- a move at zero PP ---------------------------------------------------- +do + -- a second move with PP left, so the turn refuses the empty one rather + -- than falling through to Struggle + local said = linesFrom("CYNDAQUIL", + { { id = "TACKLE", pp = 0, maxPp = 35 }, + { id = "WATER_GUN", pp = 25, maxPp = 25 } }, "GEODUDE", "TACKLE") + check(saw(said, "There's no PP left\nfor this move!"), + "the empty-PP refusal is the cart's sentence. " .. shown(said)) +end + +-- ---- the multi-hit tally -------------------------------------------------- +-- "Hit @ times!" has no singular form on the cart, which is why the "(s)" +-- this used to print is not a hedge the game ever makes. Gen 1 says it the +-- same way (src/battle/EffectRegistry.lua, _HitXTimesText). +do + local said = linesFrom("CYNDAQUIL", + { { id = "DOUBLESLAP", pp = 10, maxPp = 10 } }, "GEODUDE", "DOUBLESLAP") + local tally + for _, line in ipairs(said) do + if line:match("^Hit %d+ times!$") then tally = line end + end + check(tally ~= nil, + "the multi-hit tally reads \"Hit N times!\". " .. shown(said)) + check(not table.concat(said, " "):find("time(s)", 1, true), + "and no line hedges the plural with a parenthetical. " .. shown(said)) +end + +-- ---- Perish Song ---------------------------------------------------------- +-- What shipped here was a sentence no cart prints. StartPerishText names +-- both sides and counts in digits. +do + local said = linesFrom("CYNDAQUIL", + { { id = "PERISH_SONG", pp = 5, maxPp = 5 } }, "GEODUDE", "PERISH_SONG") + check(saw(said, "Both POKéMON will\nfaint in 3 turns!"), + "Perish Song prints StartPerishText. " .. shown(said)) +end + +-- ---- and every one of them fits the box ----------------------------------- +-- printMessage draws Chrome.wrap(self.message, TEXT_WIDTH) and stops at +-- TEXT_ROWS (home/text.asm:143, :397), so a third row is cut rather than +-- spilled. A cart line that needs one, such as SpikesText's `cont` row +-- carrying , cannot be told on this path at all: engine messages set +-- self.message directly (src/ui/gen2/BattleState.lua) instead of going +-- through showPages, so nothing paginates them. Hence Spikes is left alone +-- above, and every line that IS changed is checked to fit here. +do + local Chrome = require("src.ui.gen2.Chrome") + -- TEXT_WIDTH and TEXT_ROWS are file-locals in BattleState, so their values + -- are repeated rather than required. + local BOX_WIDTH, BOX_ROWS = 18, 2 + for _, line in ipairs({ + "It's super-\neffective!", + "It's not very\neffective…", + "There's no PP left\nfor this move!", + "Hit 3 times!", + "Both POKéMON will\nfaint in 3 turns!", + }) do + local rows = #Chrome.wrap(line, BOX_WIDTH) + check(rows <= BOX_ROWS, + ("\"%s\" wraps to %d rows, and the box draws %d") + :format((line:gsub("\n", "\\n")), rows, BOX_ROWS)) + end +end + +T.finish("gen2 battle text matches the cart")