big bug squash (#261)

This commit is contained in:
bryanthaboi
2026-07-26 13:38:52 -04:00
committed by GitHub
parent a8936e79d6
commit f7695308b7
72 changed files with 7375 additions and 372 deletions
+59 -34
View File
@@ -6,7 +6,8 @@
-- takes it ("I'll take this one, then!") and both balls disappear.
-- Source: scripts/OaksLab.asm OaksLabCharmanderPokeBallText /
-- OaksLabRivalTakePokeBallScript.
-- * Rival (object 1): before starter -> "gramps isn't around"; with
-- * Rival (object 1): before starter -> "go ahead and choose" once Oak
-- has walked you in, else "gramps isn't around" (#218); with
-- starter -> taunt + battle OPP_RIVAL1 with the counter-pick party
-- (player Bulbasaur -> rival Charmander etc., parties 1/2/3 =
-- Squirtle/Bulbasaur/Charmander in data/trainers/parties.asm);
@@ -162,36 +163,45 @@ return {
starterBall("_OaksLabYouWantBulbasaurText", "BULBASAUR", "EVENT_CHOSE_BULBASAUR",
"OAKSLAB_BULBASAUR_POKE_BALL", 6, "OAKSLAB_CHARMANDER_POKE_BALL"),
-- Talking to the rival only ever prints a line: the lab battle is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6;
-- see onStep below), never a talk action. The handler used to fall
-- through from the "looks stronger" taunt straight into start_battle,
-- so talking to Blue at the table launched the rival fight before the
-- player ever stepped onto the trigger (#219). scripts/OaksLab.asm
-- OaksLabText8 branches text only:
-- * got starter, not yet battled -> _OaksLabRivalMyPokemonLooksStronger
-- * already battled -> _OaksLabRivalFedUpWithWaitingText (a Route 22
-- line; normally unreachable since the rival is hidden after the
-- lab battle in OaksLabRivalEndBattleScript)
-- * no starter yet -> the FOLLOWED_OAK pre-starter fork (#218)
TEXT_OAKSLAB_RIVAL = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_STARTER" }, -- 2
{ "jump_if_false", 21 }, -- 3
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 4
{ "jump_if_true", 19 }, -- 5
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" }, -- 6
{ "check_flag", "EVENT_CHOSE_BULBASAUR" }, -- 7
{ "jump_if_false", 11 }, -- 8
{ "start_battle", "trainer", "OPP_RIVAL1", 3 }, -- 9 Charmander
{ "jump", 16 }, -- 10
{ "check_flag", "EVENT_CHOSE_SQUIRTLE" }, -- 11
{ "jump_if_false", 15 }, -- 12
{ "start_battle", "trainer", "OPP_RIVAL1", 2 }, -- 13 Bulbasaur
{ "jump", 16 }, -- 14
{ "start_battle", "trainer", "OPP_RIVAL1", 1 }, -- 15 Squirtle
-- OaksLabRivalEndBattleScript: HealParty + flag, then exit either way
{ "heal_party" }, -- 16
{ "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" }, -- 17
{ "jump", 23 }, -- 18
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" }, -- 19
{ "jump", "end" }, -- 20
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" }, -- 21
{ "jump", "end" }, -- 22
-- win: sulk text then exit; loss: Rival1WinText already played in
-- battle (HandlePlayerBlackOut), so skip straight to the walk-out
{ "jump_if_false", 25 }, -- 23
{ "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" }, -- 24
{ "move_npc_to", 1, 4, 11 }, -- 25
{ "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" }, -- 26
{ "face_player" },
{ "check_flag", "EVENT_GOT_STARTER" },
{ "jump_if_false", "pre_starter" },
{ "check_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" },
{ "jump_if_true", "after_battle" },
-- has a starter, has not fought yet: just the taunt. The battle is
-- the coordinate trigger in onStep, not this talk (#219)
{ "show_text", "_OaksLabRivalMyPokemonLooksStrongerText" },
{ "jump", "end" },
{ "label", "after_battle" },
{ "show_text", "_OaksLabRivalFedUpWithWaitingText" },
{ "jump", "end" },
-- pre-starter fork (scripts/OaksLab.asm OaksLabText8 rival handler):
-- Oak has already escorted you in (three balls on the table) -> he
-- waves you on to choose; not yet escorted (very early game) -> the
-- "Gramps isn't around" line. #218
{ "label", "pre_starter" },
{ "check_flag", "EVENT_FOLLOWED_OAK_INTO_LAB" },
{ "jump_if_false", "gramps_gone" },
{ "show_text", "_OaksLabRivalGoAheadAndChooseText" },
{ "jump", "end" },
{ "label", "gramps_gone" },
{ "show_text", "_OaksLabRivalGrampsIsntAroundText" },
},
},
@@ -213,9 +223,16 @@ return {
-- OaksLabScript8 / OaksLabRivalChallenge)
onStep = function(game, ow, x, y)
local flags = game.save.flags
-- Oak blocks the exit mats (4,11)/(5,11) until you take a starter
-- Oak stops you leaving without a starter at the bookshelf row (cell
-- y == 6): this is the same wYCoord == 6 coordinate script the rival
-- challenge just below uses (scripts/OaksLab.asm, both gated on
-- EVENT_GOT_STARTER), so Oak halts you level with the shelves, not one
-- corridor length later on the exit mat. At y>=6 only the x=4,5
-- corridor is walkable and the up-push keeps the player above y=7, so
-- this only ever fires at y=6 in the corridor -- matching the rival
-- trigger's shape. (#232)
if flags.EVENT_FOLLOWED_OAK_INTO_LAB and not flags.EVENT_GOT_STARTER
and y == 11 and (x == 4 or x == 5) then
and y >= 6 then
ow.runner:run({
{ "show_text", "_OaksLabOakDontGoAwayYetText" },
{ "move_player", "up", 1 },
@@ -254,10 +271,18 @@ return {
-- OaksLabRivalEndBattleScript: heal + flag on win or loss; no blackout
table.insert(rows, { "heal_party" })
table.insert(rows, { "set_flag", "EVENT_BATTLED_RIVAL_IN_OAKS_LAB" })
-- win: sulk text then exit; loss jumps to the walk-out (taunt was
-- already shown in-battle via Rival1WinText)
-- OaksLabRivalEndBattleScript: on WIN, print the "picked the wrong
-- POKéMON!" gloat, then BOTH win and loss print the shared exit line
-- _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON fight to
-- toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- marches out. A loss skips only the gloat (that taunt was already
-- shown in-battle via Rival1WinText), never the exit line (#231). The
-- jump_if_false convergence point is the exit line: base+6 indexes the
-- SmellYouLater row below, so WIN falls IPicked -> SmellYouLater and
-- LOSS jumps straight to SmellYouLater (both then walk-out + hide).
table.insert(rows, { "jump_if_false", base + 6 })
table.insert(rows, { "show_text", "_OaksLabRivalIPickedTheWrongPokemonText" })
table.insert(rows, { "show_text", "_OaksLabRivalSmellYouLaterText" })
table.insert(rows, { "move_npc_to", 1, 4, 11 })
table.insert(rows, { "hide_object", "OAKS_LAB", "OAKSLAB_RIVAL" })
ow.runner:run(rows, { npc = rival })
+86
View File
@@ -395,7 +395,93 @@ M.SS_ANNE_CAPTAINS_ROOM = {
-- MrFujisHouse.asm; the teleport back to his house is a warp)
-- -------------------------------------------------------------------
-- scripts/PokemonTower7F.asm: after each Rocket loses,
-- PokemonTower7FEndBattleScript shows its EndBattle text (EndTrainerBattle),
-- prints its AfterBattle text (DisplayTextID), then
-- PokemonTower7FRocketLeaveMovementScript walks the grunt off toward the
-- (9,16) stairs (MoveSprite) and PokemonTower7FHideNPCScript despawns it
-- (HideObject). Without this the beaten grunts stood in the corridor
-- forever (#200).
local POKEMON_TOWER_7F_ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
after = "_PokemonTower7FRocket1AfterBattleText" },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
after = "_PokemonTower7FRocket2AfterBattleText" },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
after = "_PokemonTower7FRocket3AfterBattleText" },
}
-- PokemonTower7FNPCCoordMovementTable, keyed by the PLAYER's tile ("x,y")
-- at the engagement (both the talk and the sight walk-up leave the player on
-- the tile the vanilla table indexes). Each list is that entry's
-- NPC_MOVEMENT_* exit, applied from the grunt's current tile. The exact
-- tables are one per grunt (dbmapcoord bytes decoded back to x,y).
local POKEMON_TOWER_7F_EXITS = {
[1] = { -- ROCKET1 @ (9,11), faces RIGHT
["9,12"] = { "right", "down", "down", "down", "down", "down", "left" },
["10,11"] = { "down", "right", "down", "down", "down", "down" },
["11,11"] = { "down", "down", "down", "down", "down" },
["12,11"] = { "down", "down", "down", "down", "down" },
},
[2] = { -- ROCKET2 @ (12,9), faces LEFT
["12,10"] = { "left", "down", "down", "down", "down", "down", "down" },
["11,9"] = { "down", "down", "down", "left", "down", "down" },
["10,9"] = { "down", "down", "down", "down", "down" },
["9,9"] = { "down", "down", "down", "down", "down" },
},
[3] = { -- ROCKET3 @ (9,7), faces RIGHT
["9,8"] = { "right", "down", "down", "down", "down", "down", "down" },
["10,7"] = { "down", "down", "down", "down", "down" },
["11,7"] = { "down", "down", "down", "down", "down" },
["12,7"] = { "down", "down", "down", "down", "down" },
},
}
-- The vanilla table only lists the sight/talk tiles the map geometry allows;
-- a plain down-walk keeps any other engagement tile (e.g. talking from the
-- side) safe -- it still clears the corridor and despawns the grunt.
local POKEMON_TOWER_7F_EXIT_FALLBACK =
{ "down", "down", "down", "down", "down" }
M.POKEMON_TOWER_7F = {
-- Repair saves that beat a grunt before the exit walk was ported: in
-- vanilla HideObject persists (wMissableObjectFlags), so a beaten grunt is
-- still gone on re-entry. hide_object below writes objectToggles for new
-- wins; this hides any grunt whose BEAT flag is already set.
onEnter = function(game, ow)
local Commands = require("src.script.Commands")
local ctx = { game = game, save = game.save, overworld = ow }
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
if game.save.flags[r.beat] then
Commands.hide_object(ctx, "POKEMON_TOWER_7F", r.name)
end
end
end,
-- runVictoryHook fires after every trainer win on the map (both the sight
-- and talk paths route through engageTrainer -> checkVictoryRewards), so
-- this walks off whichever grunt was just beaten but is still standing.
-- The AfterBattle text + walk queue UNDER the EndBattle box engageTrainer
-- pushes right after, giving the vanilla order: EndBattle, AfterBattle,
-- walk, despawn.
onVictory = function(game, ow)
if ow.runner:isRunning() then return end
for _, r in ipairs(POKEMON_TOWER_7F_ROCKETS) do
local npc = ow:npcByIndex(r.index)
if npc and game.save.flags[r.beat] then
local key = ow.player.cellX .. "," .. ow.player.cellY
local dirs = (POKEMON_TOWER_7F_EXITS[r.index] or {})[key]
or POKEMON_TOWER_7F_EXIT_FALLBACK
ow.runner:run({
{ "show_text", r.after }, -- DisplayTextID
{ "walk_npc", r.index, dirs }, -- MoveSprite
{ "hide_object", "POKEMON_TOWER_7F", r.name }, -- HideObject
}, { npc = npc })
return
end
end
end,
talk = {
TEXT_POKEMONTOWER7F_MR_FUJI = {
{ "face_player" }, -- 1
+4 -2
View File
@@ -528,7 +528,9 @@ M.MT_MOON_B2F = {
}
-- The ticket clerk (scripts/Museum1F.asm Museum1FScientist1Text):
-- Y50, once; declining at the rope walks you back out.
-- Y50, once. Declining at the rope shoves the player one tile SOUTH back off
-- the exhibit rope they crossed heading north (#151); the museum floor has no
-- ledges, so a plain scriptMove("down",1) is the correct primitive.
local function museumClerk(game, ow, done, onDecline)
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
@@ -563,7 +565,7 @@ M.MUSEUM_1F = {
if y == 4 and (x == 9 or x == 10)
and not game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET then
museumClerk(game, ow, nil, function()
ow:scriptMove(ow.player, "right", 1)
ow:scriptMove(ow.player, "down", 1)
end)
return true
end
+62 -31
View File
@@ -524,8 +524,17 @@ M.GAME_CORNER = {
game.data.text._GameCornerRocketAfterBattleText
or "Our hideout might\nbe discovered! I\nbetter tell BOSS!",
function()
hideRocket()
done()
-- #198: GameCornerRocketExitScript (scripts/GameCorner.asm)
-- ApplyMovementData walks the grunt one tile UP into the poster
-- (the hideout's secret entrance at 9,4) before HideObject, so
-- he leaves the floor rather than popping out of existence on
-- (9,5). scriptMove locks player input (#scriptMoves>0) and
-- ignores collision, so we despawn + unfreeze (done) only once
-- the step lands.
ow:scriptMove(npc, "up", 1, function()
hideRocket()
done()
end)
end))
end)
end,
@@ -606,40 +615,62 @@ local function activePrizes()
return require("src.core.GameVersion").isBlue() and BLUE_PRIZES or RED_PRIZES
end
-- Prize counters (engine/menus/prize_menu.asm CeladonPrizeMenu; the prize
-- list itself is data/events/prizes.asm, prize_mon_levels.asm). Gen1 gates
-- the prize window on the COIN CASE: it does IsItemInBag COIN_CASE first, and
-- with no case prints RequireCoinCaseText and returns without ever opening a
-- window; only with the case does it print ExchangeCoinsForPrizesText and then
-- show the prizes. #194: the port used to open the window unconditionally and
-- skip both text boxes.
local function prizeCounter(game, ow, npc, done)
local ListMenu = require("src.ui.ListMenu")
local Commands = require("src.script.Commands")
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items, { label = label, right = tostring(p.cost), value = p })
local TextBox = require("src.render.TextBox")
local t = game.data.text
-- IsItemInBag COIN_CASE: without the case, deny and open no window
-- (COIN_CASE is a numeric count in save.inventory, nil when absent).
if not game.save.inventory.COIN_CASE then
game.stack:push(TextBox.new(game,
t._RequireCoinCaseText or "A COIN CASE is\nrequired!", done))
return
end
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
-- ExchangeCoinsForPrizesText plays before the prize window opens.
game.stack:push(TextBox.new(game,
t._ExchangeCoinsForPrizesText or "We exchange your\ncoins for prizes.",
function()
local items = {}
for _, p in ipairs(activePrizes()) do
local label
if p.kind == "mon" then
label = ("%s L%d"):format(game.data.pokemon[p.species].name, p.level)
else
label = game.data.items[p.item].name
end
table.insert(items,
{ label = label, right = tostring(p.cost), value = p })
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
local list
list = ListMenu.new(game, "PRIZES (COINS)", items, {
footer = ("COINS %d"):format(game.save.coins or 0),
onChoose = function(item)
local p = item.value
if (game.save.coins or 0) < p.cost then
list.footer = "Not enough coins!"
return
end
game.save.coins = game.save.coins - p.cost
if p.kind == "mon" then
Commands.give_pokemon({ save = game.save, game = game },
p.species, p.level)
else
game.save.inventory[p.item] = (game.save.inventory[p.item] or 0) + 1
end
list.footer = ("Got it! COINS %d"):format(game.save.coins)
end,
onCancel = done,
})
game.stack:push(list)
end))
end
M.GAME_CORNER_PRIZE_ROOM = {
+26 -2
View File
@@ -134,12 +134,19 @@ M.MT_MOON_POKECENTER = {
-- Karate Master, take HITMONLEE or HITMONCHAN (the other disappears)
-- -------------------------------------------------------------------
-- ownBall/otherBall keep their spots for signature symmetry; only ownBall
-- is ever hidden (Gen1 removes just the chosen ball).
local function dojoBall(species, ownBall, otherBall, askKey)
return function(game, ow, npc, done)
local t = text(game)
local flags = game.save.flags
-- Already took one prize: the OTHER ball is still on the mat, so
-- talking to it now gives the "Better not get greedy..." refusal
-- (FightingDojo.asm .gotThePokemon / _FightingDojoBetterNotGetGreedyText),
-- not a silent no-op (#197).
if flags.EVENT_GOT_HITMONLEE or flags.EVENT_GOT_HITMONCHAN then
done()
push(game, t._FightingDojoBetterNotGetGreedyText
or "Better not get\ngreedy...", done)
return
end
if not flags.EVENT_BEAT_KARATE_MASTER then
@@ -153,8 +160,10 @@ local function dojoBall(species, ownBall, otherBall, askKey)
local Commands = require("src.script.Commands")
local ctx = { save = game.save, game = game, overworld = ow }
Commands.give_pokemon(ctx, species, 30)
-- Hide ONLY the chosen ball; the other stays (FightingDojo.asm hides
-- just the picked object's index) and routes to the greedy line above
-- when talked to (#197).
Commands.hide_object(ctx, "FIGHTING_DOJO", ownBall)
Commands.hide_object(ctx, "FIGHTING_DOJO", otherBall)
push(game, ("%s got\n%s!"):format(game.save.player.name, species), done)
end)
end
@@ -171,6 +180,21 @@ M.FIGHTING_DOJO = {
"FIGHTINGDOJO_HITMONLEE_POKE_BALL",
"_FightingDojoHitmonchanPokeBallText"),
},
-- The two dojo posters on the north wall (FightingDojo.asm bg_events at
-- the top of the room, cells (4,0)/(5,0) directly above the prize balls)
-- print _EnemiesOnEverySideText. The map extractor dropped the dojo
-- bg_events (signs = {}), so wire the poster read here, the same
-- facing-up + coord shape the mansion switches use. Reachable only once
-- a claimed prize frees the ball cell below the poster (#197).
onInteract = function(game, ow, fx, fy)
if ow.player.facing ~= "up" then return false end
if fy == 0 and (fx == 4 or fx == 5) then
push(game, text(game)._EnemiesOnEverySideText
or "Enemies on every\nside!")
return true
end
return false
end,
}
-- -------------------------------------------------------------------
+13 -3
View File
@@ -196,14 +196,24 @@ local function inCoords(coords, x, y)
return false
end
-- coordinate block: show a line and push the player back one step
-- coordinate block: show a line and shove the player back one step. pokered
-- shoves with a SIMULATED JOYPAD press (scripts/ViridianCity.asm ViridianCity-
-- CheckGymOpenScript), which runs the normal overworld step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove must HOP a ledge
-- when one sits in front: the tile below the Viridian Gym door is a down-ledge
-- (#151). Gates with no ledge in front (Cinnabar gym lock at (18,4)) get
-- checkLedgeHop == false and fall back to the plain forced step, unchanged.
local function stepGate(opts)
return function(game, ow, x, y)
if not inCoords(opts.coords, x, y) then return false end
if not opts.blocked(game) then return false end
require("src.core.Sound").play(game.data, "Denied")
push(game, text(game)[opts.text] or opts.fallback,
function() ow:scriptMove(ow.player, opts.push, 1) end)
push(game, text(game)[opts.text] or opts.fallback, function()
ow.player.facing = opts.push
if not ow:checkLedgeHop(opts.push) then
ow:scriptMove(ow.player, opts.push, 1)
end
end)
return true
end
end
+5 -2
View File
@@ -114,9 +114,12 @@ return {
-- Fighting Dojo Karate Master (scripts/FightingDojo.asm
-- FightingDojoKarateMasterPostBattleScript sets EVENT_BEAT_KARATE_MASTER,
-- which gates the HITMONLEE/HITMONCHAN gift). OPP_BLACKBELT party 1 is
-- only him (data/maps/objects/FightingDojo.asm).
-- only him (data/maps/objects/FightingDojo.asm). `dialogue` is the
-- concede + prize offer he speaks after the win (his header supplies the
-- "Hwa! Arrgh! Beaten!" won line first; #197).
["OPP_BLACKBELT#1"] = { flag = "EVENT_BEAT_KARATE_MASTER",
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3) },
deactivate = range("EVENT_BEAT_FIGHTING_DOJO_TRAINER_", 0, 3),
dialogue = { "_FightingDojoKarateMasterIWillGiveYouAPokemonText" } },
-- Elite Four progress flags (their rooms' door logic isn't ported, but
-- the flags make the Hall of Fame checkable)
+126 -20
View File
@@ -183,7 +183,22 @@ end
-- the image a battler pic actually draws with this frame
function BattleState:picImage(img)
if self.grayPics then return grayImage(img) end
local PaletteFX = require("src.render.PaletteFX")
-- #207: OG / OG INV / CLASSIC are forced-mono display modes. A battle that
-- exposes no SGB zones (sgbPalettes() == nil) has a whole-screen GRAYS zone
-- invented by PaletteFX.ensureZones, so Renderer:endFrame re-thresholds the
-- WHOLE finished frame through the shade shader a second time (keyed on the
-- red channel). A pic baked with the species' SGB color is then remapped
-- again and loses its warm mid shades -- REDMON's reds 1.0/0.839 both land in
-- the c0 bucket, collapsing CHARMANDER's body into the white paper and
-- leaving only the outline. Emit the raw DMG-gray build instead (exactly
-- what the SE_WAVY_SCREEN grayPics path already does) so the downstream remap
-- recolors 255->c0/170->c1/85->c2/0->c3 and all four shades survive; cool
-- palettes (CYANMON reds 0.678/0.451) already rendered correctly. This mode
-- set mirrors PaletteFX.ensureZones / effectiveColors -- keep them in sync.
local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv"
or PaletteFX.mode == "classic"
if self.grayPics or mono then return grayImage(img) end
return fadeImage(img, self:activeBgp())
end
@@ -666,16 +681,53 @@ local function shownHP(b)
return math.floor(shown)
end
-- Parse a battle message into its rendered lines. The extractor marks
-- \n = next line and \v = CONT (home/text.asm ContText: draw the blinking
-- ▼, WaitForTextScrollButtonPress, then ScrollTextUpOneLine); the boosted /
-- EXP.ALL exp lines end in the CONT code in the ROM (data/generated/text.lua
-- _BoostedText/_WithExpAllText = "...\011"). Each entry is { codes, cont },
-- cont true when the line was preceded by \v. Splitting before Font.encode
-- keeps the control chars out of the glyph stream. The box then types into
-- a rolling 2-line window (self.shown) that scrolls when a 3rd line arrives
-- instead of drawing it off-screen at y=144 (#216).
function BattleState:startMessage(item)
self.current = item
self.lines = {}
self.total = 0
for chunk in (item.text .. "\n"):gmatch("(.-)\n") do
local text = item.text or ""
local pos, cont = 1, false
while true do
local npos = text:find("[\n\v]", pos)
local chunk = npos and text:sub(pos, npos - 1) or text:sub(pos)
local codes = Font.encode(chunk)
table.insert(self.lines, codes)
self.lines[#self.lines + 1] = { codes = codes, cont = cont }
self.total = self.total + #codes
if not npos then break end
cont = text:sub(npos, npos) == "\v"
pos = npos + 1
end
self.shown = {} -- up to two visible lines of revealed glyph codes
self.lineIndex = 0
-- self.charIndex counts glyphs typed across the WHOLE message (drivers read
-- it against self.total); the current line's revealed count is #shown[last]
self.charIndex = 0
self.msgWaiting = nil
self.scrollPx = nil
self:beginMsgLine()
end
-- Start typing the next line into the rolling window. When the box already
-- shows two lines, drop the top one and set the pixel scroll-up
-- (ScrollTextUpOneLine), mirroring TextBox:beginLine.
function BattleState:beginMsgLine()
self.lineIndex = self.lineIndex + 1
local ln = self.lines[self.lineIndex]
self.codes = ln and ln.codes or {}
if #self.shown >= 2 then
table.remove(self.shown, 1)
self.scrollPx = 8
end
self.shown[#self.shown + 1] = {}
end
function BattleState:updateQueue()
@@ -823,8 +875,32 @@ function BattleState:updateQueue()
end
self:startMessage(item)
end
if self.charIndex < self.total then
self.charIndex = math.min(self.total, self.charIndex + 2)
local input = self.game.input
-- a \v CONT wait holds the box until A/B, then scrolls the next line in
-- (home/text.asm ContText); this keeps a 3rd line on-screen (#216)
if self.msgWaiting then
if input:wasPressed("a") or input:wasPressed("b") then
self.msgWaiting = nil
self:beginMsgLine()
end
return true
end
local cur = self.shown[#self.shown]
if #cur < #self.codes then
-- battle typewriter cadence: two glyphs per fixed step (as before)
for _ = 1, 2 do
if #cur >= #self.codes then break end
cur[#cur + 1] = self.codes[#cur + 1]
self.charIndex = self.charIndex + 1
end
elseif self.lineIndex < #self.lines then
-- current line finished, more lines remain: \v waits for A/B + ▼ before
-- scrolling, \n advances now (beginMsgLine scrolls if the box is full)
if self.lines[self.lineIndex + 1].cont then
self.msgWaiting = true
else
self:beginMsgLine()
end
else
local item = self.current
-- TrainerAboutToUseText ends in `done` then DisplayTextBoxID: YES/NO
@@ -841,7 +917,6 @@ function BattleState:updateQueue()
end))
return true
end
local input = self.game.input
if not (item and item.choice)
and (input:wasPressed("a") or input:wasPressed("b")) then
self.current = nil
@@ -2715,12 +2790,15 @@ function BattleState:enemyMonFainted()
-- _WithExpAllText / _BoostedText / _ExpPointsText; the EXP.ALL
-- pass beats the traded boost (wBoostExpByExpAll checks first),
-- and _ExpPointsText prints wExpAmountGained -- the raw share,
-- captured before the max-level cap (experience.asm:92-100)
-- captured before the max-level cap (experience.asm:92-100).
-- _BoostedText / _WithExpAllText end in the CONT code (\v, "...\011"
-- in data/generated/text.lua): the box waits for A/B + ▼ then scrolls
-- the amount line in, so it stays on-screen instead of at y=144 (#216).
local tail = "%d EXP. Points!"
if announce == "expAll" then
tail = "with EXP.ALL,\n" .. tail
tail = "with EXP.ALL,\v" .. tail
elseif mon.traded then
tail = "a boosted\n" .. tail
tail = "a boosted\v" .. tail
end
self:sayNext(("%s gained\n" .. tail):format(name, gained))
end
@@ -2733,6 +2811,17 @@ function BattleState:enemyMonFainted()
require("src.core.Sound").play(game.data, "Level_Up")
return StatBox.new(game, mon)
end)
-- After PrintStatsBox, experience.asm reloads the active battler's
-- wBattleMon and runs DrawHUDsAndHPBars, so its HP bar reflects the
-- higher current HP. Experience.lua:84 already raised mon.hp by
-- (newMaxHP - oldMaxHP); the party mon and the battler share one table
-- (makeBattler), so mon.stats.hp (the bar's denominator) jumps to the
-- new max instantly while the battler's shownHP numerator lags at the
-- old current HP -- the bar SHRINKS (#224). Animate shownHP up to the
-- new current HP (house convention: potions drain the bar too, see
-- itemUsed) so the bar grows instead. Only the active player battler
-- shares its table with the HUD; other party mons (EXP.ALL) have no bar.
if mon == self.player.mon then self:drainNext() end
for _, moveId in ipairs(Experience.movesLearnedAt(
self.data.pokemon[mon.species], lv)) do
self:learnMove(mon, moveId)
@@ -4114,7 +4203,12 @@ function BattleState:drawHUDs(slide)
-- the HUD clears with the send-out text (ClearScreenArea,
-- core.asm:1414-1417) and DrawEnemyHUDAndHPBar (1435) only redraws
-- it after the grow-in + cry
local barData = self:colorMode() and {} or self.data -- gray fill when zoned
-- In colorized modes the zone pass (drawZonePass over BATTLE_ZONES pal 0/1)
-- recolors the bar's DMG gray fill by region, so drawHPBar must skip its
-- per-pixel tint (grayFill) -- otherwise GREENBAR's red-channel-0 fill
-- double-applies and the zone shade shader maps the whole bar to black (#229).
local grayFill = self:colorMode()
local barData = self.data
local fx = self.fx
local hudShake = (fx and fx.hudShakeX) or 0
-- FaintEnemyPokemon clears the enemy HUD area; it stays blank through
@@ -4139,7 +4233,8 @@ function BattleState:drawHUDs(slide)
end
hudTile(0x73, 8, 16)
drawHPBar(barData, 2, 2,
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats })
{ hp = shownHP(self.enemy), stats = self.enemy.mon.stats },
nil, grayFill)
hudTile(0x74, 8, 24)
for i = 2, 9 do hudTile(0x76, i * 8, 24) end
hudTile(0x78, 80, 24)
@@ -4187,7 +4282,7 @@ function BattleState:drawHUDs(slide)
end
drawHPBar(barData, 10, 9,
{ hp = shownHP(self.player), stats = self.player.mon.stats },
1) -- wHPBarType 1: the $6D cap
1, grayFill) -- wHPBarType 1: the $6D cap
Font.draw(("%3d/%3d"):format(shownHP(self.player), self.player.mon.stats.hp), 88, 80)
hudTile(0x73, 144, 80)
hudTile(0x77, 144, 88)
@@ -4200,16 +4295,27 @@ function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and self.current then
local shown = 0
for li, codes in ipairs(self.lines) do
-- battle text uses every other tile row (hlcoord *,14 / *,16)
local y = 112 + (li - 1) * 16
for i = 1, #codes do
if shown >= self.charIndex then break end
Font.drawCode(codes[i], 8 + (i - 1) * 8, y)
shown = shown + 1
-- rolling 2-line window: shown[1] at row y=112, shown[2] at y=128 (battle
-- text uses every other tile row, hlcoord *,14 / *,16). scrollPx animates
-- the lines up one row (ScrollTextUpOneLine) so a 3rd line scrolls into
-- view instead of drawing off-screen at y=144 (#216).
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
if self.scrollPx <= 0 then self.scrollPx = nil end
end
local off = self.scrollPx or 0
local ys = { 112, 128 }
for li, line in ipairs(self.shown or {}) do
local y = (ys[li] or 128) + off
for i = 1, #line do
Font.drawCode(line[i], 8 + (i - 1) * 8, y)
end
end
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait holds the
-- box, bottom-right of the box like TextBox / home/text.asm
if self.msgWaiting and self.frame % 60 < 30 then
Font.drawCode(0xEE, (0 + 20 - 2) * 8, (12 + 6 - 1) * 8 - 4)
end
elseif self.phase == "menu" and self.demo then
-- the old-man script (DisplayBattleMenu, core.asm:2038-2049): the
-- standard menu, with the '▶' hand drawn by the scripted keystrokes
+31
View File
@@ -102,10 +102,41 @@ function Data:seedDefaults()
-- after-battle rows so Blaine's SetEventRange deactivation and talk
-- after-text work like the other gyms (scripts/CinnabarGym.asm).
self:seedCinnabarGymTrainerHeaders()
-- #197: the Fighting Dojo Karate Master is text_asm, so the extractor
-- writes no header for him -- seed one so he engages on sight and has
-- his defeat / re-talk lines (same idea as the Cinnabar seed above).
self:seedFightingDojoKarateMaster()
-- #189: 1F cabin door order vs rooms map (survey zoom)
require("src.world.SsAnneLayout").apply(self.maps)
end
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
-- no def_trainers row (DisplayTextID routes to his ASM script), so the
-- extractor emits headers only for the four blackbelts ([2]..[5]). Seed
-- object index [1] so he behaves like the real leader:
-- * range 4 (matches the strongest blackbelt) -> CheckFightingMapTrainers
-- spots the player in his DOWN line and he challenges on sight (#197),
-- * battle = his pre-battle challenge, won = "Hwa! Arrgh! Beaten!",
-- * after = the "Stay and train at Karate with us!" re-talk line.
-- Deliberately NO `event`: EVENT_BEAT_KARATE_MASTER is owned by
-- victories.lua (OPP_BLACKBELT#1) exactly like the gym leaders, and
-- engageTrainer sets header.event *before* checkVictoryRewards runs -- if
-- this header also set it, the reward's flag guard would early-return and
-- swallow the prize dialogue. trainerDefeated tracks him via
-- defeatedTrainers[npc.id] like the leaders.
function Data:seedFightingDojoKarateMaster()
local headers = self.trainer_headers
if not headers then return end
headers.FightingDojo = headers.FightingDojo or {}
if headers.FightingDojo[1] then return end
headers.FightingDojo[1] = {
range = 4,
battle = "_FightingDojoKarateMasterText",
won = "_FightingDojoKarateMasterDefeatedText",
after = "_FightingDojoKarateMasterStayAndTrainWithUsText",
}
end
function Data:seedCinnabarGymTrainerHeaders()
local headers = self.trainer_headers
if not headers or headers.CinnabarGym then return end
+21 -2
View File
@@ -31,7 +31,12 @@ local function indexOf(list, value)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
@@ -493,6 +498,14 @@ function LinkState:updateTrade(input)
if t.stage == "done" then
local sent = t.party[t.myPick]
local received, evoTo = t:apply(self.game)
-- Autosave the instant the swap commits into game.save.party, matching the
-- Cable Club: pokered engine/link/cable_club.asm calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade so the trade is on the
-- cartridge before the animation runs. Without this the received mon
-- lives only in memory until a manual START-menu save, so a force-quit
-- would lose it and a reset would clone the sent mon (#222). Guarded so
-- headless LinkBattle-style fake games with no writeSave are unaffected.
if self.game.writeSave then self.game:writeSave() end
local name = received.nickname or self.game.data.pokemon[received.species].name
Runtime.emit("link.ended", { reason = "done" })
self.net:close()
@@ -510,7 +523,13 @@ function LinkState:updateTrade(input)
("Trade completed!\f%s received\n%s!"):format(game.save.player.name, name),
function()
if evoTo then
require("src.pokemon.Evolution").evolve(game, received, evoTo)
-- via="TRADE": a trade evolution cannot be B-cancelled
-- (pokered LINK_STATE_TRADING skips the flash B-poll) (#213).
-- Re-save once the evolution movie finishes so the evolved
-- species (not the pre-evo landed by t:apply) is what persists,
-- keeping disk in step with the autosave above (#222).
require("src.pokemon.Evolution").evolve(game, received, evoTo,
function() if game.writeSave then game:writeSave() end end, "TRADE")
end
end))
end,
+30 -4
View File
@@ -43,7 +43,12 @@ Protocol.plainCopy = plainCopy
-- serialize a mon instance for the wire (plain data only). ppUps rides
-- along because the real cable transmitted it and its absence silently
-- capped a PP-Upped move at base PP on the receiving side.
-- capped a PP-Upped move at base PP on the receiving side. ot/otId ride
-- along too: pokered's trade sends the whole party block including each
-- mon's OT ID (party_struct MON_OTID) and the OT-names block (wPartyMonOT),
-- and the receiver keeps them verbatim -- a differing OT/ID is what marks a
-- mon as traded (boosted EXP, high-level disobedience). Omitting them made
-- a received mon show the receiver as its OT (#215).
function Protocol.packMon(mon)
local moves = {}
for _, mv in ipairs(mon.moves) do
@@ -59,6 +64,8 @@ function Protocol.packMon(mon)
dvs = mon.dvs,
statExp = mon.statExp,
moves = moves,
ot = mon.ot,
otId = mon.otId,
extra = plainCopy(mon.extra),
}
end
@@ -71,6 +78,14 @@ function Protocol.unpackMon(data, packed, opts)
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local strict = opts and opts.strict
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
-- tonumber(50)/tonumber("50") both give 50.
local forceLevel = opts and tonumber(opts.forceLevel) or nil
local def = data.pokemon[packed.species]
if not def then
if strict then return nil, "unknown POKéMON" end
@@ -81,8 +96,8 @@ function Protocol.unpackMon(data, packed, opts)
-- ignored and everyone rebuilds at the same fixed level instead, so a
-- Lv12 and a Lv100 party can battle on equal footing. Both sides pass
-- the identical forceLevel for a given match, so this stays symmetric.
if opts and opts.forceLevel then
level = math.max(2, math.min(100, math.floor(opts.forceLevel)))
if forceLevel then
level = math.max(2, math.min(100, math.floor(forceLevel)))
end
local dvs = {}
for _, k in ipairs({ "hp", "attack", "defense", "speed", "special" }) do
@@ -115,10 +130,19 @@ function Protocol.unpackMon(data, packed, opts)
-- current HP/status (a different level's numbers, possibly mid-fight)
-- isn't meaningful anymore -- auto-level starts everyone full and fresh,
-- same as a standardized tournament format would
local forced = opts and opts.forceLevel
local forced = forceLevel
local hp = forced and stats.hp
or math.max(0, math.min(stats.hp, math.floor(packed.hp or stats.hp)))
local status = forced and nil or packed.status
-- preserve the sender's original-trainer identity (party_struct MON_OTID +
-- wPartyMonOT on a real cable), clamped/typed like every other field so a
-- tampered packet can't inject a bad ID or a huge name. Left nil when the
-- packet omits them (a v1/old peer) -- no worse than before for that legacy
-- path, and once ot is set the load-time stampOT backfill (mon.ot or ...)
-- becomes a no-op so the sender's identity survives save/reload (#215).
local otId = packed.otId
and math.max(0, math.min(65535, math.floor(packed.otId))) or nil
local ot = type(packed.ot) == "string" and packed.ot:sub(1, 10) or nil
return {
species = packed.species,
level = level,
@@ -129,6 +153,8 @@ function Protocol.unpackMon(data, packed, opts)
hp = hp,
status = status,
nickname = packed.nickname,
ot = ot,
otId = otId,
moves = moves,
-- a namespace whose mod this install lacks survives untouched, so the
-- mon keeps it for the trip home
+6 -1
View File
@@ -57,7 +57,12 @@ local function levelLabel(v)
end
local function levelForWire(v)
return v == ANY and nil or v
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
+48 -2
View File
@@ -106,11 +106,55 @@ function Evolution.apply(game, mon, newSpecies, via)
})
end
-- After the "evolved into" text, Gen1 re-runs the level-up learn check on
-- the EVOLVED species (engine/pokemon/evos_moves.asm EvolveMon calls the
-- LearnMoveFromLevelUp predef, engine/pokemon/learn_move.asm) -- a mon
-- evolving at exactly a learnset level gains that move (GYARADOS learns
-- BITE at 20, so MAGIKARP->GYARADOS @20 learns BITE, @21 does not) (#12).
-- Mirrors the rare-candy learn loop in src/ui/BagMenu.lua so a full move
-- list opens the forget prompt. mon.species is already the new species
-- (Evolution.apply ran before the congrats text). onDone runs once the
-- learn list is exhausted, replacing the caller's direct onDone.
function Evolution.learnEvolutionMoves(game, mon, onDone)
local Experience = require("src.battle.Experience")
local def = game.data.pokemon[mon.species]
-- movesLearnedAt uses entry.level == level (exact Gen1 rule); do NOT use
-- Pokemon.movesAtLevel (<= level), which would over-grant older moves.
local moves = Experience.movesLearnedAt(def, mon.level)
local i = 0
local function nextStep()
i = i + 1
local moveId = moves[i]
if not moveId then
if onDone then onDone() end
return
end
for _, mv in ipairs(mon.moves) do
if mv.id == moveId then return nextStep() end
end
local mdef = game.data.moves[moveId]
if not mdef then return nextStep() end
local name = mon.nickname or def.name
if #mon.moves < 4 then
table.insert(mon.moves, { id = moveId, pp = mdef.pp })
Runtime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
game.stack:push(TextBox.new(game,
("%s learned\n%s!"):format(name, mdef.name), nextStep))
else
-- LearnMoveFromLevelUp with a full moveset: the forget UI
Screens.push(game, "MoveLearnMenu", mon, moveId, nextStep)
end
end
nextStep()
end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
if love.image and love.image.newImageData then
Screens.push(game, "EvolutionState", mon, newSpecies, onDone)
-- forward `via` so EvolutionState can keep trade evolutions
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
@@ -120,7 +164,9 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
:format(oldName, oldName, game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
if onDone then onDone() end
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
end))
end
+22 -10
View File
@@ -77,7 +77,17 @@ end
-- one-pixel sliver. The fill is tinted with the SGB bar palettes at
-- GetHealthBarColor's thresholds (>= 27 px green, >= 10 yellow, else
-- red).
function HudTiles.drawHPBar(data, tx, ty, mon, barType)
--
-- grayFill (#229): when the caller will colorize this bar with an SGB
-- region palette (BattleState's zone pass, BATTLE_ZONES pal 0/1 =
-- GetHealthBarColor), leave the fill as its raw DMG shade-2 gray and skip
-- the per-pixel tint -- the DMG hardware bar is ONE gray shade recolored by
-- the region palette (engine/gfx/palettes.asm SetPal_Battle,
-- data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel repaint.
-- Tinting first would double-apply the color: GREENBAR's fill {0,189,0} has
-- red channel 0, so the tint zeroes the whole bar's red and the zone's
-- red-channel-keyed shade shader then maps every pixel to color 3 = black.
function HudTiles.drawHPBar(data, tx, ty, mon, barType, grayFill)
local x, y = tx * 8, ty * 8
HudTiles.tile(0x71, x, y)
HudTiles.tile(0x62, x + 8, y)
@@ -86,15 +96,17 @@ function HudTiles.drawHPBar(data, tx, ty, mon, barType)
px = math.max(1, math.floor(mon.hp * 48 / mon.stats.hp))
end
local tint
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
if not grayFill then
local PaletteFX = require("src.render.PaletteFX")
local name = px >= 27 and "GREENBAR" or px >= 10 and "YELLOWBAR" or "REDBAR"
local colors = PaletteFX.pal(data, name)
if colors then
local c = colors[3] -- GB color 2 is the fill shade
-- the fill pixels are the 2/3-gray shade; divide so they land on
-- the palette color exactly (the black outline stays black)
tint = { math.min(1, c[1] / 170), math.min(1, c[2] / 170),
math.min(1, c[3] / 170), 1 }
end
end
for i = 0, 5 do
local seg = math.min(8, math.max(0, px - i * 8))
+44 -17
View File
@@ -50,14 +50,23 @@ PaletteFX.GBC_OBJ = {
}
-- OG BLUE: Pokemon Blue's Game Boy Color boot-ROM auto-palette. Same
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the
-- boot ROM colorizes the background blue instead of red -- so "OG RED" for a
-- Blue playthrough is white -> light blue -> dark blue -> black, mirroring
-- GBC_BG channel-for-channel so the blue reads at the same brightness. The
-- OBJ (sprite) palette stays the same green, matching how Red and Blue share
-- the green-character look on a Game Boy Color.
-- one-global-pair scheme as OG RED (Blue also ships no CGB code), but the boot
-- ROM gives Blue its OWN entry rather than a recolored Red: a light-blue/blue
-- BACKGROUND and -- unlike Red -- a PINK object palette (OBP0). Values from
-- Bulbapedia's "List of color palettes ... Generation I" GBC boot-ROM table
-- (BG 0x63A5FF/0x0000FF, OBJ 0xFF8484/0x943A3A), confirmed against a Gambatte
-- hardware capture. The earlier code mirrored GBC_BG channel-for-channel
-- (0x8484FF/0x3A3A94) and reused Red's green sprites for both versions on the
-- premise that Red and Blue "share the green-character look"; both premises
-- are wrong -- Blue's background is a genuinely different blue and its
-- characters are pink (#155). Lightest shade first, like GBC_BG.
PaletteFX.GBC_BG_BLUE = {
{ 255, 255, 255 }, { 132, 132, 255 }, { 58, 58, 148 }, { 0, 0, 0 },
{ 255, 255, 255 }, { 99, 165, 255 }, { 0, 0, 255 }, { 0, 0, 0 },
}
-- Blue's OBJ palette (OBP0) is the red/pink ramp -- the very same colors OG
-- RED uses for its BACKGROUND (GBC_BG), just applied to objects instead.
PaletteFX.GBC_OBJ_BLUE = {
{ 255, 255, 255 }, { 255, 132, 132 }, { 148, 58, 58 }, { 0, 0, 0 },
}
-- The active game's OG boot-ROM background palette: blue for a Blue
@@ -69,6 +78,16 @@ function PaletteFX.ogBg()
return PaletteFX.GBC_BG
end
-- The active game's OG boot-ROM object palette (OBP0): Blue's pink ramp for a
-- Blue playthrough, Red's green otherwise. Returns the colors AND a
-- version-distinct cache-group string, because SpriteRenderer.getObpImage keys
-- its baked-image cache by (image path, group): a shared group would collide a
-- Red bake with a Blue one and one version would show the other's colors.
function PaletteFX.ogObj()
if GameVersion.isBlue() then return PaletteFX.GBC_OBJ_BLUE, "gbcobj_blue" end
return PaletteFX.GBC_OBJ, "gbcobj"
end
local INV_MAP = { [0] = 3, [1] = 2, [2] = 1, [3] = 0 }
function PaletteFX.shader()
@@ -183,17 +202,25 @@ function PaletteFX.usesGbcPack(mode)
end
-- Whether the active mode bakes a per-OBJ palette onto overworld sprites
-- (the OBP bake + post-zone redraw path). ONLY OG RED does: it wears the
-- GBC boot-ROM green object palette (PaletteFX.GBC_OBJ) so the player and
-- NPCs stay green over the red background, exactly like Pokemon Red on a
-- Game Boy Color. SGB mode deliberately does NOT: an SGB OBJ carries no
-- palette of its own, so the characters tint with the whole-map region
-- palette along with the terrain (the Super Game Boy never colored Pokemon
-- Red's sprites separately -- baking a per-sprite palette there was the
-- "reds coloring on the player/NPCs" bug). RED++ colors sprites through
-- the usesGbcPack() path in SpriteRenderer instead.
-- (the OBP bake + post-zone redraw path). OG RED and SGB both do: characters
-- wear the GBC boot-ROM object palette (PaletteFX.ogObj -- green over Red's red
-- background, pink over Blue's blue background), so the player and NPCs carry a
-- fixed object color instead of tinting with whatever region palette their
-- feet stand over. On real hardware a sprite is an OBJ colored by an OBJ
-- palette (color/sprites.asm ColorOverworldSprite), distinct from the BG it
-- overlaps -- so Red's cap must stay green in tall grass, not turn the ROUTE
-- palette's light-blue (issue #150: SGB region-tinting sent the cap to shade-2
-- = light-blue and the character clashed with the grass it should blend into).
-- Terrain is unaffected -- pal() below still hands SGB its per-map BG palette;
-- only OG RED short-circuits BG to the one global red palette. An EARLIER
-- attempt at per-sprite SGB color baked GBC_BG (the RED background ramp) onto
-- characters -- that was the "reds coloring on the player/NPCs" bug; the object
-- palette is GBC_OBJ (green), so baking it here is the fix, not that
-- regression. RED++ colors sprites through the usesGbcPack() path in
-- SpriteRenderer instead.
function PaletteFX.usesSpriteObp(mode)
return (mode or PaletteFX.mode) == "ogred"
mode = mode or PaletteFX.mode
return mode == "ogred" or mode == "gbc"
end
-- ------- post-zone sprite redraw (GBC mode)
+28 -4
View File
@@ -46,18 +46,30 @@ Renderer.UPRIGHT_MARGIN = 160
-- non-square "pixels", and movement judder (issue #87). Always derive the
-- crisp integer scale from the drawable pixel size; draw with (pixels/dpi)
-- so the GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
--
-- `dpi` here must be the factor LOVE actually applies to every draw call
-- (getDPIScale), NOT the drawable/unit size ratio pw/ww. On a normal device
-- those are equal (getPixelDimensions == getDimensions * getDPIScale), but on
-- the AYN Thor dual-screen surface in forced landscape they diverge: LOVE
-- reports pw/ww ≈ 1 while its real transform is 1.5, so scaling by pw/ww lands
-- each GB pixel on Sp*(getDPIScale/(pw/ww)) physical pixels -- a fractional,
-- stretched/non-square count (issue #208). Prefer getDPIScale so draws land
-- on whole physical pixels through LOVE's actual transform; fall back to pw/ww
-- (then 1) only when getDPIScale is unavailable. Since getDPIScale == pw/ww
-- on every normal device, #87's behavior is byte-identical there.
local function displayMetrics()
local ww, wh = love.graphics.getDimensions()
local pw, ph = ww, wh
if love.graphics.getPixelDimensions then
pw, ph = love.graphics.getPixelDimensions()
end
local dpi = 1
if ww > 0 and pw > 0 then
dpi = pw / ww
elseif love.graphics.getDPIScale then
local dpi
if love.graphics.getDPIScale then
dpi = love.graphics.getDPIScale()
end
if (not dpi or dpi < 1e-6) and ww > 0 and pw > 0 then
dpi = pw / ww
end
if not dpi or dpi < 1e-6 then dpi = 1 end
return ww, wh, pw, ph, dpi
end
@@ -99,6 +111,18 @@ function Renderer:fitScale()
return math.max(1, math.floor(math.min(pw / self.WIDTH, ph / self.HEIGHT)))
end
-- The LOVE-unit draw scale endFrame uses for the UI blit: the integer
-- framebuffer scale (fitScale) divided by the live coordinate->pixel factor,
-- so a GB pixel lands on fitScale() whole PHYSICAL pixels once LOVE applies
-- its own transform (fitScale() == drawScale() * dpi). Exposed so #208's
-- regression can assert GB pixels stay square on divergent-DPI surfaces
-- without reaching into endFrame's locals; endFrame recomputes the same value
-- inline (`S = Sp / dpi`).
function Renderer:drawScale()
local _, _, _, _, dpi = displayMetrics()
return self:fitScale() / dpi
end
-- world-pass canvas size in world pixels: enough to fill the window at s'.
-- In tilt mode the canvas grows (both dimensions, by Tilt.viewGrowth) so
-- the projected ground plane still covers the whole window with no
+11 -6
View File
@@ -105,7 +105,10 @@ function SpriteRenderer:resolveImage()
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
elseif PaletteFX.usesSpriteObp() then
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
-- returns colors + a version-distinct cache group so the two never
-- collide in obpCache) -- see issue #155
return getObpImage(self.def.image, PaletteFX.ogObj())
end
return self.image
end
@@ -141,11 +144,13 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
image = getObpImage(self.def.image, colors, group)
end
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
-- OG RED (GBC boot-ROM look): every OBJ wears the one global green
-- object palette. The red BG zone shader still runs over the world
-- canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its green pixels on top.
image = getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
-- palette -- green over Red's red background, pink over Blue's blue
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
-- the world canvas, so the baked sprite is queued for a post-zone redraw
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
-- top.
image = getObpImage(self.def.image, PaletteFX.ogObj())
redraw = true
end
-- single-frame sprites (item balls, fossils...) have one fixed pose;
+17 -7
View File
@@ -214,11 +214,10 @@ local function useOn(game, battle, id, target, list, moveIndex)
and ow.map.id ~= "AGATHAS_ROOM" then
list:close()
consume(game, id)
require("src.core.Sound").play(game.data, "Teleport_Exit1")
ow.player.surfing = false
-- EnterMapAnim on arrival (BIT_ESCAPE_WARP / special warp path);
-- blackouts omit arrive="teleport" (HandleBlackOut has no LeaveMapAnim)
ow:warpToHealPoint(nil, { arrive = "teleport" })
-- LeaveMapAnim spin-up + SFX_TELEPORT_EXIT_1, a fade, then land OUTSIDE
-- the last Pokémon Center town door like Fly (#196), via the shared
-- departure helper -- the same path Dig/Teleport take from the party menu
ow:beginTeleportOut()
else
showMessages(game, { "OAK: " .. game.save.player.name
.. "!\nThis isn't the\ntime to use that!" })
@@ -303,7 +302,8 @@ local function pickTargetAndUse(game, battle, id, list)
-- the ETHERs and PP UP open the move menu after picking a mon
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
local wantsMove = id == "ETHER" or id == "MAX_ETHER" or id == "PP_UP"
require("src.ui.Screens").push(game, "PartyMenu", {
local def = game.data.items[id]
local opts = {
pickOnly = true,
onSwitch = function(mon)
if not wantsMove then
@@ -326,7 +326,17 @@ local function pickTargetAndUse(game, battle, id, list)
end,
}))
end,
})
}
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
-- strictly on def.machine. #210
if def and def.machine then
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
end
require("src.ui.Screens").push(game, "PartyMenu", opts)
end
local function useItem(game, battle, id, list)
+42 -8
View File
@@ -1,8 +1,11 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- B during the flash cancels ("Huh? ... stopped evolving!"? -- Gen 1
-- has no cancel; the flash always completes).
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) polls hJoyHeld during
-- the flash: holding B aborts the evolution -- the mon keeps its species
-- and _StoppedEvolvingText ("Huh? MON stopped evolving!") prints. The
-- lone exception is trade evolutions (wLinkState == LINK_STATE_TRADING),
-- which skip that poll and cannot be cancelled (#213).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
@@ -14,7 +17,10 @@ EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local species = self.done and self.newSpecies or self.mon.species
-- a cancelled evolution keeps the old species (never applied), so only
-- colorize with the new form once it has actually evolved
local species = (self.done and not self.canceled) and self.newSpecies
or self.mon.species
local c = P.monPal(game.data, species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
@@ -29,17 +35,22 @@ local function frontSprite(game, species)
return ok and img or nil
end
function EvolutionState.new(game, mon, newSpecies, onDone)
function EvolutionState.new(game, mon, newSpecies, onDone, via)
local self = setmetatable({}, EvolutionState)
self.game = game
self.mon = mon
self.newSpecies = newSpecies
self.onDone = onDone
self.via = via
-- evos_moves.asm: only trade evolutions (LINK_STATE_TRADING) skip the
-- B-cancel poll; level-up, stone and rare-candy evos are all cancelable.
self.cancelable = (via ~= "TRADE")
self.oldName = mon.nickname or game.data.pokemon[mon.species].name
self.oldSprite = frontSprite(game, mon.species)
self.newSprite = frontSprite(game, newSpecies)
self.t = 0
self.done = false
self.canceled = false
Music.play(game.data, Music.special(game.data, "evolution"))
return self
end
@@ -47,11 +58,28 @@ end
function EvolutionState:update(dt)
self.t = self.t + 1
if self.done then return end
local game = self.game
-- evos_moves.asm EvolveMon: each flash iteration polls hJoyHeld and, for
-- a cancelable evolution, aborts when B is held -- the mon keeps its
-- species (Evolution.apply never runs) and _StoppedEvolvingText prints.
if self.cancelable and game.input:isDown("b") then
self.done = true
self.canceled = true
local TextBox = require("src.render.TextBox")
-- mirrors data/generated/text.lua _StoppedEvolvingText
game.stack:push(TextBox.new(game,
("Huh? %s\nstopped evolving!"):format(self.oldName),
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
end))
return
end
if self.t >= FLASH_FRAMES then
self.done = true
local game = self.game
local Evolution = require("src.pokemon.Evolution")
Evolution.apply(game, self.mon, self.newSpecies)
Evolution.apply(game, self.mon, self.newSpecies, self.via)
require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
@@ -61,7 +89,12 @@ function EvolutionState:update(dt)
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
if self.onDone then self.onDone() end
-- Gen1 re-runs the level-up learn check on the evolved species
-- after the "evolved into" text (evos_moves.asm EvolveMon ->
-- learn_move.asm LearnMoveFromLevelUp, #12). Pop the evo screen
-- first so the "learned MOVE!" text / forget prompt push onto the
-- overworld / battle-return, not this state.
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end))
end
end
@@ -73,7 +106,8 @@ function EvolutionState:draw()
-- accelerating flash between the two forms
local sprite
if self.done then
sprite = self.newSprite
-- a cancelled evolution settles back on the original form
sprite = self.canceled and self.oldSprite or self.newSprite
else
local period = math.max(4, 28 - math.floor(self.t / 40) * 6)
local showNew = math.floor(self.t / period) % 2 == 1
+6 -2
View File
@@ -11,9 +11,13 @@ function FlyMenu.new(game)
local visited = game.save.visited or {}
local seen = {}
for _, mapId in ipairs(game.data.field.flyOrder or {}) do
-- towns only (dungeon escape spots share the table), each listed once
-- towns only (dungeon escape spots share the table), each listed once.
-- Indigo Plateau (tileset PLATEAU) is a valid Fly destination too, so allow
-- it past the OVERWORLD-only isOutdoor gate while the CAVERN/FACILITY escape
-- spots stay excluded (LoadTownMap_Fly cycles it like any town, #203).
local def = game.data.maps[mapId]
if visited[mapId] and def and Map.isOutdoor(def) and not seen[mapId] then
if visited[mapId] and def and not seen[mapId]
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
table.insert(items, {
value = mapId,
+96 -23
View File
@@ -132,6 +132,10 @@ function PartyMenu.new(game, opts)
self.onSwitch = opts.onSwitch
self.onCancel = opts.onCancel
self.pickOnly = opts.pickOnly
-- TM/HM teaching: opts.tmhm = { move, kind } switches the list to Gen 1's
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
self.tmhm = opts.tmhm
self.forceSwitch = opts.forceSwitch
self.battle = opts.battle
self.party = opts.party -- link battles pass their clamped copies
@@ -178,8 +182,15 @@ function PartyMenu:update(dt)
elseif action == "switch" then
self.swapFrom = self.index
elseif action == "fly" then
-- FLY opens the TOWN MAP with a cursor over the visited fly towns,
-- not a plain text list (engine/menus/town_map.asm LoadTownMap_Fly).
-- flyTo (OverworldController) validates the fly-warp + runs the
-- departure/warp, so we just hand it the chosen mapId (#195).
local ow = self.game.overworld
self.game.stack:pop() -- close the party menu
Screens.push(self.game, "FlyMenu")
Screens.push(self.game, "TownMap", { fly = true, onFly = function(mapId)
if ow then ow:flyTo(mapId) end
end })
return
elseif action == "flash" then -- FLASH lights dark tunnels
-- start_sub_menus.asm .flash: PrintText _FlashLightsAreaText, then
@@ -304,21 +315,15 @@ function PartyMenu:update(dt)
-- 1/5 of the user's max HP to a chosen teammate
self.softboiledFrom = self.index
elseif action == "escape" then
-- DIG / TELEPORT both warp to the last Pokémon Center town
-- (wLastBlackoutMap, special_warps.asm escape warp); .dig/.teleport
-- end with GBPalWhiteOutWithDelay3 + jp .goBackToMap
-- DIG / TELEPORT warp to the last Pokémon Center TOWN (wLastBlackoutMap,
-- special_warps.asm escape warp). pokered's .dig/.teleport spin the
-- player up (LeaveMapAnim), white/fade out, then land it; this port
-- lands OUTSIDE the town PC door like Fly (#196). beginTeleportOut
-- centralizes the spin -> fade -> warp so BagMenu's ESCAPE ROPE shares
-- the exact departure; the fade + warp fire when the spin ends.
local ow = self.game.overworld
local heal = self.game.save.lastHeal
local Transition = require("src.render.Transition")
self.game.stack:pop()
if ow and heal then
self.game.stack:push(Transition.whiteFlash(self.game, nil, function()
require("src.core.Sound").play(self.game.data, "Teleport_Exit1")
-- EnterMapAnim on arrival (HandleFlyWarpOrDungeonWarp sets
-- BIT_FLY_WARP); blackouts must not pass arrive="teleport"
ow:warpToHealPoint(nil, { arrive = "teleport" })
end))
end
if ow then ow:beginTeleportOut() end
return
end
self.submenu = nil
@@ -436,6 +441,30 @@ function PartyMenu:update(dt)
end
end
-- The bottom-of-screen context message for the current menu state
-- (pokered engine/menus/party_menu.asm PartyMenuMessage / RedrawPartyMenu_):
-- the party menu always prints a message in the bottom text box. With the
-- normal message id that is PartyMenuBattleText ("Bring out which POKéMON?")
-- when IsInBattle else PartyMenuNormalText ("Choose a POKéMON."); the swap /
-- item / TM-HM ids print their own strings, which draw() handles inline.
-- Pure (no side effects) so drivers can assert it. #147
function PartyMenu:bottomMessage()
if self.swapFrom then
return "Move to where?"
elseif self.softboiledFrom or self.pickOnly then
return "Use on which one?"
elseif self.tmhm then
return self.game.data.text._PartyMenuUseTMText
or "Use TM on which\nPOKéMON?"
elseif self.battle then
return self.game.data.text._PartyMenuBattleText
or "Bring out which\nPOKéMON?"
else
return self.game.data.text._PartyMenuNormalText
or "Choose a POKéMON."
end
end
function PartyMenu:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
@@ -462,16 +491,34 @@ function PartyMenu:draw()
-- PrintLevel overwrites the <LV> tile with the third digit
Font.draw(tostring(mon.level), 104, y)
end
if mon.hp <= 0 then
Font.draw("FNT", 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
if self.tmhm then
-- TM/HM teaching menu (engine/menus/party_menu.asm PrintPartyMenu):
-- the second row shows the inline "ABLE" / "NOT ABLE" learnability
-- strings in place of the HP bar and status, decided by CanLearnTM.
-- The learnset scan mirrors ItemEffects.use so the display can never
-- disagree with the actual teach. #210
local can = false
for _, m in ipairs(def.tmhm or {}) do
if m == self.tmhm.move then can = true break end
end
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
if can then
Font.draw("ABLE", 120, y + 8)
else
Font.draw("NOT ABLE", 88, y + 8)
end
else
if mon.hp <= 0 then
Font.draw("FNT", 136, y)
elseif mon.status then
Font.draw(mon.status, 136, y)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
end
-- the colored tile HP bar (DrawHP2 + SetPartyMenuHPBarColor)
love.graphics.setColor(1, 1, 1, 1)
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, mon)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 104, y + 8)
if i == self.index then
Font.drawCode(Theme.cursor, 0, y)
end
@@ -483,8 +530,34 @@ function PartyMenu:draw()
Font.draw("Move to where?", 8, 136)
elseif self.softboiledFrom then
Font.draw("Use on which one?", 8, 136)
elseif self.tmhm then
-- "Use TM on which\nPOKeMON?" in the standard bottom text box
-- (party_menu.asm keeps the message box for the TM/HM menu); box + line
-- geometry match TextBox's default (rows 12-17, text on rows 14/16). #210
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local prompt = self.game.data.text._PartyMenuUseTMText
or "Use TM on which\nPOKéMON?"
local ly = 112
for line in (prompt .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, ly)
ly = ly + 16
end
elseif self.pickOnly then
Font.draw("Use on which one?", 8, 136)
else
-- default field party menu (StartMenu) and the battle voluntary-switch
-- (BattleState:openParty): Gen1 prints PartyMenuNormalText / PartyMenuBattleText
-- in the standard bottom text box (party_menu.asm PartyMenuMessage), not
-- plain bottom-row text. Box + line geometry match the #210 TM/HM case and
-- TextBox's default (rows 12-17, text on rows 14/16). #147
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
local ly = 112
for line in (self:bottomMessage() .. "\n"):gmatch("([^\n]*)\n") do
Font.draw(line, 8, ly)
ly = ly + 16
end
end
if self.submenu then
local n = #self.subItems
+5 -2
View File
@@ -106,8 +106,11 @@ local function sell(game)
onChoose = function(item)
local def = game.data.items[item.value]
-- only key items and HMs are unsellable (pokemart.asm IsKeyItem /
-- IsItemHM); zero-price items like ETHER sell for ¥0
if (def and def.keyItem) or item.value:find("^HM_") then
-- IsItemHM); zero-price items like ETHER sell for ¥0. An unknown id
-- (nil def) has no price, so treat it as unsellable too rather than
-- indexing nil below -- guards saves that already picked up a bogus
-- ITEM_NONE "0" from Blue's House before that pickup was fixed (#11).
if not def or def.keyItem or item.value:find("^HM_") then
list.footer = txt(game, "_PokemartUnsellableItemText",
"I can't put a\nprice on that.")
return
+9 -2
View File
@@ -5,6 +5,13 @@
-- A on page 2) closes.
local Font = require("src.render.Font")
-- status_screen.asm PrintMonType prints the type's DISPLAY name from the
-- TypeNames table, not the constant: species types are stored as pokered
-- constants (RomExtractor:typesById) and PSYCHIC's is "PSYCHIC_TYPE" (so it
-- won't collide with the PSYCHIC move), which would overflow the TYPE field.
-- TypeChart.displayName maps it back to "PSYCHIC", like HallOfFame and the
-- battle move-type box already do (#214).
local TypeChart = require("src.battle.TypeChart")
local SummaryMenu = {}
SummaryMenu.__index = SummaryMenu
@@ -99,10 +106,10 @@ function SummaryMenu:draw()
-- TYPE1/TYPE2/IDNo/OT column (10,9) with values indented (11,10)
drawLineBox(19, 9, 8, 6)
Font.draw("TYPE1/", 80, 72)
Font.draw(def.types[1] or "", 88, 80)
Font.draw(def.types[1] and TypeChart.displayName(def.types[1]) or "", 88, 80)
if def.types[2] then
Font.draw("TYPE2/", 80, 88)
Font.draw(def.types[2], 88, 96)
Font.draw(TypeChart.displayName(def.types[2]), 88, 96)
end
Font.draw("IDNo/", 80, 104)
-- the trainer ID is rolled at new game (SaveData.newGame) and
+101 -10
View File
@@ -7,6 +7,11 @@
-- the selected name in a banner up top, and the player's current
-- location blinking. List mode (townMap data missing): up/down through
-- an ordered list of fly towns instead. B closes.
--
-- Fly mode (opts.fly + opts.onFly, LoadTownMap_Fly): the same Kanto map,
-- but the cursor cycles ONLY the visited fly destinations (Up/Down, in fly
-- order), the banner reads "To <NAME>", and A calls onFly(mapId) to depart.
-- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font")
local Sound = require("src.core.Sound")
@@ -80,7 +85,10 @@ local function buildLocations(game)
local seen = {}
for _, mapId in ipairs(field.flyOrder or {}) do
local def = game.data.maps and game.data.maps[mapId]
if not seen[mapId] and def and Map.isOutdoor(def) then
-- accept the PLATEAU tileset too so Indigo Plateau shows on the
-- stale-asset list fallback, matching the fly-list filter (#203)
if not seen[mapId] and def
and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
local loc = { name = mapId:gsub("_", " ") }
table.insert(locs, loc)
@@ -119,6 +127,42 @@ local function markerXY(loc)
return loc.x * 8 + 16, loc.y * 8 + 8
end
-- the row-0 name banner; fly mode prefixes "To " like LoadTownMap_Fly
-- (engine/menus/town_map.asm prints the destination as "To <NAME>")
function TownMap:bannerText(loc)
return (self.fly and "To " or "") .. loc.name
end
-- Fly mode selection set (engine/menus/town_map.asm LoadTownMap_Fly): the
-- cursor cycles ONLY the visited fly destinations, in fly order, each landing
-- on its town square. Built from field.flyOrder filtered to visited outdoor
-- towns that have a fly-warp spot, deduped, reusing the grid loc so the cursor
-- lands on the town and its name shows in the banner.
local function buildFlyList(game, byMap)
local field = game.data.field or {}
local visited = game.save.visited or {}
local flyWarps = field.flyWarps or {}
local Map = require("src.world.Map")
local flyLocs, flyMapIds, seen = {}, {}, {}
for _, mapId in ipairs(field.flyOrder or {}) do
local def = game.data.maps and game.data.maps[mapId]
-- INDIGO_PLATEAU is a normal Fly spot (engine/menus/town_map.asm
-- LoadTownMap_Fly cycles it like any town), but its map uses tileset
-- "PLATEAU" not OVERWORLD, so Map.isOutdoor() alone dropped it from the
-- cursor even though it is visited and has a fly warp. Allow PLATEAU here
-- while the CAVERN/FACILITY dungeon escape spots that share flyOrder still
-- fail the gate and stay out (#203).
if not seen[mapId] and visited[mapId] and flyWarps[mapId]
and def and (Map.isOutdoor(def) or def.tileset == "PLATEAU") then
seen[mapId] = true
local loc = byMap[mapId] or { name = mapId:gsub("_", " ") }
table.insert(flyLocs, loc)
flyMapIds[#flyLocs] = mapId
end
end
return flyLocs, flyMapIds
end
-- opts.nestSpecies: the Pokédex AREA screen (LoadTownMap_Nest) --
-- blink a nest icon on every map whose wild slots hold the species
function TownMap.new(game, opts)
@@ -152,6 +196,26 @@ function TownMap.new(game, opts)
or "assets/generated/townmap/nest.png")
self.nestIcon = ok and img or nil
end
if opts.fly then
-- FLY picker (LoadTownMap_Fly): restrict the selectable set to the
-- visited fly towns so Up/Down cycle only those and A knows the mapId.
local flyLocs, flyMapIds = buildFlyList(game, self.byMap)
if #flyLocs > 0 then
self.fly = true
self.onFly = opts.onFly
self.locs = flyLocs
self.flyMapIds = flyMapIds
-- grid rendering needs coords on every entry; without them fall back to
-- the name list so the fly screen still works on stale asset builds
if self.mode == "grid" then
for _, loc in ipairs(flyLocs) do
if not (loc.x and loc.y) then self.mode = "list" break end
end
end
end
-- with nothing visited yet there is nowhere to fly: leave self.fly unset
-- so the screen degrades to a plain viewer (B closes)
end
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
@@ -199,7 +263,19 @@ function TownMap:update(dt)
self.game.stack:pop()
return
end
if self.nestSpecies then
if self.fly then
-- LoadTownMap_Fly: Up/Down cycle the visited destinations, A flies there,
-- B cancels (handled above). moveList walks self.locs, now the fly list.
if input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
local mapId = self.flyMapIds[self.sel]
self.game.stack:pop()
if mapId and self.onFly then self.onFly(mapId) end
return
elseif input:wasPressed("up") then self:moveList(-1)
elseif input:wasPressed("down") then self:moveList(1)
end
elseif self.nestSpecies then
if input:wasPressed("a") then
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
@@ -260,18 +336,28 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase)
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0.75, 0.1, 0.1, 1)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
-- blinking cursor on the selected location
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152).
if selected and self.blink % 16 < 10 then
local x, y = markerXY(selected)
if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x, y)
love.graphics.draw(self.bg.cursor, x - 4, y - 4)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", x + 0.5, y + 0.5, 7, 7)
@@ -281,7 +367,7 @@ function TownMap:draw()
-- the name strip on row 0 (DisplayTownMap: ClearScreenArea + name)
love.graphics.rectangle("fill", 0, 0, 160, 8)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 0) end
if selected then Font.draw(self:bannerText(selected), 8, 0) end
love.graphics.setColor(1, 1, 1, 1)
return
end
@@ -294,7 +380,9 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
love.graphics.setColor(0.75, 0.1, 0.1, 1)
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
@@ -317,7 +405,10 @@ function TownMap:draw()
end
Font.draw(loc.name, 24, y)
if loc == self.playerLoc and self.blink < 20 then
-- blinking marker on the player's current town
-- blinking marker on the player's current town; force the palette-safe
-- dark shade explicitly so the red-channel shade-remap keeps it
-- visible regardless of Font.draw's leftover color (#152)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", 24 + #loc.name * 8 + 6, y + 2, 4, 4)
end
end
@@ -327,7 +418,7 @@ function TownMap:draw()
-- name banner across the top
Font.drawBox(0, 0, 20, 3)
love.graphics.setColor(0, 0, 0, 1)
if selected then Font.draw(selected.name, 8, 8) end
if selected then Font.draw(self:bannerText(selected), 8, 8) end
love.graphics.setColor(1, 1, 1, 1)
end
+10
View File
@@ -204,6 +204,16 @@ function Map:isWalkableCell(cx, cy)
end
function Map:isGrassCell(cx, cy)
-- Off-map cells never count as tall grass (issue #217). cellTile
-- border-extends out-of-bounds coordinates with the map's borderBlock,
-- and some border blocks (e.g. ROUTE_1's block 11) have the grass tile
-- ($52 = 82) in their bottom row -- filler scenery, never standable
-- grass. During a map-connection seam step crossConnection parks the
-- player one cell before the entry point (cellY = -1 crossing Viridian
-- City -> Route 1), so without this guard the feet-overdraw painted an
-- animated grass tuft over the player's head for the whole step. pokered
-- only ever reads $52 from loaded map tiles, not the border filler.
if not self:inBounds(cx, cy) then return false end
local grass = self.tileset.grassTile
return grass ~= nil and self:cellTile(cx, cy) == grass
end
+139 -17
View File
@@ -222,17 +222,31 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
self.map.renderer:rebuild()
self.cutBlocks[mapId] = nil
end
-- Silph Co card key doors: the .blk layouts ship with the doorways
-- open; each floor's map script stamps the closed door block on load
-- until its unlock event is set (scripts/SilphCo2F.asm
-- SilphCo2FGateCallbackScript et al., closed blocks $54/$5f/$20)
-- Silph Co card key doors + Rocket Hideout elevator gates: the .blk
-- layouts ship with the doorways open; each floor's map script stamps
-- the closed door block on load until its unlock event is set
-- (scripts/SilphCo2F.asm SilphCo2FGateCallbackScript et al., closed
-- blocks $54/$5f/$20; scripts/RocketHideoutB1F.asm +
-- RocketHideoutB4F.asm ...DoorCallbackScript, closed blocks $54/$2d over
-- the lift doorway). A door opens on its single `event`, or on `events`
-- when every listed flag must be set (Rocket Hideout B4F's lift gate
-- needs both guard trainers beaten -- CheckBothEventsSet).
local closedDoors = FieldDefaults.fieldValue(Game.data, "cardKeyDoors",
"closedDoors")
local floorDoors = closedDoors and closedDoors[mapId]
if floorDoors then
local stamped = false
for _, door in ipairs(floorDoors) do
local want = Game.save.flags[door.event] and door.open or door.block
local open
if door.events then
open = true
for _, ev in ipairs(door.events) do
if not Game.save.flags[ev] then open = false break end
end
else
open = Game.save.flags[door.event]
end
local want = open and door.open or door.block
if self.map:blockAt(door.bx, door.by) ~= want then
self.map:setBlock(door.bx, door.by, want)
stamped = true
@@ -749,6 +763,27 @@ function OverworldState:update(dt)
end
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
-- mirror of Fly's flyAnim lead-in above. Only when the spin finishes does
-- warpToHealPoint push the fade + warp, so the arrival spin-down lands the
-- player OUTSIDE the last Pokemon Center door (#196). player.spinFrames
-- decrements in lockstep in Player:update, so the rising spin ends here too.
if self.teleportOut then
self.teleportOut.frames = self.teleportOut.frames - 1
if self.teleportOut.frames <= 0 then
local onDone = self.teleportOut.onDone
self.teleportOut = nil
self.player.spinning = false
self.player.spinFrames = nil
self.player.spinRise = nil
self.player.inputLocked = false
self:warpToHealPoint(onDone, { arrive = "teleport" })
return
end
end
-- delayed one-shot SFX (the teleport-in spin's second note)
if self.delaySfx then
self.delaySfx.frames = self.delaySfx.frames - 1
@@ -787,7 +822,7 @@ function OverworldState:update(dt)
-- escort then walks an extra tile before PlayerEntryMovementRLE, and
-- the player lands on desk Oak.
local scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote
or self.engaging or self.emote or self.teleportOut
if not scripted and not self.transitioning then
self:checkTrainerSight()
-- CheckFightingMapTrainers (home/trainers.asm) zeroes hJoyHeld and
@@ -795,7 +830,7 @@ function OverworldState:update(dt)
-- direction handling (JoypadOverworld runs the map script first) --
-- the player can never start another step after being spotted.
scripted = self.runner:isRunning() or #self.scriptMoves > 0
or self.engaging or self.emote
or self.engaging or self.emote or self.teleportOut
end
if not scripted and not self.transitioning then
self:handleInput()
@@ -854,6 +889,21 @@ function OverworldState:dirHeld()
or input:isDown("left") or input:isDown("right")
end
-- The warp cell the player WARPED IN on is inert until they physically step
-- off it: standing on it, or bonking a wall/edge from it, must not re-fire a
-- warp (CheckWarpsNoCollision's arrival-disable; see setMap where
-- warpEntryCell/justWarped are set and onStepComplete where they clear).
-- Both stand-still warp triggers -- the map-edge exit (checkEdgeExit) and the
-- blocked-step collision warp (handleInput) -- must consult this, or a corner
-- staircase whose warp tile sits on the map edge (Red's-house (7,1)) bounces
-- floors every input frame (issue #230).
function OverworldState:onWarpArrivalCell()
if self.justWarped then return true end
local entry = self.warpEntryCell
return entry ~= nil and self.player.cellX == entry.x
and self.player.cellY == entry.y
end
function OverworldState:handleInput()
local input = Game.input
@@ -875,10 +925,11 @@ function OverworldState:handleInput()
if self:checkBoulderPush(dir) then return end
end
local result, why = self.player:tryMove(dir, self.map, self.entities)
if result == "blocked" then
-- a collision while standing on a warp square fires the warp
-- when the extra check passes (CheckWarpsCollision: route-gate
-- doorways, dock entrances, ...)
-- a collision while standing on a warp square fires the warp when the
-- extra check passes (CheckWarpsCollision: route-gate doorways, dock
-- entrances, ...) -- but never on the inert cell we just warped in on
-- (issue #230), which the completed-step path guards the same way.
if result == "blocked" and not self:onWarpArrivalCell() then
local w = Warp.onCollision(self.map, Game.data.field.warpCarpets,
self.player.cellX, self.player.cellY, dir)
if w then
@@ -1009,6 +1060,11 @@ function OverworldState:checkEdgeExit(dir)
local w = Warp.onEdge(self.map, p.cellX, p.cellY, dir)
if w then
-- ...but not while still standing on the warp cell we just arrived on
-- (issue #230): fall through so pushing into the edge bonks (SFX +
-- walk-in-place) instead of instantly re-warping. A real step onto an
-- exit-carpet edge cleared warpEntryCell first, so those still fire.
if self:onWarpArrivalCell() then return false end
self:takeWarp(w.def)
return true
end
@@ -1256,6 +1312,37 @@ function OverworldState:flyTo(mapId)
self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end
-- Dig / Teleport / Escape Rope departure animation, then land OUTSIDE the
-- last Pokemon Center door like Fly (#196). pokered's _LeaveMapAnim
-- (engine/overworld/player_animations.asm) plays SFX_TELEPORT_EXIT_1 and
-- spins the player while it rises up off the map (PlayerSpinWhileMovingUp)
-- before the palettes fade; Fly's bird lead-in (flyTo/flyAnim) is the
-- analogous departure this mirrors. When the spin finishes (the teleportOut
-- countdown in OverworldState:update), warpToHealPoint pushes the fade + warp
-- with arrive="teleport" so the sprite spins back DOWN in front of the town
-- PC door. Shared by the party-menu DIG/TELEPORT action and BagMenu's
-- ESCAPE ROPE so all three animate identically.
function OverworldState:beginTeleportOut(onDone)
if not Game.save.lastHeal then
-- a save that has never visited a Pokemon Center has no heal point to
-- warp to; skip the animation entirely (matches the old guard that did
-- nothing when lastHeal was absent) instead of spinning into a nil warp
if onDone then onDone() end
return
end
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
self.player.surfing = false
self.player.inputLocked = true
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
self.player.spinning = true
self.player.spinTimer = 0
self.player.spinFrames = 48
self.player.spinTotal = 48
self.player.spinRise = true
self.teleportOut = { frames = 48, onDone = onDone }
end
function OverworldState:npcAtCell(cx, cy)
for _, npc in ipairs(self.npcs) do
if (npc.cellX == cx and npc.cellY == cy) or
@@ -1474,7 +1561,17 @@ function OverworldState:tryHiddenObject(fx, fy)
-- Pokémon Center PCs and other PC tiles
for _, h in ipairs(extras.pcTiles[self.map.id] or {}) do
if h.x == fx and h.y == fy and (not h.facing or h.facing == facing) then
self:openPC()
if self.map.id == "REDS_HOUSE_2F" then
-- The player's bedroom PC is the one location in Red/Blue whose PC
-- callback is OpenRedsPC (engine/events/hidden_objects/players_pc.asm),
-- which runs the PlayerPC predef directly -- item storage, no
-- SOMEONE'S/BILL'S PC main menu (DisplayPCMainMenu). Every other
-- pcTile is a Pokémon Center-style PC that shows the multi-PC menu. (#228)
require("src.core.Sound").play(Game.data, "Turn_On_PC")
Screens.push(Game, "PlayerPC")
else
self:openPC()
end
return true
end
end
@@ -2007,8 +2104,12 @@ function OverworldState:talkTo(npc)
return
end
-- item balls (object_event item argument)
if d.item then
-- item balls (object_event item argument). A payload id of "0" is
-- pokered's ITEM_NONE sentinel: the ROM object sets the 0x80 "has item"
-- bit but names item 0, so it is a plain text object, not an item ball
-- (e.g. Blue's House wall Town Map / walking Daisy, #11). Lua treats
-- the string "0" as truthy, so screen it out and fall through to text.
if d.item and d.item ~= "0" and d.item ~= 0 then
if not require("src.inventory.Bag").add(Game.save, d.item, 1) then
Game.stack:push(TextBox.new(Game, "You can't carry\nany more items!"))
return
@@ -3257,11 +3358,32 @@ function OverworldState:warpToHealPoint(onDone, opts)
-- HandleFlyWarpOrDungeonWarp + DisplayPlayerBlackedOutText both clear
-- BIT_ALWAYS_ON_BIKE (home/overworld.asm / home/text_script.asm)
Game.save.forcedBike = nil
if opts and opts.arrive == "teleport" then
local map, x, y = heal.map, heal.x, heal.y
local teleport = opts and opts.arrive == "teleport"
if teleport then
self.arriveWarp = "teleport"
-- Dig/Teleport/Escape Rope land OUTSIDE at the last Pokemon Center TOWN
-- door, like Fly (#196) -- NOT the interior heal cell a blackout returns
-- to. pret routes escape-warp and blackout both through wLastBlackoutMap
-- (both appear inside in front of the nurse), but this port has decided
-- the escape-warp destination is the town PC door. Prefer the canonical
-- Fly landing (field.flyWarps, one tile south of the PC door warp), else
-- the remembered outdoor door cell; fall back to the interior heal cell
-- only for an old save with no recorded outdoor.
local out = heal.outdoor
if out then
local fw = (Game.data.field.flyWarps or {})[out.id]
map = out.id
x = fw and fw.x or out.x
y = fw and fw.y or out.y
end
end
self:startWarpTo(heal.map, heal.x, heal.y, "down", onDone)
if heal.outdoor then
self:startWarpTo(map, x, y, "down", onDone)
-- Blackouts land at the interior heal cell, so re-point LAST_MAP exits at
-- the remembered town door. The teleport branch already lands ON that
-- outdoor map, so startWarpTo remembers it on the next exit; re-pointing
-- here would wrongly steer exits away from where the player now stands.
if heal.outdoor and not teleport then
self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y)
end
end
+33 -1
View File
@@ -61,16 +61,26 @@ function Player:tryMove(dir, map, entities)
if self.facing ~= dir then
self.facing = dir
self.turnTimer = self.turnFrames or TURN_FRAMES
self.bumpFrames = nil -- turning to a new facing ends any wall-bonk cycle
return "turned"
end
if self.turnTimer > 0 then return nil end
local ok, why = Collision.canMove(map, entities, self, dir)
if not ok then
-- Gen1: a blocked step still animates the player walking in place --
-- the collision path spends the step's worth of frames running
-- UpdateSprites before returning control, so the legs cycle without
-- the cell changing (home/overworld.asm collision handling; issue
-- #230). Re-armed every frame the direction is held into the wall;
-- Player:update ticks the walk clock while it counts down, so releasing
-- returns to the standing pose within a step's length.
self.bumpFrames = self.stepFrames or STEP_FRAMES
return "blocked", why
end
local tx, ty = Collision.target(self.cellX, self.cellY, dir)
self.targetX, self.targetY = tx, ty
self.moving = true
self.bumpFrames = nil -- a real step supersedes any in-place bonk
self.progress = 0
-- the bicycle doubles walking speed (8 frames per step)
local save = require("src.core.Game").save
@@ -97,9 +107,19 @@ function Player:update()
if self.spinFrames <= 0 then
self.spinFrames = nil
self.spinDrop = nil
self.spinRise = nil -- teleport-out departure lift (#196)
self.spinning = false
end
end
-- wall-bonk walk-in-place (issue #230): while pushing into a wall the
-- collision path keeps the walk clock running without moving the cell,
-- so the sprite animates against the wall. Guarded on not-moving so a
-- real step (which clears bumpFrames and advances animClock itself
-- below) can never double-tick the leg cadence.
if not self.moving and self.bumpFrames and self.bumpFrames > 0 then
self.bumpFrames = self.bumpFrames - 1
self.animClock = (self.animClock or 0) + 1
end
if not self.moving then return false end
local stepLen = self.stepFramesCur or self.stepFrames or STEP_FRAMES
self.progress = self.progress + 1
@@ -132,7 +152,12 @@ function Player:facingCell()
end
function Player:walkPhase()
if not self.moving and not self.stepLanded then return 0 end
-- moving, the land-frame after a completed step, or an active wall-bonk
-- (issue #230) animate; a standing sprite otherwise
if not self.moving and not self.stepLanded
and not (self.bumpFrames and self.bumpFrames > 0) then
return 0
end
-- walk frame during the middle of each 16-frame animation cycle
local p = (self.animClock or self.progress) % 16
return (p >= 4 and p < 12) and 1 or 0
@@ -183,6 +208,13 @@ function Player:pose()
-- (EnterMapAnim PlayerSpinWhileMovingDown)
if self.spinFrames and self.spinDrop then
py = py - math.floor(self.spinFrames * 24 / (self.spinTotal or 64))
elseif self.spinFrames and self.spinRise then
-- Dig/Teleport/Escape-Rope departures spin the sprite UP out of the
-- map before the fade (LeaveMapAnim PlayerSpinWhileMovingUp) -- the
-- mirror of the arrival spin-down: the lift grows from 0 as spinFrames
-- counts down to 0 (#196), opposite sign to spinDrop above.
local total = self.spinTotal or 64
py = py - math.floor((total - self.spinFrames) * 24 / total)
end
end
local sprite = (self.surfing and self.surfSprite)
@@ -0,0 +1,173 @@
-- Driver: reproduce #216 - "Boosted EXP text cut off".
--
-- A traded mon's EXP gain prints a 3-line message:
-- "<mon> gained\na boosted\v<N> EXP. Points!"
-- (engine/battle/experience.asm _GainedText/_BoostedText/_ExpPointsText;
-- _BoostedText ends in the CONT code \v = char 11, "a boosted\011" in the
-- extracted data/generated/text.lua). The in-battle message box only has
-- room for two lines (rows y=112 and y=128), so before the fix the third
-- line was drawn at y=144 -- one row past the 160x144 screen -- and was
-- never seen: a single A press dismissed the whole message and the wild win
-- popped straight back to the overworld, so the boosted amount was invisible.
--
-- Gen1-correct behavior (home/text.asm ContText): the box scrolls a 2-line
-- window, waiting for A/B on the \v (drawing the blinking ▼ arrow), then
-- scrolls "a boosted" up to the top row and types "<N> EXP. Points!" on the
-- bottom row (y=128) -- a VISIBLE row.
--
-- The driver builds a deterministic traded RATTATA, KOs a weak wild PIDGEY
-- with no level-up, then renders the battle into a clean offscreen canvas
-- while capturing Font.drawCode, and asserts the encoded "EXP. Points!"
-- glyph run lands on a visible row (y <= 130), not off-screen at y=144.
-- Fails on the buggy build (amount only ever drawn at y=144); passes once
-- the box scrolls.
--
-- Run:
-- SHOT_DIR=/tmp/bug216 POKEPORT_IDENTITY=bug216 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_boosted_exp_bug216_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local Font = require("src.render.Font")
local BattleState = require("src.battle.BattleState")
-- deterministic traded RATTATA :L8; exp pinned to the L8 floor so a single
-- weak wild KO (~22 boosted exp) stays below the L9 threshold (no level-up
-- to muddy the message). DVs pinned to max so numbers are stable per run.
local rattata = Pokemon.new(game.data, "RATTATA", 8, function(_, b) return b end)
local def = game.data.pokemon.RATTATA
rattata.exp = Growth.expForLevel(def.growthRate, 8, game.data.growth_rates)
rattata.traded = true -- the flag real trades/link set (Commands.lua / Protocol.lua)
game.save.party = { rattata }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
-- weak enemy: 1 HP so the first move KOs, speed 1 so RATTATA always moves
-- first, rng pinned low so every accuracy roll hits (Damage.accuracyRoll:
-- rng(0,255) < acc)
local battle = BattleState.newWild(game, "PIDGEY", 2)
battle.onFinish = function() end
battle.rng = function(a, _) return a end
battle.enemy.mon.hp = 1
battle.enemy.mon.stats.speed = 1
ow:pushBattle(battle)
-- Render the battle into a clean offscreen 160x144 canvas while recording
-- every Font.drawCode(code, x, y). Returns true iff `targetCodes` appear
-- as a contiguous left-to-right run on a row at y <= maxY. The off-screen
-- amount row (y=144) is excluded, so the buggy build never counts as
-- "shown". BattleState:draw runs its own canvas pipeline internally and
-- restores the caller's canvas (see battle_hpbar_gbc_bug229_test.lua).
local canvas = love.graphics.newCanvas(160, 144)
local function seqVisible(targetCodes, maxY)
local rec = {}
local orig = Font.drawCode
Font.drawCode = function(code, x, y)
rec[#rec + 1] = { code, x, y }
return orig(code, x, y)
end
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
local okDraw = pcall(function() battle:draw() end)
love.graphics.setCanvas()
Font.drawCode = orig
if not okDraw then return false end
-- group glyphs by row, sort left-to-right, join codes into a delimited
-- string, and search each visible row for the target subsequence
local byY = {}
for _, g in ipairs(rec) do
if g[3] <= maxY then
byY[g[3]] = byY[g[3]] or {}
table.insert(byY[g[3]], g)
end
end
local tparts = {}
for _, c in ipairs(targetCodes) do tparts[#tparts + 1] = tostring(c) end
local needle = "," .. table.concat(tparts, ",") .. ","
for _, list in pairs(byY) do
table.sort(list, function(a, b) return a[2] < b[2] end)
local cs = {}
for _, g in ipairs(list) do cs[#cs + 1] = tostring(g[1]) end
if ("," .. table.concat(cs, ",") .. ","):find(needle, 1, true) then
return true
end
end
return false
end
local amountCodes = Font.encode("EXP. Points!")
-- mash through the intro to the FIGHT menu, then FIGHT -> move slot 1
-- (TACKLE) -> KO (mirrors battle_levelup_hpbar_bug224_test.lua)
for _ = 1, 300 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
if battle.phase ~= "menu" then error("bug216: never reached the FIGHT menu") end
U.tap(game, "a") -- FIGHT
for _ = 1, 60 do
if battle.phase == "moveSelect" then break end
U.wait(1)
end
if battle.phase ~= "moveSelect" then error("bug216: never reached move select") end
U.tap(game, "a") -- TACKLE -> KO
-- Phase A: advance to the boosted-EXP message (contains both "boosted"
-- and "EXP. Points!"). Break the frame it becomes current, before any A
-- press could scroll past it.
local function isBoosted()
local c = battle.current
return c and c.text and c.text:find("EXP. Points!", 1, true)
and c.text:find("boosted", 1, true)
end
local reached = false
for _ = 1, 900 do
if isBoosted() then reached = true break end
if game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(2)
end
if not reached then
error("bug216: never reached the boosted-EXP message (battle ended early)")
end
U.log("boosted message: " ..
(tostring(battle.current.text):gsub("[\n\v]", "|")))
-- let the first two lines type out, then capture the cut-off
-- "gained / a boosted" box (both builds show this; the buggy build has no
-- visible third row)
U.wait(40)
U.shot(game, DIR .. "/bug216_boosted.png")
-- Phase B: the amount must become visible on a real row. Each iteration
-- renders offscreen and checks; then taps A -- which scrolls the CONT
-- window on the fixed build, and (once fully typed) dismisses the message
-- on the buggy build.
local shown = false
for _ = 1, 120 do
if seqVisible(amountCodes, 130) then shown = true break end
if game.stack:top() ~= battle then break end
U.tap(game, "a")
U.wait(4)
end
if not shown then
error("bug216: 'EXP. Points!' amount never shown on a visible row " ..
"(drawn off-screen at y=144); the box did not scroll to the amount")
end
U.shot(game, DIR .. "/bug216_amount.png")
-- no level-up muddied the message
if battle.player.mon.level ~= 8 then
error("bug216: expected level 8, got " .. tostring(battle.player.mon.level))
end
U.log("bug216 OK: boosted EXP amount shown on a visible row, level still 8")
U.wait(4)
end
@@ -0,0 +1,104 @@
-- Regression test for issue #229 ("GSC palette black full health").
--
-- In RED++ ("Gen 2 / GSC-style") COLORS mode the full-health (green-band)
-- in-battle HP bar rendered as a solid black rectangle. Root cause is in
-- src/render/HudTiles.lua drawHPBar: it pre-tinted the fill with GREENBAR's
-- fill color {0,189,0} (red channel 0), zeroing the red channel of every bar
-- pixel; the battle zone shade-remap shader (PaletteFX.shader, keyed ONLY on
-- the red channel) then mapped every zeroed-red pixel to color 3 = black.
-- Red/orange bands survived because REDBAR {247,0,0} / YELLOWBAR {247,165,0}
-- keep a nonzero red channel. SGB ('gbc') mode was unaffected because
-- PaletteFX.pack({}) returns nil there, so the fill was already drawn gray.
--
-- Gen1-correct behavior: the DMG hardware bar is one gray shade recolored by
-- the SGB region palette (home/pokemon.asm DrawHPBar + engine/gfx/palettes.asm
-- SetPal_Battle + data/sgb/sgb_packets.asm BlkPacket_Battle), never a per-pixel
-- repaint. This driver forces RED++, enters a full-HP wild battle, screenshots
-- the intro + action menu, then renders the battle into a clean 160x144 canvas
-- and asserts the player AND enemy HP-bar fill bands are green (not black).
--
-- Run:
-- SHOT_DIR=/tmp/bug229 POKEPORT_IDENTITY=bug229 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_hpbar_gbc_bug229_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "."
local PaletteFX = require("src.render.PaletteFX")
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
-- save.options.colors, so a bare setMode would get reverted to the default.
game.save.options = game.save.options or {}
game.save.options.colors = "redpp"
PaletteFX.setMode("redpp")
-- BULBASAUR :L5 is 19/19 -> full HP -> green band (matches the issue shot).
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "RATTATA", 3) -- full HP -> green
battle.onFinish = function() end
ow:pushBattle(battle)
U.wait(220)
U.shot(game, DIR .. "/bug229_redpp_intro.png")
-- Advance the intro text to the action menu deterministically (phase flips
-- to "menu" at BattleState:1111/1243); stop before a menu tap enters FIGHT.
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(4)
U.shot(game, DIR .. "/bug229_redpp_menu.png")
-- Programmatic assertion: render the battle into a clean offscreen 160x144
-- canvas -- BattleState:draw runs its own SGB zone pass internally, so this
-- is the real colorized output (verified pixel-identical to the on-screen
-- capture). Sample the CENTER fill rows of each bar (an 8px bar tile is
-- white frame rows / fill rows / white frame rows, so the middle rows are
-- the fill). Before the fix the fill is ~ (0,0,0); after the fix it is
-- ~ (0,0.74,0) (GREENBAR fill {0,189,0}/255).
if love and love.graphics and love.graphics.newCanvas and battle.phase == "menu" then
local canvas = love.graphics.newCanvas(160, 144)
love.graphics.setCanvas(canvas)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
battle:draw()
love.graphics.setCanvas()
local id = canvas:newImageData()
do local dbg = id:encode("png"); local f = io.open(DIR .. "/bug229_offscreen.png", "wb"); if f then f:write(dbg:getString()); f:close() end end
local function green(r, g, b) return g > 0.4 and g > r and g > b end
-- returns worst (lowest-green) fill pixel across the center rows sampled
local function worstFill(x, ys)
local wr, wg, wb = 1, 1, 1
for _, y in ipairs(ys) do
local r, g, b = id:getPixel(x, y)
if g < wg then wr, wg, wb = r, g, b end
end
return wr, wg, wb
end
-- player bar: drawHPBar tile (10,9) -> fill x 96..143; fill rows y 74..77
local pr, pg, pb = worstFill(120, { 75, 76 })
-- enemy bar: drawHPBar tile (2,2) -> fill x 32..79; fill rows y 18..21
local er, eg, eb = worstFill(56, { 19, 20 })
U.log(string.format("player fill rgb = %.2f %.2f %.2f", pr, pg, pb))
U.log(string.format("enemy fill rgb = %.2f %.2f %.2f", er, eg, eb))
if not (green(pr, pg, pb) and green(er, eg, eb)) then
error(string.format(
"issue #229: RED++ HP bar fill not green (player %.2f/%.2f/%.2f enemy %.2f/%.2f/%.2f)",
pr, pg, pb, er, eg, eb))
end
U.log("issue #229 PASS: green HP bar fill in RED++")
else
error("issue #229 driver: never reached the battle action menu")
end
U.wait(4)
end
@@ -0,0 +1,125 @@
-- Driver: reproduce #224 - "Level up health bar inches down".
--
-- On a level-up during battle, Gen 1 (engine/battle/experience.asm) raises
-- the mon's current HP by (newMaxHP - oldMaxHP) and redraws the active
-- battler's HP bar UP to reflect the higher current HP. Our data layer is
-- correct (Experience.lua:84 applies the current-HP delta), but the on-screen
-- numerator - the battler's shownHP (the value the HUD bar/number use) - was
-- never advanced on level-up, while the denominator (mon.stats.hp) jumped
-- instantly. So the drawn fill FRACTION fell (e.g. 8/20 -> 8/22) instead of
-- rising to 10/22.
--
-- Setup: a SQUIRTLE at L5 with 8/oldMax HP and exp one point below the level-6
-- threshold; a single wild KO crosses it. The driver asserts the player HP
-- bar's shownHP rises to the new current HP DURING the level-up messages
-- (before the menu-phase safety net at BattleState.lua:1099 would mask it).
-- Fails on the buggy build (shownHP stuck at 8); passes once fixed.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Growth = require("src.pokemon.Growth")
local BattleState = require("src.battle.BattleState")
-- deterministic mon: pin DVs to the max so the HP numbers are identical
-- across the before/after runs (Stats.randomDVs consumes rng(0,15))
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 5, function(_, b) return b end)
local def = game.data.pokemon.SQUIRTLE
local oldHP = 8
squirtle.hp = oldHP -- partly depleted so the bar is well under full
-- one point below the level-6 exp threshold: a single kill levels up once
squirtle.exp = Growth.expForLevel(def.growthRate, 6, game.data.growth_rates) - 1
game.save.party = { squirtle }
local oldMax = squirtle.stats.hp
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
-- weak enemy: 1 HP so the first TACKLE KOs; speed pinned to 1 so SQUIRTLE
-- always moves first (no enemy turn to muddy the HP math); rng pinned to
-- the low end so every roll hits (Damage.accuracyRoll: rng(0,255) < acc)
local battle = BattleState.newWild(game, "SLOWPOKE", 2)
battle.onFinish = function() end
battle.rng = function(a, _) return a end
battle.enemy.mon.hp = 1
battle.enemy.mon.stats.speed = 1
ow:pushBattle(battle)
-- mash through the intro to the FIGHT menu
for _ = 1, 240 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(3)
end
if battle.phase ~= "menu" then error("bug224: never reached the FIGHT menu") end
-- HP box at L5: the menu safety net snaps shownHP to mon.hp, so 8/oldMax
U.shot(game, DIR .. "/bug224_menu.png")
-- FIGHT -> first move (TACKLE, slot 1) -> KO
U.tap(game, "a") -- FIGHT
for _ = 1, 60 do
if battle.phase == "moveSelect" then break end
U.wait(1)
end
if battle.phase ~= "moveSelect" then error("bug224: never reached move select") end
U.tap(game, "a") -- TACKLE
-- Monitor the level-up messages. The bar must animate from oldHP toward
-- the new current HP while phase == 'messages' (before finish/menu-snap).
local maxShown = oldHP
local caughtUp = false
local shotGrew, shotRisen = false, false
for _ = 1, 600 do
U.wait(1)
U.tap(game, "a") -- advance text / dismiss the stat box (never skips the
-- time-based drain), so we reach the HP-bar redraw
if battle.phase == "messages" and battle.player.mon.level >= 6 then
local sh = battle.player.shownHP or battle.player.mon.hp
maxShown = math.max(maxShown, sh)
if not shotGrew then
-- first L6 messages frame: buggy build already shows the shrunk bar
-- (8/newMax) here, and it never recovers
U.shot(game, DIR .. "/bug224_grew.png")
shotGrew = true
end
if not shotRisen and sh >= oldHP + 1.0 then
-- the bar has climbed at least a full HP: capture it mid-rise
U.shot(game, DIR .. "/bug224_hpbar.png")
shotRisen = true
end
if sh >= squirtle.hp - 0.5 then
caughtUp = true
break
end
end
if game.stack:top() ~= battle then break end -- battle finished/popped
end
-- data-layer sanity (Experience.lua): one level gained, max HP grew, and
-- current HP rose by the max-HP delta
if battle.player.mon.level ~= 6 then
error("bug224: expected level 6, got " .. tostring(battle.player.mon.level))
end
local newMax = squirtle.stats.hp
if not (newMax > oldMax) then
error("bug224: max HP did not grow (oldMax=" .. tostring(oldMax) ..
", newMax=" .. tostring(newMax) .. "); repro is meaningless")
end
local expectHP = math.min(newMax, oldHP + (newMax - oldMax))
if squirtle.hp ~= expectHP then
error("bug224: current HP wrong: got " .. tostring(squirtle.hp) ..
", expected " .. tostring(expectHP))
end
-- THE BUG: the on-screen bar must have risen to the new current HP during
-- the messages phase. On the buggy build shownHP stays stuck at oldHP.
if not caughtUp then
error("bug224: HP bar did not rise on level-up - shownHP stuck at " ..
tostring(maxShown) .. " (oldHP=" .. tostring(oldHP) ..
"), mon.hp=" .. tostring(squirtle.hp) .. "/" .. tostring(newMax))
end
U.log("bug224 OK: L6, HP " .. tostring(squirtle.hp) .. "/" .. tostring(newMax) ..
", bar rose from " .. tostring(oldHP) .. " to " .. tostring(maxShown))
end
@@ -0,0 +1,150 @@
-- Regression test for issue #207 ("Back of sprite only showing outline").
--
-- In the forced-mono display modes (OG / OG INV / CLASSIC) a warm-palette
-- mon's battle pic (e.g. CHARMANDER, SGB palette REDMON) rendered as a bare
-- black outline on white, its two mid shades gone, while cool-palette mons
-- (SQUIRTLE, CYANMON) kept full shading. Root cause is a double shade-remap:
-- BattleState bakes each pic ONCE with the species' SGB colors, then draws it
-- onto the UI canvas; in these modes BattleState exposes no SGB zones, so
-- Renderer:endFrame invents a whole-screen GRAYS zone (PaletteFX.ensureZones)
-- and runs the ENTIRE colored battle frame through PaletteFX.shader() a SECOND
-- time. That shader keys the DMG shade off the red channel
-- (r>0.83?c0:r>0.5?c1:r>0.17?c2:c3); REDMON's two mid shades have red 1.0 and
-- 0.839 -- BOTH > 0.83 -- so they collapse into shade 0 (the white paper),
-- leaving only the near-black outline as shade 3. CYANMON's reds (0.678,
-- 0.451) land in the c1/c2 buckets, which is why blue mons were unaffected.
--
-- Gen1-correct behavior: mon pics are 2bpp 4-shade tiles (gfx/pokemon/back,
-- gfx/pokemon/front); all four shades must be visible, and the same grayscale
-- palette that renders SQUIRTLE renders CHARMANDER. The fix draws the pics as
-- raw DMG grays in these modes so the whole-screen remap recolors 255->c0,
-- 170->c1, 85->c2, 0->c3 -- all four shades survive.
--
-- This driver forces OG, gives the player a CHARMANDER (the failing warm
-- palette), enters a wild SQUIRTLE battle, and screenshots the exact action
-- menu the reporter shows. The assertion replays the on-screen two-stage
-- pipeline into a clean 160x144 canvas (BattleState:draw, then the whole-screen
-- GRAYS remap) and asserts the player CHARMANDER back-pic interior contains
-- mid-gray shades (not outline-only), with the enemy SQUIRTLE front interior as
-- an always-passing control.
--
-- Run:
-- SHOT_DIR=/tmp/bug207 POKEPORT_IDENTITY=bug207 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/battle_mono_sprite_bug207_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "."
local PaletteFX = require("src.render.PaletteFX")
-- Set the SAVED option, not just the live mode: Game:applyOptions re-reads
-- save.options.colors, so a bare setMode would get reverted to the default.
game.save.options = game.save.options or {}
game.save.options.colors = "og"
PaletteFX.setMode("og")
-- CHARMANDER :L5 -- SGB palette REDMON, the warm palette that collapsed.
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "CHARMANDER", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
local BattleState = require("src.battle.BattleState")
local battle = BattleState.newWild(game, "SQUIRTLE", 5)
battle.onFinish = function() end
ow:pushBattle(battle)
U.wait(220)
U.shot(game, DIR .. "/bug207_og_intro.png")
-- Advance the intro text to the action menu deterministically (phase flips
-- to "menu" once "Go! CHARMANDER!" has swapped in the species back pic).
for _ = 1, 40 do
if battle.phase == "menu" then break end
U.tap(game, "a")
U.wait(6)
end
U.wait(4)
-- The reporter's exact screen: the FIGHT/PKMN/ITEM/RUN action menu with the
-- player's CHARMANDER back pic fully visible bottom-left.
U.shot(game, DIR .. "/bug207_og_menu.png")
if not (love and love.graphics and love.graphics.newCanvas
and battle.phase == "menu") then
error("issue #207 driver: never reached the battle action menu")
end
-- Replay the on-screen forced-mono pipeline into an offscreen 160x144 canvas.
-- Stage 1 is BattleState's own colorized frame (its internal SGB zone pass
-- plus the mon pics drawn via picImage). Stage 2 is Renderer:endFrame's
-- whole-screen remap for a state with no SGB zones: ensureZones -> whole(GRAYS),
-- then blit sends GRAYS through the shade shader over the FULL frame. This is
-- pixel-faithful to the presented window (the U.shot captures above), but in
-- clean 160x144 canvas space so the sample boxes are resolution-independent.
local g = love.graphics
local shader = PaletteFX.shader()
local prev = g.getCanvas()
local a = g.newCanvas(160, 144)
local b = g.newCanvas(160, 144)
g.setCanvas(a)
g.clear(1, 1, 1, 1) -- battle letterbox is white (letterboxWhite)
g.setColor(1, 1, 1, 1)
battle:draw()
g.setCanvas(b)
g.clear(0, 0, 0, 1)
g.setShader(shader)
PaletteFX.sendColors(shader, PaletteFX.GRAYS)
g.setColor(1, 1, 1, 1)
g.draw(a, 0, 0)
g.setShader()
g.setCanvas(prev)
local id = b:newImageData()
do
local png = id:encode("png")
local f = io.open(DIR .. "/bug207_og_offscreen.png", "wb")
if f then f:write(png:getString()); f:close() end
end
-- Count mid-gray pixels in a box. The forced-mono frame is neutral gray:
-- shade 0 = white (~1.0), shade 3 = black (~0.0), and the two MID shades are
-- ltgray (170/255 = 0.667) and dkgray (85/255 = 0.333). A mid shade exists
-- only when all four DMG shades survived the remap; an outline-only pic has
-- pure white + pure black and zero mid pixels.
local function midCount(x0, y0, x1, y1)
local n = 0
for y = y0, y1 do
for x = x0, x1 do
local r = id:getPixel(x, y)
if r > 0.18 and r < 0.82 then n = n + 1 end
end
end
return n
end
-- Player CHARMANDER back pic: hlcoord 1,5 (x=8), feet at y=96 -- interior
-- box well inside the body, clear of the white matte and the near-black
-- outline, and clear of the bottom-right HUD/HP bar.
local backMid = midCount(12, 50, 52, 92)
-- Enemy SQUIRTLE front pic: 7x7 slot at hlcoord 12,0 (x~96) -- control that
-- always keeps its mid shades (CYANMON reds land in the c1/c2 buckets).
local enemyMid = midCount(104, 8, 148, 48)
U.log(string.format("player back mid-gray px = %d ; enemy front mid-gray px = %d",
backMid, enemyMid))
if enemyMid <= 20 then
error(string.format(
"issue #207 driver: control failed -- enemy SQUIRTLE front has no "
.. "mid-gray (%d); sample box or pipeline is wrong", enemyMid))
end
if backMid <= 20 then
error(string.format(
"issue #207: OG-mode CHARMANDER back pic is outline-only -- interior "
.. "has %d mid-gray px (expected the full 4-shade grayscale, like the "
.. "enemy front's %d)", backMid, enemyMid))
end
U.log(string.format(
"issue #207 PASS: OG-mode CHARMANDER back keeps its mid shades "
.. "(%d mid-gray px, control enemy %d)", backMid, enemyMid))
U.wait(4)
end
+115
View File
@@ -0,0 +1,115 @@
-- Driver: issue #11 -- Blue's House wall Town Map phantom pickup + the
-- sell crash it causes.
--
-- In pokered the framed Town Map on the wall of Blue's House
-- (data/maps/objects/BluesHouse.asm BLUESHOUSE_TOWN_MAP) is a plain
-- text object -- talking to it prints _BluesHouseTownMapText
-- ("It's a big map! This is useful!") and nothing enters the bag.
-- The ROM object carries the 0x80 "has item payload" bit with a payload
-- id of 0 (ITEM_NONE), which our extractor copies through as item="0".
-- The engine's item-ball branch treated the truthy string "0" as a real
-- item, so pressing A picked up a bogus item "0" -- and selling that
-- unknown id later hard-crashed ShopMenu.sell (nil item def).
--
-- This driver reproduces both halves and asserts the correct Gen1
-- behavior, so it fails while the bug exists and passes once fixed.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Bag = require("src.inventory.Bag")
local Screens = require("src.ui.Screens")
local pass, fail = 0, 0
local function check(ok, label)
if ok then pass = pass + 1; U.log("PASS", label)
else fail = fail + 1; U.log("FAIL", label) end
end
-- defensive: keep the pre-fix "<name> found 0!" box from erroring on a
-- nil player name (the box never appears after the fix)
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
-- ---- Part 1: talking to the wall Town Map must not pick anything up ----
U.teleport(game, "BLUES_HOUSE", 2, 6, "up")
U.wait(5)
local wallmap
for _, n in ipairs(game.overworld.npcs) do
if n.def and n.def.name == "BLUESHOUSE_TOWN_MAP" then wallmap = n end
end
check(wallmap ~= nil, "wall Town Map object present")
if wallmap then
game.overworld:talkTo(wallmap)
U.wait(8)
U.shot(game, DIR .. "/bh_1_wallmap.png")
-- correct Gen1 behavior: nothing enters the bag
check(game.save.inventory["0"] == nil, "no phantom item '0' in bag")
check((game.save.inventory["0"] == nil) and (game.save.bagOrder == nil
or not (function()
for _, id in ipairs(game.save.bagOrder) do
if id == "0" then return true end
end
return false
end)()), "bag order has no '0' entry")
-- dismiss whatever box is up
for _ = 1, 4 do U.tap(game, "b"); U.wait(3) end
end
-- ---- Part 2: selling an unknown id must not crash ----------------------
-- Seed a corrupted bag (a save that already picked up "0" before the fix,
-- or any legacy/unknown id) and drive the mart SELL path over it.
while game.stack:top() do game.stack:pop() end
U.teleport(game, "PEWTER_MART", 2, 5, "left")
U.wait(5)
game.save.money = 3000
-- known bag state regardless of whether part 1 leaked a phantom "0"
game.save.inventory = {}
game.save.bagOrder = nil
Bag.add(game.save, "0", 1)
check(game.save.inventory["0"] == 1, "seeded unknown item '0' in bag")
-- open the clerk's BUY/SELL/QUIT menu exactly as the mart interaction does
Screens.push(game, "ShopMenu", {})
U.wait(4)
U.tap(game, "down") -- BUY -> SELL
U.wait(4)
U.tap(game, "a") -- open the SELL list
U.wait(6)
local sellList = game.stack:top()
check(sellList and sellList.items ~= nil, "SELL list opened")
U.shot(game, DIR .. "/bh_2_sell_list.png")
if sellList and sellList.items then
-- find the bogus "0" row
local item, idx
for i, it in ipairs(sellList.items) do
if it.value == "0" then item, idx = it, i end
end
check(item ~= nil, "bogus '0' row present in sell list")
if item then
sellList.index = idx
local footerBefore = sellList.footer
-- Invoke the real onChoose the ListMenu would call on A. Before the
-- fix this throws at math.floor(def.price/2) with def=nil; we catch
-- it so the run reports the crash instead of hanging on love's error
-- screen.
local ok, err = pcall(sellList.onChoose, item, sellList)
if not ok then
fail = fail + 1
U.log("FAIL", "selling '0' CRASHED: " .. tostring(err))
else
check(true, "selling '0' did not crash")
-- nothing may be sold: the guard returns before any QuantityBox
check(game.save.inventory["0"] == 1, "unknown item still in bag (not sold)")
check(sellList.footer ~= footerBefore
and tostring(sellList.footer):find("price") ~= nil,
"sell footer shows the unsellable message")
end
U.wait(4)
U.shot(game, DIR .. "/bh_3_sell_choose.png")
end
end
U.log("RESULT", ("pass=%d fail=%d"):format(pass, fail))
if fail == 0 then U.log("RESULT", "ALL PASS") else U.log("RESULT", "HAS FAILURES") end
end
+141
View File
@@ -0,0 +1,141 @@
-- Driver: forced-step shoves use the wrong primitive (issue #151).
--
-- Two related defects, both a scripted "push the player back one step" that
-- reaches for the wrong movement primitive:
--
-- A MUSEUM_1F ticket rope (data/scripts/story2.lua museumClerk onDecline):
-- declining the Y50 ticket must shove the player one cell SOUTH off the
-- exhibit rope (scripts/Museum1F.asm) -- the player crossed the rope
-- heading NORTH, so the shove is south. The bug shoved "right" onto the
-- counter tile (11,4)=tile 23, non-walkable, matching the report's "moved
-- onto the table". Correct landing is (10,5)=tile 1 (walkable floor).
--
-- B VIRIDIAN_CITY gym lock (data/scripts/story5.lua stepGate/viridianGym-
-- Lock): the tile below the Gym door (32,8)=tile 44 is a DOWN-ledge
-- (44 -> 55, data/tilesets/ledge_tiles.asm); Gen1 shoves the player with a
-- SIMULATED JOYPAD down-press that runs the normal step pipeline including
-- HandleLedges (engine/overworld/ledges.asm), so the shove HOPS the ledge
-- and lands on (32,10)=tile 57. The bug used a raw scriptMove that
-- ignores ledges, planting the player standing on the ledge tile (32,9).
--
-- Both cases assert the CORRECT Gen1 outcome, so this FAILS on the bug and
-- PASSES once the shove primitives are fixed.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/decline_push_bug151_test.lua \
-- POKEPORT_IDENTITY=bug151 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
-- a party + starter flag so the overworld is fully usable
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
end
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
-- Advance dialog until control returns to the overworld: tap A through
-- TextBoxes, tap B through a ChoiceBox (B always declines -- ChoiceBox:update
-- calls onChoose(false) on B). Bounded so a stuck box can't hang the run.
local function driveDialog(maxIters)
for _ = 1, (maxIters or 240) do
local top = game.stack:top()
if top == game.overworld then return true end
if getmetatable(top) == ChoiceBox then
U.tap(game, "b")
elseif getmetatable(top) == TextBox then
U.tap(game, "a")
else
-- unknown state: nudge with A so we never wedge
U.tap(game, "a")
end
U.wait(2)
end
return game.stack:top() == game.overworld
end
-- Drain queued scriptMoves + any in-flight step, sampling p.hopFrames each
-- frame (a hop arc is set only by checkLedgeHop). Returns whether a hop was
-- ever seen while draining.
local function drainMoves(p, maxFrames)
local hopSeen = (p.hopFrames or 0) > 0
for _ = 1, (maxFrames or 180) do
local ow = game.overworld
local pending = ow.scriptMoves and #ow.scriptMoves > 0
if not pending and not p.moving and (p.hopFrames or 0) == 0 then break end
if (p.hopFrames or 0) > 0 then hopSeen = true end
U.wait(1)
end
return hopSeen
end
-- ------------------------------------------------------------------
-- Case A: MUSEUM_1F ticket-rope decline must shove SOUTH, not RIGHT.
-- ------------------------------------------------------------------
do
game.save.flags.EVENT_BOUGHT_MUSEUM_TICKET = false
game.save.money = 1000 -- enough to buy, so declining is a real NO choice
U.teleport(game, "MUSEUM_1F", 10, 5, "up")
U.wait(6)
local p = game.overworld.player
shot("museum_decline_before.png")
-- step north onto the rope cell (10,4); the clerk stops us there
U.hold(game, "up", 24)
-- clerk dialog: advance the pitch, decline the YES/NO, clear "Come again!"
driveDialog(240)
drainMoves(p, 120)
shot("museum_decline_after.png")
expect(p.cellX == 10 and p.cellY == 5,
"A: declining shoves the player SOUTH to (10,5), got:", p.cellX, p.cellY)
end
-- ------------------------------------------------------------------
-- Case B: VIRIDIAN_CITY gym lock shove must HOP the down-ledge.
-- ------------------------------------------------------------------
do
-- fresh badge state: no non-Earth badges, so the gym stays locked
game.save.inventory = game.save.inventory or {}
for _, b in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE",
"VOLCANOBADGE" }) do
game.save.inventory[b] = nil
end
U.teleport(game, "VIRIDIAN_CITY", 31, 8, "right")
U.wait(6)
local p = game.overworld.player
shot("viridian_gymlock_before.png")
-- step east onto (32,8) directly below the locked Gym door
U.hold(game, "right", 24)
-- the "GYM's doors are locked..." box appears; clearing it fires the shove
local ow = game.overworld
local hopSeen = false
for _ = 1, 240 do
local top = game.stack:top()
if top == game.overworld then break end
if getmetatable(top) == TextBox then U.tap(game, "a") else U.tap(game, "a") end
if (p.hopFrames or 0) > 0 then hopSeen = true end
U.wait(2)
if (p.hopFrames or 0) > 0 then hopSeen = true end
end
if drainMoves(p, 180) then hopSeen = true end
shot("viridian_gymlock_after.png")
expect(hopSeen, "B: the gym-lock shove HOPS the ledge (hop arc seen)")
expect(p.cellX == 32 and p.cellY == 10,
"B: landed south of the ledge at (32,10), got:", p.cellX, p.cellY)
end
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed -- #151 shoves are Gen1-correct "
.. "(museum decline goes south, Viridian gym lock hops the ledge)")
end
+193
View File
@@ -0,0 +1,193 @@
-- Driver: regression coverage for #196 "Dig not working exactly as intended".
--
-- Report (v0.1.23): using DIG, the character appears SPINNING INSIDE the
-- Pokemon Center in front of the nurse, and there is no spin-before-fade on
-- departure. Expected (reporter + triage cluster E + the file's own code
-- comments): the sprite begins to spin, the screen fades, and the player
-- fades in OUTSIDE the nearest town's Pokemon Center door -- exactly like Fly.
--
-- Gen1/pokered references this exercises:
-- engine/overworld/player_animations.asm _LeaveMapAnim (SFX_TELEPORT_EXIT_1
-- + PlayerSpinWhileMovingUp) for the departure spin-up;
-- EnterMapAnim (PlayerSpinWhileMovingDown) for the arrival spin-down;
-- engine/items/item_effects.asm ItemUseEscapeRope (Dig/Teleport share it).
--
-- Two defects, two assertions:
-- FIX A: a DEPARTURE spin must appear on the origin (cave) map BEFORE the
-- warp -- ow.teleportOut set / player spinning while still in the cave.
-- FIX B: the landing map must be the town (VIRIDIAN_CITY) at the in-front-of
-- -door fly spot (23,26), NOT the interior VIRIDIAN_POKECENTER.
-- Pre-fix this driver documents the bug (lands in VIRIDIAN_POKECENTER, no
-- departure spin); post-fix it passes.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local failures = {}
local function check(cond, msg)
if cond then
U.log("PASS:", msg)
else
U.log("FAIL:", msg)
table.insert(failures, msg)
end
end
-- ---- set up a heal record with a remembered Viridian town door ----
-- Simulate having healed at the Viridian Pokemon Center: lastHeal points
-- at the INTERIOR (the buggy landing), but carries the outdoor town door
-- so the fix can relocate the escape-warp outside like Fly does.
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "bryan"
game.save.lastOutdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 }
game.save.lastHeal = {
map = "VIRIDIAN_POKECENTER", x = 3, y = 3,
outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 25 },
}
-- field.flyWarps.VIRIDIAN_CITY = {23,26} is the canonical in-front-of-door
-- fly landing (one tile south of the PC door warp at 23,25).
local fw = game.data.field.flyWarps.VIRIDIAN_CITY
U.log("viridian flyWarp:", tostring(fw and fw.x), tostring(fw and fw.y))
-- ======================= LEG 1: DIG via the party menu ==============
-- A DIG-knowing party mon. Pokemon.movesAtLevel never grants DIG, so
-- inject the move directly (DIG needs no badge and works in CAVERN maps).
local digger = Pokemon.new(game.data, "NIDOKING", 40)
digger.moves = { { id = "DIG", pp = 10 } }
game.save.party = { digger }
-- MT_MOON_1F tileset is CAVERN (in DIG_TILESETS); a walkable cave cell.
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
local ow = game.overworld
U.log("start map:", tostring(ow and ow.map and ow.map.id),
"tileset:", tostring(ow and ow.map and ow.map.def and ow.map.def.tileset))
U.shot(game, DIR .. "/dig_00_cave.png")
U.wait(4)
-- open the party menu and pick DIG on slot 1
-- (submenu order for a DIG-only mon: STATS / SWITCH / DIG)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> DIG
U.wait(2)
U.tap(game, "a") -- choose DIG
U.wait(2)
-- ---- FIX A: watch for a DEPARTURE spin on the cave map before the warp ----
-- A departure spin counts only if it happens while we are still in the cave
-- (map.id == MT_MOON_1F). The pre-fix code only spins on arrival, inside the
-- Center, so this stays false pre-fix.
local sawDepartureSpin = false
local spinShotTaken = false
local leftCave = false
for _ = 1, 240 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
-- departure-only markers: ow.teleportOut is the new pre-warp spin state,
-- and player.spinRise is the rising spin (the arrival uses spinDrop), so
-- neither can be confused with the interior arrival spin-down
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
sawDepartureSpin = true
if not spinShotTaken then
U.shot(game, DIR .. "/dig_01_spin.png")
spinShotTaken = true
end
end
if ow.map and ow.map.id ~= "MT_MOON_1F" then
leftCave = true
break
end
U.wait(1)
end
U.log("sawDepartureSpin:", tostring(sawDepartureSpin),
"leftCave:", tostring(leftCave))
-- ---- settle the arrival, then FIX B: where did we land? ----
local landedMap
for _ = 1, 240 do
landedMap = ow.map and ow.map.id
-- wait until the transition settles onto a stable map that isn't the cave
if landedMap and landedMap ~= "MT_MOON_1F" and not ow.transitioning then
break
end
U.wait(1)
end
U.wait(8)
U.shot(game, DIR .. "/dig_02_land.png")
U.wait(4)
landedMap = ow.map and ow.map.id
U.log("DIG landed map:", tostring(landedMap),
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
check(sawDepartureSpin,
"DIG: departure spin appears in the cave before the fade (FIX A)")
check(landedMap == "VIRIDIAN_CITY",
"DIG: lands OUTSIDE at VIRIDIAN_CITY, not the interior (FIX B) -- got "
.. tostring(landedMap))
check(ow.player.cellX == 23 and ow.player.cellY == 26,
"DIG: lands at the in-front-of-door fly spot 23,26 -- got "
.. tostring(ow.player.cellX) .. "," .. tostring(ow.player.cellY))
-- ======================= LEG 2: ESCAPE ROPE via the bag =============
-- Same shared departure path, but driven through BagMenu's escape_rope
-- branch so src/ui/BagMenu.lua is exercised too.
game.save.party = { Pokemon.new(game.data, "PIDGEY", 8) }
game.save.bagOrder = nil
game.save.inventory = { ESCAPE_ROPE = 1 }
game.save.money = game.save.money or 3000
U.teleport(game, "MT_MOON_1F", 14, 34, "down")
ow = game.overworld
U.shot(game, DIR .. "/dig_03_rope_cave.png")
U.wait(4)
Screens.push(game, "BagMenu")
U.wait(5)
U.tap(game, "a") -- choose ESCAPE ROPE (only item) -> USE / TOSS menu
U.wait(3)
U.tap(game, "a") -- USE (first option) -> escape_rope branch
U.wait(2)
local ropeSpin = false
for _ = 1, 240 do
local stillCave = ow.map and ow.map.id == "MT_MOON_1F"
if stillCave and (ow.teleportOut ~= nil or ow.player.spinRise) then
ropeSpin = true
end
if ow.map and ow.map.id ~= "MT_MOON_1F" then break end
U.wait(1)
end
local ropeLanded
for _ = 1, 240 do
ropeLanded = ow.map and ow.map.id
if ropeLanded and ropeLanded ~= "MT_MOON_1F" and not ow.transitioning then
break
end
U.wait(1)
end
U.wait(8)
U.shot(game, DIR .. "/dig_04_rope_land.png")
U.wait(4)
ropeLanded = ow.map and ow.map.id
U.log("ESCAPE ROPE landed map:", tostring(ropeLanded),
"cell:", tostring(ow.player.cellX), tostring(ow.player.cellY))
check(ropeSpin,
"ESCAPE ROPE: departure spin appears in the cave before the fade (FIX A)")
check(ropeLanded == "VIRIDIAN_CITY",
"ESCAPE ROPE: lands OUTSIDE at VIRIDIAN_CITY (FIX B) -- got "
.. tostring(ropeLanded))
if #failures == 0 then
U.log("RESULT bug196 PASS")
else
U.log("RESULT bug196 FAIL (" .. #failures .. "):")
for _, m in ipairs(failures) do U.log(" -", m) end
end
end
@@ -0,0 +1,126 @@
-- Driver: cancel an evolution with the B button (#213).
--
-- pokered engine/pokemon/evos_moves.asm polls hJoyHeld during the pic
-- flash: holding B aborts the evolution (the mon keeps its species and
-- _StoppedEvolvingText prints). Trade evolutions (wLinkState ==
-- LINK_STATE_TRADING) skip that poll and cannot be cancelled.
--
-- Case 1 (level path, cancelable): open EvolutionState directly, wait a
-- few frames into the flash (t well under FLASH_FRAMES=220), hold B, and
-- assert the mon stays CATERPIE with "stopped evolving" text on screen.
-- Case 2 (control): let the flash run to completion with no input and
-- assert the mon becomes METAPOD with the "Congratulations!" text.
--
-- SHOT_DIR=/tmp/evo213 POKEPORT_DRIVER=tests/drivers/evolution_cancel_bug213_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo213"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local function top() return game.stack:top() end
local function evoTop()
local t = top()
return t and t.screenId == "EvolutionState"
end
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
-- flatten a TextBox's paginated pages (list of line lists) to one string
local function pagesText(st)
if not st.pages then return nil end
local parts = {}
for _, page in ipairs(st.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do
if type(line) == "string" then parts[#parts + 1] = line end
end
elseif type(page) == "string" then
parts[#parts + 1] = page
end
end
return table.concat(parts, " ")
end
local function findText(needle)
for _, st in ipairs(game.stack.states or {}) do
local blob = pagesText(st)
if blob and blob:find(needle, 1, true) then return st end
end
return nil
end
-- mash A (one tap every few frames) until cond holds; a single tap only
-- fast-forwards a still-typing TextBox, so mashing both finishes the
-- typewriter and then presses the close button
local function mashUntil(cond, max)
for _ = 1, max or 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return cond()
end
U.teleport(game, "ROUTE_1", 5, 5, "down")
-- === Case 1: hold B during the flash -> evolution aborts ===
local mon = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon)
local done1 = false
Evolution.evolve(game, mon, "METAPOD", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
U.wait(20) -- into the flash, well under FLASH_FRAMES=220
U.log("case1 flash", "t=", top().t, "species=", mon.species)
U.shot(game, DIR .. "/evo213_1_evolving.png")
U.hold(game, "b", 20) -- Gen1 hJoyHeld B-cancel
-- the flash aborts: EvolutionState is no longer the top (the stopped
-- text overlays it and then pops it)
if not waitFor(function() return not evoTop() end, 240) then
error("evolution did not abort on B: still on EvolutionState, species="
.. tostring(mon.species))
end
U.log("case1 aborted", "species=", mon.species)
U.wait(40) -- let "Huh? MON stopped evolving!" finish typing before the shot
U.shot(game, DIR .. "/evo213_2_stopped.png")
assert(mon.species == "CATERPIE",
"B-cancel failed: mon evolved to " .. tostring(mon.species)
.. " (expected CATERPIE)")
assert(findText("stopped evolving"), "StoppedEvolvingText not shown")
mashUntil(function() return done1 end, 80) -- close the stopped-evolving text
assert(done1, "cancel onDone never fired")
assert(mon.species == "CATERPIE", "species changed after cancel tail")
-- === Case 2 (control): no input -> evolution completes ===
local mon2 = Pokemon.new(game.data, "CATERPIE", 7)
table.insert(game.save.party, 1, mon2)
local done2 = false
Evolution.evolve(game, mon2, "METAPOD", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
-- let the full flash run (FLASH_FRAMES=220) without pressing B
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
U.wait(40) -- let the Congratulations text finish typing before the shot
U.shot(game, DIR .. "/evo213_3_congrats.png")
assert(mon2.species == "METAPOD",
"control failed: mon2 stayed " .. tostring(mon2.species)
.. " (expected METAPOD)")
mashUntil(function() return done2 end, 80)
U.log("done", "case1=", mon.species, "case2=", mon2.species)
love.event.quit()
end
+143
View File
@@ -0,0 +1,143 @@
-- Driver: evolution grants the new species' level-up move (#12).
--
-- pokered engine/pokemon/evos_moves.asm (EvolveMon) re-runs the level-up
-- learn check on the *evolved* species after the "evolved into" text, via
-- the LearnMoveFromLevelUp predef (engine/pokemon/learn_move.asm). The
-- check is EXACT level equality (learnset entry level == mon.level), so a
-- mon evolving at exactly a learnset level gains that move.
--
-- Data pins: GYARADOS.learnset has { level = 20, move = "BITE" }
-- (data/generated/pokemon.lua), so MAGIKARP->GYARADOS at level 20 must
-- learn BITE, while an evolution at level 21 must NOT (nothing at 21).
--
-- Case 1 (repro/fix): Lv20 MAGIKARP knowing only SPLASH evolves; after the
-- congratulations text the mon must be GYARADOS and know BITE, and the
-- "GYARADOS learned BITE!" text must appear. This assertion FAILS on the
-- pre-fix build (no learn step) and PASSES once the fix runs the check.
-- Case 2 (control): Lv21 MAGIKARP evolves to GYARADOS and must NOT gain
-- BITE (guards the exact-level == rule against an over-broad <= fix).
--
-- SHOT_DIR=/tmp/evo12 POKEPORT_DRIVER=tests/drivers/evolution_move_bug12_test.lua \
-- POKEPORT_IDENTITY=bug12 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/evo12"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local Evolution = require("src.pokemon.Evolution")
game.save.options = game.save.options or {}
game.save.options.textSpeed = 1
local function top() return game.stack:top() end
local function evoTop()
local t = top()
return t and t.screenId == "EvolutionState"
end
local function waitFor(cond, max)
for _ = 1, max or 600 do
if cond() then return true end
U.wait(1)
end
return false
end
-- flatten a TextBox's paginated pages (list of line lists) to one string
local function pagesText(st)
if not st.pages then return nil end
local parts = {}
for _, page in ipairs(st.pages) do
if type(page) == "table" then
for _, line in ipairs(page) do
if type(line) == "string" then parts[#parts + 1] = line end
end
elseif type(page) == "string" then
parts[#parts + 1] = page
end
end
return table.concat(parts, " ")
end
local function findText(needle)
for _, st in ipairs(game.stack.states or {}) do
local blob = pagesText(st)
if blob and blob:find(needle, 1, true) then return st end
end
return nil
end
local function mashUntil(cond, max)
for _ = 1, max or 200 do
if cond() then return true end
U.tap(game, "a")
U.wait(3)
end
return cond()
end
local function hasMove(mon, id)
for _, mv in ipairs(mon.moves) do
if mv.id == id then return true end
end
return false
end
U.teleport(game, "ROUTE_1", 5, 5, "down")
-- === Case 1: MAGIKARP @20 -> GYARADOS must learn BITE ===
local mon = Pokemon.new(game.data, "MAGIKARP", 20)
mon.moves = { { id = "SPLASH", pp = 40 } } -- deterministic single slot
table.insert(game.save.party, 1, mon)
local done1 = false
Evolution.evolve(game, mon, "GYARADOS", function() done1 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case1)") end
-- let the full flash run (FLASH_FRAMES=220) with no input, then apply
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case1)")
end
U.wait(40) -- let the congrats text finish typing before the shot
U.shot(game, DIR .. "/evo12_1_congrats.png")
-- advance past congrats into the level-up learn flow; the fix pushes
-- "GYARADOS learned BITE!" here (pre-fix: onDone fires with no learn)
local sawLearned = false
mashUntil(function()
if findText("learned") then sawLearned = true; return true end
return done1
end, 200)
U.wait(30) -- let the "learned BITE!" text type out (fix path)
U.shot(game, DIR .. "/evo12_2_learned.png")
mashUntil(function() return done1 end, 120)
U.log("case1", "species=", mon.species, "hasBite=", hasMove(mon, "BITE"),
"sawLearned=", sawLearned)
assert(mon.species == "GYARADOS",
"case1: mon stayed " .. tostring(mon.species) .. " (expected GYARADOS)")
assert(hasMove(mon, "BITE"),
"case1: GYARADOS did not learn BITE on evolution at level 20 (bug #12)")
assert(sawLearned, "case1: no 'learned' text shown for the evolution move")
-- === Case 2 (control): MAGIKARP @21 -> GYARADOS must NOT gain BITE ===
local mon2 = Pokemon.new(game.data, "MAGIKARP", 21)
mon2.moves = { { id = "SPLASH", pp = 40 } }
table.insert(game.save.party, 1, mon2)
local done2 = false
Evolution.evolve(game, mon2, "GYARADOS", function() done2 = true end)
if not waitFor(evoTop, 300) then error("EvolutionState never opened (case2)") end
waitFor(function() return not evoTop() end, 400)
if not waitFor(function() return findText("evolved into") ~= nil end, 120) then
error("Congratulations text not shown (case2)")
end
mashUntil(function() return done2 end, 200)
U.wait(20)
U.shot(game, DIR .. "/evo12_3_lv21_no_bite.png")
U.log("case2", "species=", mon2.species, "hasBite=", hasMove(mon2, "BITE"))
assert(mon2.species == "GYARADOS",
"case2: mon2 stayed " .. tostring(mon2.species) .. " (expected GYARADOS)")
assert(not hasMove(mon2, "BITE"),
"case2: GYARADOS wrongly learned BITE at level 21 (over-grant; exact == broken)")
U.log("done", "case1=", mon.species, "case2=", mon2.species)
love.event.quit()
end
+221
View File
@@ -0,0 +1,221 @@
-- Driver: Fighting Dojo Karate Master bundle (#197).
-- Six sub-bugs live in FIGHTING_DOJO (scripts/FightingDojo.asm):
-- BUG1 no aggro -- the master has no trainer header so range=0
-- BUG2 no speech -- no won text + no prize dialogue after the win
-- BUG3 wrong re-talk -- shows the pre-battle challenge, not the after line
-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, not a dex entry
-- BUG5 both balls -- the chosen ball AND the other one both vanish; the
-- other should stay and give the "greedy" refusal
-- BUG6 poster -- the north-wall posters ("Enemies on every side!") are
-- inert (bg_events dropped by the extractor)
--
-- Every scenario screenshots the moment and records a pass/fail; the driver
-- asserts once at the end, so a single run captures before-evidence for all
-- six while still failing red until the fixes land.
--
-- SHOT_DIR=/tmp/dojo POKEPORT_DRIVER=tests/drivers/fighting_dojo_bug197_test.lua \
-- POKEPORT_IDENTITY=bug197 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local TextBox = require("src.render.TextBox")
local ChoiceBox = require("src.ui.ChoiceBox")
local OW = require("src.world.OverworldController")
local Pokemon = require("src.pokemon.Pokemon")
local Commands = require("src.script.Commands")
local failures = {}
local function check(cond, msg)
if cond then U.log("ok:", msg) else
table.insert(failures, msg)
U.log("FAIL:", msg)
end
return cond
end
local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end
local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end
local function currentPageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local page = top.pages and top.pages[top.pageIndex]
if not page then return "" end
return table.concat(page, "\n")
end
local function pageReady()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return false end
return top.waiting or top.done
end
-- let the current page finish typing WITHOUT advancing past it
local function waitReadyPage()
for _ = 1, 200 do
if pageReady() then break end
U.wait(2)
end
return currentPageText()
end
local function mashUntil(cond, cap)
for _ = 1, (cap or 200) do
if cond() then return true end
U.tap(game, "a")
U.wait(2)
end
return cond()
end
-- advance text pages until a ready page contains `want`; stops before
-- blowing past a choice box
local function sawText(want)
return mashUntil(function()
if topIsChoice() then return false end
if not pageReady() then return false end
return currentPageText():find(want, 1, true) ~= nil
end, 150)
end
local function npcByName(ow, name)
for _, n in ipairs(ow.npcs) do
if n.def and n.def.name == name then return n end
end
end
local function resetDojo(x, y, facing, flags)
while game.stack:top() do game.stack:pop() end
-- the four blackbelts are not under test; retire them so only the
-- master (or the balls/poster) can react in each scenario
game.save.flags = {
EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = true,
EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = true,
}
game.save.defeatedTrainers = {}
game.save.objectToggles = {}
game.save.itemsTaken = {}
game.save.inventory = game.save.inventory or {}
game.save.player.name = game.save.player.name or "RED"
-- one healthy mon: enough for a battle to construct, room for a prize
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
for k, v in pairs(flags or {}) do game.save.flags[k] = v end
game.stack:push(OW, "FIGHTING_DOJO", x, y, facing or "up")
U.wait(5)
return game.stack:top()
end
------------------------------------------------------------------
-- BUG1/2/3 header seed sanity (deterministic, no battle needed)
------------------------------------------------------------------
local hdr = game.data:trainerHeader("FightingDojo", 1)
check(hdr ~= nil, "BUG1/2/3: Karate Master trainer header (index 1) exists")
check(hdr and (hdr.range or 0) > 0, "BUG1: master has a sight range")
check(hdr and hdr.won ~= nil, "BUG2: master has a won (defeat) text")
check(hdr and hdr.after ~= nil, "BUG3: master has an after (re-talk) text")
------------------------------------------------------------------
-- BUG1: sight aggro. Stand directly below the master (5,3 faces DOWN,
-- range 4) with the four blackbelts pre-cleared so only he can engage.
------------------------------------------------------------------
local ow = resetDojo(5, 4, "up", {})
U.shot(game, DIR .. "/dojo_1_before.png")
U.wait(20) -- the idle sight scan runs every frame
local engaged = ow.engaging or (ow.emote ~= nil)
U.log("aggro engaging:", tostring(ow.engaging), "emote:", tostring(ow.emote ~= nil))
U.shot(game, DIR .. "/dojo_2_aggro.png")
check(engaged, "BUG1: Karate Master aggros on sight")
------------------------------------------------------------------
-- BUG3: talk to the already-beaten master -> "Stay and train..." and
-- NOT the "I am the LEADER here!" pre-battle challenge.
------------------------------------------------------------------
-- talk from (4,3) facing right: beside the master (5,3), off his DOWN
-- sight line so he can't (post-fix) aggro before we set him defeated
ow = resetDojo(4, 3, "right", { EVENT_BEAT_KARATE_MASTER = true })
game.save.defeatedTrainers["FIGHTING_DOJO_obj_1"] = true
local master = npcByName(ow, "FIGHTINGDOJO_KARATE_MASTER")
if check(master ~= nil, "BUG3: master npc present") then
ow:talkTo(master)
local first = waitReadyPage()
check(not first:find("LEADER", 1, true) and not first:find("Grunt", 1, true),
"BUG3: beaten master no longer shows the challenge (page1='" .. first .. "')")
check(sawText("Stay and train"),
"BUG3: beaten master says 'Stay and train at Karate with us!'")
U.shot(game, DIR .. "/dojo_3_retalk.png")
mashUntil(function() return game.stack:top() == ow end)
end
------------------------------------------------------------------
-- BUG2: the post-battle prize speech (run the same reward path a win
-- takes; EVENT_BEAT_KARATE_MASTER must be unset so it prints).
------------------------------------------------------------------
ow = resetDojo(4, 9, "up", {})
ow:checkVictoryRewards("OPP_BLACKBELT", 1)
U.wait(5)
check(sawText("prized"),
"BUG2: win shows the '...prized fighting POKeMON!' prize offer")
U.shot(game, DIR .. "/dojo_2_prize.png")
mashUntil(function() return game.stack:top() == ow end)
------------------------------------------------------------------
-- BUG4 (verify-only): the Hitmonlee ball prompt is the Gen1 descriptor
-- ("You want the hard kicking HITMONLEE?"), not a Pokedex entry screen.
------------------------------------------------------------------
ow = resetDojo(4, 2, "up", { EVENT_BEAT_KARATE_MASTER = true })
local leeBall = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL")
local chanBall = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL")
check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat")
if leeBall then
ow:talkTo(leeBall)
check(sawText("hard kicking") or sawText("HITMONLEE"),
"BUG4: ball asks the Gen1 descriptor prompt (no dex entry)")
U.shot(game, DIR .. "/dojo_4_prompt.png")
------------------------------------------------------------------
-- BUG5: choose YES -> only the chosen ball vanishes; the other stays
-- and, when talked to, gives the "Better not get greedy..." refusal.
------------------------------------------------------------------
mashUntil(topIsChoice)
U.tap(game, "a") -- YES (index 1)
mashUntil(function() return game.stack:top() == ow end)
check(game.save.flags.EVENT_GOT_HITMONLEE == true, "BUG5: received HITMONLEE")
local leeGone = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL") == nil
local chanStays = npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL") ~= nil
check(leeGone, "BUG5: the chosen HITMONLEE ball is removed")
check(chanStays, "BUG5: the other (HITMONCHAN) ball stays on the mat")
U.shot(game, DIR .. "/dojo_5_onegone.png")
if chanStays then
ow:talkTo(npcByName(ow, "FIGHTINGDOJO_HITMONCHAN_POKE_BALL"))
check(sawText("greedy"), "BUG5: remaining ball gives the greedy refusal")
check(not game.save.flags.EVENT_GOT_HITMONCHAN,
"BUG5: talking the other ball does NOT hand a second POKeMON")
U.shot(game, DIR .. "/dojo_5_greedy.png")
mashUntil(function() return game.stack:top() == ow end)
end
end
------------------------------------------------------------------
-- BUG6: the north-wall poster. A claimed prize frees its ball cell, so
-- stand on (4,1) facing up and read the poster above it.
------------------------------------------------------------------
ow = resetDojo(4, 1, "up",
{ EVENT_BEAT_KARATE_MASTER = true, EVENT_GOT_HITMONLEE = true })
Commands.hide_object({ game = game, save = game.save, overworld = ow },
"FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL")
U.wait(3)
U.shot(game, DIR .. "/dojo_6_before.png")
ow:interact()
check(sawText("Enemies on every"),
"BUG6: the poster prints 'Enemies on every side!'")
U.shot(game, DIR .. "/dojo_6_poster.png")
mashUntil(function() return game.stack:top() == ow end)
------------------------------------------------------------------
U.log("fighting_dojo_bug197_test: failures =", #failures)
for _, m in ipairs(failures) do U.log(" -", m) end
assert(#failures == 0,
"#197 unresolved:\n " .. table.concat(failures, "\n "))
U.log("fighting_dojo_bug197_test: ok")
end
+125
View File
@@ -0,0 +1,125 @@
-- Driver: regression coverage for #203 "You can't fly to indigo plateau".
--
-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly cycles EVERY visited
-- fly destination, and Indigo Plateau is a normal Fly spot once reached (its
-- special-warp lands the player on the Plateau exterior, data/maps/
-- special_warps.asm). The port's fly-list filter gated each entry on
-- Map.isOutdoor(def), which is tileset == "OVERWORLD" -- but Indigo Plateau's
-- map uses tileset "PLATEAU", so it was silently dropped from the fly cursor
-- even though it is visited, has a fly warp, and sits in flyOrder. This driver
-- walks the party-menu FLY flow, asserts INDIGO_PLATEAU is a cyclable fly
-- destination, flies there, and lands on the Plateau exterior.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject
-- the move directly after construction (same setup as the #195 driver).
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
flyer.moves[1] = { id = "FLY", pp = 15 }
game.save.party = { flyer }
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated
game.save.visited = {
PALLET_TOWN = true, VIRIDIAN_CITY = true, INDIGO_PLATEAU = true,
}
-- Exercise the real visited-marking path (OverworldController marks any
-- flyWarps map visited on entry) by standing on the Plateau exterior first,
-- then hop back to Pallet for a clean starting point.
U.teleport(game, "INDIGO_PLATEAU", 9, 6, "down")
U.wait(5)
assert(game.save.visited.INDIGO_PLATEAU,
"entering the Plateau exterior must mark INDIGO_PLATEAU visited")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
local top = game.stack:top()
U.shot(game, DIR .. "/fly_map_screen.png")
U.wait(4) -- let the async png write land before any assert can quit us
U.log("fly screen top screenId:", tostring(top and top.screenId),
"fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode))
local isFlyMap = top ~= nil and top.fly == true
and top.locs ~= nil and top.mode ~= nil
assert(isFlyMap,
"FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '"
.. tostring(top and top.screenId or "nil") .. "' fly="
.. tostring(top and top.fly))
-- KEY ASSERTION (the #203 bug): INDIGO_PLATEAU must be a cyclable fly
-- destination. Before the fix the isOutdoor gate drops the PLATEAU-tileset
-- Plateau, so this list is missing it and the assert fails.
local hasIndigo = false
local listStr = {}
for i, id in ipairs(top.flyMapIds or {}) do
listStr[i] = id
if id == "INDIGO_PLATEAU" then hasIndigo = true end
end
U.log("fly destinations:", table.concat(listStr, ", "))
assert(hasIndigo,
"INDIGO_PLATEAU must appear in the fly destination list (it is visited, "
.. "has a fly warp, and is in flyOrder); got { "
.. table.concat(listStr, ", ") .. " }")
-- walk the cursor to INDIGO PLATEAU (banner reads "To INDIGO PLATEAU")
local function selectedMap()
return top.flyMapIds and top.flyMapIds[top.sel]
end
U.log("initial fly selection:", tostring(selectedMap()))
local guard = 0
while selectedMap() ~= "INDIGO_PLATEAU" and guard < 12 do
U.tap(game, "down")
U.wait(2)
guard = guard + 1
end
assert(selectedMap() == "INDIGO_PLATEAU",
"Up/Down must cycle the fly cursor to INDIGO_PLATEAU, landed on "
.. tostring(selectedMap()))
U.shot(game, DIR .. "/fly_indigo_selected.png")
U.wait(4)
-- A flies to the highlighted destination: flyTo departs (48-frame bird),
-- then warps to the Plateau exterior.
U.tap(game, "a")
U.wait(3)
assert(game.overworld and game.overworld.flyDest
and game.overworld.flyDest.map == "INDIGO_PLATEAU",
"pressing A on the fly map must start the departure to INDIGO_PLATEAU, got "
.. tostring(game.overworld and game.overworld.flyDest
and game.overworld.flyDest.map))
-- past the bird sweep + warp transition: the player lands on the Plateau
local landed = false
for _ = 1, 240 do
if game.overworld and game.overworld.map
and game.overworld.map.id == "INDIGO_PLATEAU" then
landed = true
break
end
U.wait(1)
end
U.wait(6)
U.shot(game, DIR .. "/fly_landed_indigo.png")
U.wait(4)
assert(landed, "Fly must land the player on INDIGO_PLATEAU, ended on "
.. tostring(game.overworld and game.overworld.map and game.overworld.map.id))
U.log("RESULT bug203 PASS")
end
+101
View File
@@ -0,0 +1,101 @@
-- Driver: regression coverage for #195 "Fly doesn't show the map".
--
-- pret/pokered engine/menus/town_map.asm LoadTownMap_Fly: choosing FLY from
-- the party field-move submenu opens the TOWN MAP with a blinking cursor that
-- cycles ONLY the visited fly destinations (Up/Down), A flies there, B cancels.
-- The port used to push a plain "FLY TO?" ListMenu (src/ui/FlyMenu.lua)
-- instead -- a text list, never the map (the how-it-is screenshot). This
-- driver walks Fly and asserts the TOWN MAP fly screen appears, that Up/Down
-- cycle the visited towns, and that A actually flies.
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pokemon = require("src.pokemon.Pokemon")
local Screens = require("src.ui.Screens")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Party slot 1 knows FLY. Pokemon.movesAtLevel never grants FLY, so inject
-- the move directly after construction.
local flyer = Pokemon.new(game.data, "PIDGEOT", 40)
flyer.moves[1] = { id = "FLY", pp = 15 }
game.save.party = { flyer }
game.save.player.name = "bryan"
game.save.inventory = game.save.inventory or {}
game.save.inventory.THUNDERBADGE = true -- FLY submenu entry is badge-gated
game.save.visited = {
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
CERULEAN_CITY = true, CELADON_CITY = true,
}
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
-- open the party menu and pick FLY on slot 1 (submenu: STATS / SWITCH / FLY)
Screens.push(game, "PartyMenu")
U.wait(5)
U.tap(game, "a") -- open the per-mon submenu
U.wait(2)
U.tap(game, "down") -- STATS -> SWITCH
U.wait(2)
U.tap(game, "down") -- SWITCH -> FLY
U.wait(2)
U.tap(game, "a") -- choose FLY
U.wait(5)
local top = game.stack:top()
U.shot(game, DIR .. "/fly_map_screen.png")
U.wait(4) -- let the async png write land before any assert can quit us
U.log("fly screen top screenId:", tostring(top and top.screenId),
"fly=", tostring(top and top.fly), "mode=", tostring(top and top.mode))
local isFlyMap = top ~= nil and top.fly == true
and top.locs ~= nil and top.mode ~= nil
assert(isFlyMap,
"FLY must open the TOWN MAP in fly mode (LoadTownMap_Fly), got screen '"
.. tostring(top and top.screenId or "nil") .. "' fly="
.. tostring(top and top.fly))
-- The cursor cycles ONLY the visited fly towns. Walk it to CELADON CITY.
local function selectedMap()
return top.flyMapIds and top.flyMapIds[top.sel]
end
U.log("initial fly selection:", tostring(selectedMap()))
local guard = 0
while selectedMap() ~= "CELADON_CITY" and guard < 12 do
U.tap(game, "down")
U.wait(2)
guard = guard + 1
end
assert(selectedMap() == "CELADON_CITY",
"Up/Down must cycle the fly cursor to CELADON_CITY, landed on "
.. tostring(selectedMap()))
U.shot(game, DIR .. "/fly_celadon_selected.png")
U.wait(4)
-- A flies to the highlighted town: flyTo departs (48-frame bird), then warps.
U.tap(game, "a")
U.wait(3)
assert(game.overworld and game.overworld.flyDest
and game.overworld.flyDest.map == "CELADON_CITY",
"pressing A on the fly map must start the departure to CELADON_CITY, got "
.. tostring(game.overworld and game.overworld.flyDest
and game.overworld.flyDest.map))
-- past the bird sweep + warp transition: the player lands in Celadon
local landed = false
for _ = 1, 240 do
if game.overworld and game.overworld.map
and game.overworld.map.id == "CELADON_CITY" then
landed = true
break
end
U.wait(1)
end
U.wait(6)
U.shot(game, DIR .. "/fly_landed.png")
U.wait(4)
assert(landed, "Fly must land the player in CELADON_CITY, ended on "
.. tostring(game.overworld and game.overworld.map and game.overworld.map.id))
U.log("RESULT bug195 PASS")
end
@@ -0,0 +1,165 @@
-- Driver: #198 Celadon Game Corner poster grunt exit.
-- The Rocket (GAMECORNER_ROCKET) guards the hideout poster at cell (9,5),
-- facing UP toward the poster / secret entrance at (9,4). After you beat
-- him he warns "Our hideout might be discovered! I better tell BOSS!" and,
-- in Gen1 (scripts/GameCorner.asm GameCornerRocketExitScript), walks UP one
-- tile into the poster (the hideout entrance) before HideObject despawns
-- him -- freeing (9,5) so the player can reach the poster switch. The bug
-- despawned him in place at (9,5) the instant the after-battle box closed.
--
-- This driver talks to him, mashes through the battle to a win, advances
-- the after-battle text, then samples the grunt every frame: it must move
-- toward the poster (cellY/targetY north of its start) before it leaves
-- ow.npcs. Fails on the pre-fix instant-despawn, passes after the walk.
--
-- SHOT_DIR=/tmp/gc198 POKEPORT_IDENTITY=bug198 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/gamecorner_rocket_bug198_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
-- clean slate: the grunt must not already read as defeated/hidden
game.save.defeatedTrainers = {}
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.GAME_CORNER = nil
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
-- a tank that one-shots the whole party (OPP_ROCKET #7) so the mash win
-- is quick and deterministic regardless of type matchups
local tank = Pokemon.new(game.data, "MEWTWO", 100)
tank.moves = {
{ id = "PSYCHIC_M", pp = 99 },
{ id = "THUNDERBOLT", pp = 99 },
{ id = "ICE_BEAM", pp = 99 },
{ id = "EARTHQUAKE", pp = 99 },
}
game.save.party = { tank }
-- stand south of the grunt (9,6) facing up; grunt at (9,5) faces the
-- poster/secret entrance at (9,4)
U.teleport(game, "GAME_CORNER", 9, 6, "up")
local ow = game.overworld
local function findGrunt()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == "GAMECORNER_ROCKET" then return n end
end
return nil
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local function idle()
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and not ow.transitioning
end
local grunt = findGrunt()
assert(grunt, "GAMECORNER_ROCKET not present at start")
local startX, startY = grunt.cellX, grunt.cellY
U.log("grunt start:", startX, startY, grunt.facing)
assert(startX == 9 and startY == 5, "grunt not at expected (9,5)")
U.shot(game, DIR .. "/gamecorner_rocket_0_before.png")
-- Talk, then mash A through pre-battle text, the battle (select FIGHT +
-- first move), the won text ("Dang!"), until the after-battle "hideout"
-- text is on screen. Force-finish a stalled battle via onFinish("win")
-- (same safety valve as rival_walkoff_test) so the post-battle script
-- (which owns the exit walk) always runs.
U.tap(game, "a")
local sawAfter = false
for f = 1, 4000 do
if pageText():find("hideout", 1, true) then sawAfter = true break end
local top = game.stack:top()
if top and top.phase then
if top.phase == "menu" then top.menuIndex = 1
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
U.tap(game, "a")
if f > 2400 and top.onFinish then
U.log("force-finishing stalled battle")
top.onFinish("win")
if game.stack:top() == top then game.stack:pop() end
end
elseif top ~= ow then
U.tap(game, "a")
elseif idle() and not findGrunt() then
break -- somehow already resolved
else
U.tap(game, "a")
end
U.wait(2)
end
U.log("saw after-battle text:", sawAfter, "defeated:",
tostring(game.save.defeatedTrainers["GAME_CORNER_obj_11"]))
U.shot(game, DIR .. "/gamecorner_rocket_1_afterbattle.png")
assert(sawAfter, "never reached the after-battle 'hideout' text")
-- Dismiss the after-battle box. From here the fixed script queues a
-- one-tile scriptMove UP before hide_object; the buggy script removes
-- the grunt in place immediately.
U.tap(game, "a")
-- Sample every logic frame. The scripted walk sets facing=up and
-- targetY=(startY-1) for ~16 frames, then lands cellY=startY-1 and the
-- onDone despawns him the same frame, so watch for either the in-motion
-- targetY or the transient landed cellY north of the start.
local walkedUp, walkShot = false, false
for _ = 1, 600 do
local g = findGrunt()
if g then
if g.cellY < startY or (g.targetY and g.targetY < startY) then
walkedUp = true
if not walkShot then
walkShot = true
U.shot(game, DIR .. "/gamecorner_rocket_2_walk.png")
end
end
else
if idle() then break end
end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(1)
end
for _ = 1, 400 do
if idle() then break end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
U.wait(5)
U.shot(game, DIR .. "/gamecorner_rocket_3_after.png")
local toggles = game.save.objectToggles.GAME_CORNER
U.log("walkedUp:", walkedUp, "grunt gone:", findGrunt() == nil,
"toggle:", tostring(toggles and toggles.GAMECORNER_ROCKET))
-- CORRECT Gen1 behavior: he walks toward the poster before despawning.
assert(walkedUp,
"grunt never moved toward the poster before despawning (#198)")
assert(findGrunt() == nil, "GAMECORNER_ROCKET still present after exit")
assert(toggles and toggles.GAMECORNER_ROCKET == false,
"grunt objectToggle not hidden")
assert(game.save.defeatedTrainers["GAME_CORNER_obj_11"],
"grunt not recorded as defeated")
for _, n in ipairs(ow.npcs or {}) do
assert(not (n.cellX == startX and n.cellY == startY),
"an NPC still occupies the grunt's old tile (9,5)")
end
U.log("gamecorner_rocket_bug198_test: ok")
end
+105
View File
@@ -0,0 +1,105 @@
-- Visual + render-decision regression for #150 ("Grass transparency is off").
--
-- The reporter's should_be.png shows Red standing in Route 1 tall grass with a
-- GREEN cap that blends into the grass. The default SGB color mode instead
-- region-tints the player with the per-map SGB BG palette: Red's dark-gray cap
-- (DMG shade 2) maps to the ROUTE palette's shade-2 = light-blue (165,214,255)
-- (data/generated/palettes.lua ROUTE), so the character clashes with the grass.
-- On real GBC/SGB an OBJ carries its own object palette; OG RED already bakes
-- PaletteFX.ogObj() (green over Red, pink over Blue -- color/sprites.asm
-- ColorOverworldSprite) onto overworld characters. The fix makes SGB mode do
-- the same while leaving terrain on its per-map SGB palette.
--
-- Gate (fails before the fix, passes after):
-- * PaletteFX.usesSpriteObp("gbc") == true
-- * the player SpriteRenderer bakes a distinct OBJ image in SGB mode
-- (resolveImage() ~= the raw grayscale sheet) -- the pixel path that
-- recolors the cap green
-- * that baked OBJ palette's shade-2 (the cap) is green, not ROUTE light-blue
-- Regression guard (must hold before AND after -- terrain is untouched):
-- * the ROUTE terrain palette still carries BOTH grass green (173,230,90) and
-- light-blue (165,214,255), so the grass field keeps its green+blue dither.
--
-- Screenshots (SHOT_DIR): grass_bug150_sgb.png (the reported view) and
-- grass_bug150_ogred.png (OG RED reference -- green character, red terrain).
--
-- Run: POKEPORT_DRIVER=tests/drivers/grass_overlay_bug150_test.lua \
-- POKEPORT_IDENTITY=bug150 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local DIR = os.getenv("SHOT_DIR") or "."
local fails = 0
local function check(cond, msg)
if cond then U.log("ok: " .. msg)
else fails = fails + 1; U.log("FAIL: " .. msg) end
end
local function hasColor(pal, r, g, b)
if not pal then return false end
for i = 1, #pal do
if pal[i][1] == r and pal[i][2] == g and pal[i][3] == b then return true end
end
return false
end
-- a party + starter flag so the overworld is fully usable
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
-- default SGB color mode. Set the SAVED option too: Game:applyOptions
-- re-reads save.options.colors, so a bare setMode() would get reverted.
game.save.options = game.save.options or {}
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
U.teleport(game, "ROUTE_1", 10, 6, "down")
local ow = game.overworld
local p = ow.player
check(ow.map.id == "ROUTE_1", "on ROUTE_1")
check(ow.map:isGrassCell(p.cellX, p.cellY),
"player stands on a tall-grass cell (" .. p.cellX .. "," .. p.cellY .. ")")
U.wait(40) -- let the grass/flower tile animation cycle
U.shot(game, DIR .. "/grass_bug150_sgb.png")
-- === render-decision gate: fails before the fix, passes after =========
check(PaletteFX.usesSpriteObp("gbc") == true,
"SGB mode bakes an OBJ palette onto overworld characters")
-- the player's sprite must resolve to a baked OBJ image (not the raw
-- grayscale sheet) in SGB mode -- this is the exact path that colors the cap
local spr = p.sprite
check(spr and spr.image and spr:resolveImage() ~= spr.image,
"player sprite bakes a distinct OBJ image in SGB mode")
-- the baked object palette's shade-2 (the cap) is a green, and specifically
-- NOT the ROUTE light-blue the region shader used to hand it
local obj = PaletteFX.ogObj() -- {white, brightgreen, darkgreen, black}
local cap = obj and obj[3] -- DMG shade 2 -> 3rd palette entry
check(cap and cap[2] > cap[1] and cap[2] > cap[3],
"OBJ cap color is green-dominant (g>r and g>b)")
check(cap and not (cap[1] == 165 and cap[2] == 214 and cap[3] == 255),
"OBJ cap color is NOT ROUTE light-blue (165,214,255)")
-- === regression guard: terrain palette untouched =====================
local terrain = PaletteFX.pal(game.data, ow:paletteNameFor(ow.map))
check(hasColor(terrain, 173, 230, 90),
"ROUTE terrain palette still contains grass green (173,230,90)")
check(hasColor(terrain, 165, 214, 255),
"ROUTE terrain palette still contains light-blue (grass keeps its blue dither)")
-- OG RED reference for the human diff (green character over red terrain)
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
U.wait(20)
U.shot(game, DIR .. "/grass_bug150_ogred.png")
-- restore the default so the run doesn't end in a non-default mode
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
U.wait(2)
if fails > 0 then error(fails .. " check(s) failed for #150") end
U.log("all #150 checks passed")
end
+63
View File
@@ -0,0 +1,63 @@
-- Visual regression for #217: no phantom tall-grass tuft should be drawn
-- over the player's head while crossing the Viridian City -> Route 1 seam.
--
-- The player walks south out of Viridian City's exit path (cellX = 20). On
-- the step off the south edge, crossConnection swaps in ROUTE_1 and parks the
-- player one cell before the entry point at cellY = -1 (off the top edge) for
-- the duration of the seam step. ROUTE_1's border block back-fills that
-- off-map row with the grass tile, so before the fix the "feet overdraw"
-- painted an animated grass tuft over the player's head for ~5 frames.
--
-- Screenshots (paths come from SHOT_DIR):
-- grass_seam_during.png -- the seam step, map == ROUTE_1 and cellY < 0
-- grass_seam_after.png -- one clean frame after the step lands (cellY >= 0)
--
-- Run: POKEPORT_DRIVER=tests/drivers/grass_seam_bug217_test.lua \
-- POKEPORT_IDENTITY=bug217 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
-- a party + starter flag so the overworld is fully usable
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
local shotDir = os.getenv("SHOT_DIR") or "."
-- south exit path column; four cells above the bottom edge (cellY 35)
U.teleport(game, "VIRIDIAN_CITY", 20, 31, "down")
local ow = game.overworld
local shotDuring, shotAfter = false, false
for i = 1, 120 do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
coroutine.yield()
local p = ow.player
local inb = ow.map:inBounds(p.cellX, p.cellY)
U.log(("f=%d map=%-14s cell=(%d,%d) inb=%s grassCell=%s py=%d moving=%s")
:format(i, ow.map.id, p.cellX, p.cellY, tostring(inb),
tostring(ow.map:isGrassCell(p.cellX, p.cellY)), p.py, tostring(p.moving)))
-- BEFORE the fix this is exactly the frame the phantom grass draws:
-- map is now ROUTE_1 but the player is still parked off the top edge.
if not shotDuring and ow.map.id == "ROUTE_1" and p.cellY < 0 then
game.input.state.down = false
U.shot(game, shotDir .. "/grass_seam_during.png")
shotDuring = true
game.input.state.down = true
end
-- one clean frame after the seam step lands on the real map
if shotDuring and not shotAfter and ow.map.id == "ROUTE_1"
and p.cellY >= 0 and not p.moving then
game.input.state.down = false
U.shot(game, shotDir .. "/grass_seam_after.png")
shotAfter = true
break
end
end
game.input.state.down = false
U.wait(2)
if not shotDuring then U.log("WARN: never captured the seam step frame") end
if not shotAfter then U.log("WARN: never captured a landed frame") end
end
+71
View File
@@ -0,0 +1,71 @@
-- Driver: reproduce/verify #204 -- the PvP link/online battle crash when the
-- "level ruling" is left on ANY.
--
-- The link level picker cycles a string sentinel "ANY" meaning "use each
-- mon's real level" (Gen1's link cable always fought at the real level, so
-- ANY is the project's name for "no forced level"). LinkState.levelForWire is
-- supposed to turn that sentinel into nil before it goes on the wire; a broken
-- `x and nil or y` idiom instead let the literal "ANY" reach opts.forceLevel,
-- and Protocol.unpackMon then crashed on math.floor("ANY") the instant newHost
-- unpacked the parties (report traceback: Protocol.lua unpackMon <- LinkBattle
-- unpackParty <- newHost <- LinkState.update). This drives that exact host
-- battle path in a real window with the bad value forceLevel = "ANY".
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local Net = require("src.link.Net")
local LinkBattle = require("src.link.LinkBattle")
-- a low-level host mon and a high-level foe: the ANY ruling must keep both
-- real levels (12 and 100), unlike an AUTO 50 ruling that forces them equal
game.save.party = { Pokemon.new(game.data, "PIKACHU", 12) }
game.save.player.name = "RED"
local foeParty = { Pokemon.new(game.data, "GEODUDE", 100) }
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
local netA = Net.loopbackPair() -- the guest end is unused: the intro is
-- local, and the crash (if present) fires
-- during newHost before any turn is taken
local opts = {
myParty = Protocol.packParty(game.save.party),
theirParty = Protocol.packParty(foeParty),
theirName = "BLUE",
seed = 24680,
forceLevel = "ANY", -- the exact value LinkState.levelForWire wrongly emitted
}
local ok, battle = pcall(LinkBattle.newHost, game, netA, opts)
if not ok then
-- pre-fix: math.floor("ANY") throws inside unpackMon; the battle never
-- starts. Capture the still-in-overworld state and the error message
-- (the harness would otherwise just print "driver error" and exit, with
-- no on-screen error screen to shoot).
U.log("LINK_ANY_204: newHost CRASHED (bug present): " .. tostring(battle))
U.shot(game, DIR .. "/link_any_204_crash.png")
U.log("LINK_ANY_204: done (crash reproduced)")
return
end
-- post-fix: the battle constructs; push it and screenshot the intro/scene
game.stack:push(battle)
U.wait(20)
U.shot(game, DIR .. "/link_any_204_battle_intro.png")
U.wait(40)
U.shot(game, DIR .. "/link_any_204_battle_scene.png")
local hostLvl = battle.player and battle.player.mon and battle.player.mon.level
local foeLvl = battle.enemy and battle.enemy.mon and battle.enemy.mon.level
U.log(("LINK_ANY_204: battle started; host lvl=%s foe lvl=%s (expect 12 / 100)")
:format(tostring(hostLvl), tostring(foeLvl)))
if hostLvl == 12 and foeLvl == 100 then
U.log("LINK_ANY_204: PASS -- real levels preserved under the ANY ruling")
else
U.log("LINK_ANY_204: FAIL -- levels not preserved (host=" ..
tostring(hostLvl) .. " foe=" .. tostring(foeLvl) .. ")")
end
U.log("LINK_ANY_204: done")
end
@@ -0,0 +1,162 @@
-- Driver: regression coverage for #219 "Early Blue Battle".
--
-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) is the rival's *talk*
-- handler. In pret/pokered scripts/OaksLab.asm OaksLabText8, talking to
-- the rival after you have a starter but before the lab battle only prints
-- _OaksLabRivalMyPokemonLooksStrongerText: the battle itself is a
-- coordinate trigger (OaksLabRivalChallengesPlayerScript, wYCoord == 6),
-- never a talk action. The buggy handler fell through from that line
-- straight into start_battle OPP_RIVAL1, so talking to Blue at the table
-- immediately launched the rival fight.
--
-- Scenario A (the #219 regression): talk to the rival with a starter but
-- no lab battle yet. Correct: the "looks stronger" line shows and NO
-- battle starts. Fails before the fix (start_battle fires on talk).
-- Scenario B (guard against over-correction): step onto the coordinate
-- trigger (y >= 6). Correct: the onStep challenge still starts the
-- battle. Passes both before and after the fix.
--
-- start_battle is stubbed to record the call and return "end" (halts the
-- script cleanly, no BattleState push, no yield) so the run never hangs
-- and needs no full party. We also read the raw text handed to
-- TextBox.new (before {PLAYER}/{RIVAL} substitution) so "stronger" is
-- detectable.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Capture the raw text the next TextBox is built with.
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local lastText
TextBox.new = function(g, text, ...)
lastText = text
return origNew(g, text, ...)
end
-- Stub start_battle: record it and halt the script instead of pushing a
-- BattleState (which would need a real party) or yielding (which would
-- hang the driver). ScriptRunner resolves the live Commands.start_battle
-- when no mod overrides it, so this monkeypatch intercepts both the talk
-- handler and the onStep challenge.
local Commands = require("src.script.Commands")
local origStartBattle = Commands.start_battle
local battleStarted = false
Commands.start_battle = function(_ctx, _kind, _a, _b)
battleStarted = true
return "end"
end
local function restore()
TextBox.new = origNew
Commands.start_battle = origStartBattle
end
local function setFlags()
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_SQUIRTLE = true
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
end
-- ---- Scenario A: talk to the rival at the table.
-- fresh overworld in Oak's lab, player one cell right of the rival
-- (object 1 at cell 4,3) and facing him.
U.teleport(game, "OAKS_LAB", 5, 3, "left")
setFlags()
U.wait(6)
battleStarted = false
lastText = nil
U.shot(game, DIR .. "/a_before.png")
-- open the rival's textbox
U.tap(game, "left"); U.wait(2)
for _ = 1, 8 do
U.tap(game, "a")
for _ = 1, 30 do
if lastText then break end
U.wait(1)
end
if lastText then break end
end
local strongerSeen = lastText ~= nil and lastText:find("stronger") ~= nil
-- let the typewriter reveal the line, then shoot the box: the "looks
-- stronger" taunt, still overworld, no battle intro
U.wait(30)
U.shot(game, DIR .. "/a_after.png")
-- dismiss the taunt box. Before the fix, closing it drops the script
-- into the buggy start_battle rows (battleStarted flips true); after the
-- fix it hits jump "end" and the stack settles back to the overworld.
-- Stop the moment either happens so we never re-open the box by talking
-- again (which would leave a stray TextBox on top and false-fail the
-- overworld check).
for _ = 1, 20 do
if battleStarted then break end
if game.stack:top() == game.overworld then break end
U.tap(game, "a")
U.wait(3)
end
U.wait(10) -- let any fall-through start_battle fire
local aBattleStarted = battleStarted
local aOverworld = (game.stack:top() == game.overworld)
local aText = lastText or "<none>"
local aPass = strongerSeen and (aBattleStarted == false) and aOverworld
U.log("SCENARIO A text:", aText)
U.log("SCENARIO A strongerSeen:", tostring(strongerSeen),
"battleStarted:", tostring(aBattleStarted),
"overworld:", tostring(aOverworld))
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
-- close any open box before scenario B
for _ = 1, 10 do U.tap(game, "a"); U.wait(2) end
-- ---- Scenario B: step onto the coordinate trigger (y >= 6).
-- Guards the real challenge path so the fix doesn't kill the lab battle.
U.teleport(game, "OAKS_LAB", 4, 5, "down")
setFlags()
U.wait(6)
battleStarted = false
do
local p = game.overworld.player
U.log("SCENARIO B start cell:", tostring(p.cellX), tostring(p.cellY),
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
end
-- walk down until the player crosses y == 6 (OaksLabRivalChallenges),
-- pausing to mash A so the "I'll take you on" box + rival walk advance
for _ = 1, 6 do
U.hold(game, "down", 8)
for _ = 1, 12 do
U.tap(game, "a")
U.wait(3)
if battleStarted then break end
end
if battleStarted then break end
end
do
local p = game.overworld.player
U.log("SCENARIO B end cell:", tostring(p.cellX), tostring(p.cellY),
"lastText:", tostring(lastText))
end
U.shot(game, DIR .. "/b_after.png")
local bPass = (battleStarted == true)
U.log("SCENARIO B battleStarted:", tostring(battleStarted))
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
-- restore hooks before any assert so a failure can't leave them installed
restore()
U.log("RESULT bug219", (aPass and bPass) and "PASS" or "FAIL")
assert(aPass,
"Scenario A: talking to the rival must only show the 'looks stronger' "
.. "line and start no battle (strongerSeen/battleStarted=false/overworld); "
.. "text=" .. aText .. " battleStarted=" .. tostring(aBattleStarted))
assert(bPass,
"Scenario B: stepping onto the coordinate trigger must still start the "
.. "lab battle")
end
@@ -0,0 +1,122 @@
-- Driver: regression coverage for #232 "Hey! Don't go away yet!".
--
-- The Oak leave-block in data/scripts/oaks_lab.lua onStep must halt a
-- pre-starter player at the bookshelf row (cell y == 6), the same
-- coordinate the sibling rival challenge below it uses. In
-- pret/pokered scripts/OaksLab.asm the "don't go away yet" intercept
-- and OaksLabRivalChallengesPlayerScript share the wYCoord == 6
-- coordinate script slot (gated on EVENT_GOT_STARTER); the bookshelves
-- flank that corridor, so Oak stops you level with the shelves rather
-- than one full corridor later on the exit mat.
--
-- Bug (before the fix): the guard read `y == 11 and (x == 4 or x == 5)`,
-- i.e. it only fired on the exit door mat. A pre-starter player walked
-- the whole corridor down through the shelves (y=6,7,8,9,10) and was only
-- halted at y=11, exactly the port screenshot the issue attached.
--
-- OAKS_LAB is 10x12 cells (x 0-9, y 0-11). Live walkability:
-- y=5 .......... (open)
-- y=6 ####..#### (bookshelves solid at x=0-3/6-9, corridor at x=4,5)
-- y=7 ####..####
-- y=10 .......... door mat warps at (4,11)/(5,11)
-- so at y>=6 only the x=4,5 corridor is walkable, and once Oak pushes the
-- player back to y=5 they can never reach y>=7 -- the trigger only ever
-- fires at y=6 in the corridor, matching the rival trigger's shape.
--
-- We monkeypatch TextBox.new to detect the raw "Don't go" text and record
-- the player's cell Y at that instant (interceptY). No battle is involved
-- (EVENT_GOT_STARTER stays clear so the rival-challenge branch is skipped),
-- so nothing needs stubbing.
return function(game)
io.stdout:setvbuf("no") -- LOVE block-buffers stdout; flush [driver] logs
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Capture the raw text handed to TextBox and the Y it fired at.
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local dontGoSeen = false
local interceptY = nil
TextBox.new = function(g, text, ...)
if type(text) == "string" and text:find("Don't go", 1, true) then
dontGoSeen = true
if game.overworld and game.overworld.player then
interceptY = interceptY or game.overworld.player.cellY
end
end
return origNew(g, text, ...)
end
local function restore()
TextBox.new = origNew
end
-- The pre-starter state right after the walk-in cutscene: FOLLOWED_OAK
-- set, no starter yet, no lab battle yet.
local function setFlags()
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_GOT_STARTER = nil
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
end
-- story2.lua labWalkIn ends the follow-Oak cutscene at (5,3) facing down.
U.teleport(game, "OAKS_LAB", 5, 3, "down")
setFlags()
U.wait(6)
local ow = game.overworld
U.log("start cell:", tostring(ow.player.cellX), tostring(ow.player.cellY),
"map:", ow.map.id)
U.shot(game, DIR .. "/bug232_start.png")
-- Walk down toward the exit in bursts, mashing A to dismiss the "don't go
-- away" box each time it opens. Track how far down the player ever gets.
local maxY = ow.player.cellY
local shotIntercept = false
for _ = 1, 12 do
U.hold(game, "down", 8)
maxY = math.max(maxY, ow.player.cellY)
if dontGoSeen and not shotIntercept then
-- let the typewriter reveal the line before shooting the box
U.wait(20)
U.shot(game, DIR .. "/bug232_intercept.png")
shotIntercept = true
end
for _ = 1, 6 do
U.tap(game, "a")
U.wait(2)
maxY = math.max(maxY, ow.player.cellY)
end
maxY = math.max(maxY, ow.player.cellY)
U.log("iter cell:", tostring(ow.player.cellX), tostring(ow.player.cellY),
"map:", ow.map.id, "maxY:", maxY, "dontGoSeen:", tostring(dontGoSeen))
if ow.map.id ~= "OAKS_LAB" then break end
end
U.shot(game, DIR .. "/bug232_end.png")
local finalMap = ow.map.id
U.log("RESULT dontGoSeen:", tostring(dontGoSeen),
"interceptY:", tostring(interceptY), "maxY:", maxY,
"finalMap:", finalMap, "finalY:", tostring(ow.player.cellY))
-- restore the hook before any assert so a failure can't leave it installed
restore()
assert(dontGoSeen,
"Oak's 'don't go away yet' block must fire when a pre-starter player "
.. "walks toward the exit")
assert(interceptY == 6,
"Oak must intercept at the bookshelf row (cell y == 6), not the exit "
.. "mat; interceptY=" .. tostring(interceptY))
assert(maxY <= 6,
"the player must never pass the bookshelves (y>=7) before Oak stops "
.. "them; maxY=" .. tostring(maxY))
assert(finalMap == "OAKS_LAB",
"the pre-starter player must never warp out of the lab; finalMap="
.. tostring(finalMap))
U.log("RESULT bug232 PASS")
end
@@ -0,0 +1,92 @@
-- Driver: regression coverage for #218 "Blue mentions Oak being absent".
--
-- TEXT_OAKSLAB_RIVAL (data/scripts/oaks_lab.lua) picks the pre-starter line.
-- pret/pokered scripts/OaksLab.asm gates that line on EVENT_FOLLOWED_OAK_INTO_LAB:
-- * flag CLEAR (Oak has not escorted you in yet) -> "Yo {PLAYER}! Gramps
-- isn't around!" (_OaksLabRivalGrampsIsntAroundText)
-- * flag SET (Oak present, three balls on the table) -> "Heh, I don't need
-- to be greedy... Go ahead and choose, {PLAYER}!" (_OaksLabRivalGoAheadAndChooseText)
-- The buggy handler jumped straight to GrampsIsntAround for any no-starter
-- state, so the rival wrongly claimed Oak was gone while Oak stood in the lab.
--
-- We read the RAW text handed to TextBox.new (before pagination and {PLAYER}/
-- {RIVAL} substitution) so the distinctive words "greedy"/"Gramps" survive.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- Capture the raw text the next TextBox is built with.
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local lastText
TextBox.new = function(g, text, ...)
lastText = text
return origNew(g, text, ...)
end
-- fresh overworld in Oak's lab, player standing one cell right of the
-- rival (object 1 at cell 4,3) and facing him.
local function setup(followed)
U.teleport(game, "OAKS_LAB", 5, 3, "left")
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = followed or nil
flags.EVENT_GOT_STARTER = nil
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
U.wait(6)
end
-- face the rival and press A until his text box opens (lastText set).
local function talkToRival()
lastText = nil
U.tap(game, "left"); U.wait(2)
for _ = 1, 5 do
U.tap(game, "a")
for _ = 1, 60 do
if lastText then return end
U.wait(1)
end
end
end
-- close any open text box before the next scenario.
local function dismiss()
for _ = 1, 20 do U.tap(game, "a"); U.wait(2) end
end
-- ---- Scenario A: Oak has escorted the player in, no starter chosen.
-- Correct Gen1 line: the greedy / "go ahead and choose" taunt.
setup(true)
U.shot(game, DIR .. "/a_before.png")
talkToRival()
U.wait(20)
U.shot(game, DIR .. "/a_after.png")
local aText = lastText or "<none>"
local aPass = (aText:find("greedy") ~= nil) and (aText:find("Gramps") == nil)
U.log("SCENARIO A (FOLLOWED_OAK set) text:", aText)
U.log("SCENARIO A", aPass and "PASS" or "FAIL")
dismiss()
-- ---- Scenario B: very early game, Oak has not walked you in yet.
-- Correct Gen1 line: "Gramps isn't around". Guards the appended
-- jump_if_false path so the fix keeps the true-early-game text.
setup(false)
talkToRival()
U.wait(20)
U.shot(game, DIR .. "/b_after.png")
local bText = lastText or "<none>"
local bPass = (bText:find("Gramps") ~= nil)
U.log("SCENARIO B (FOLLOWED_OAK clear) text:", bText)
U.log("SCENARIO B", bPass and "PASS" or "FAIL")
-- restore before any assert so a failure can't leave the hook installed
TextBox.new = origNew
U.log("RESULT bug218", (aPass and bPass) and "PASS" or "FAIL")
assert(aPass,
"Scenario A: rival must give the greedy/choose line while Oak is in the lab, got: " .. aText)
assert(bPass,
"Scenario B: rival must say Gramps isn't around before Oak escorts you, got: " .. bText)
end
@@ -0,0 +1,170 @@
-- Driver: regression coverage for #231 "Missing Blue Dialogue (first rival
-- battle exit line)".
--
-- The first lab rival battle is a coordinate trigger in
-- data/scripts/oaks_lab.lua onStep (OaksLabRivalChallengesPlayerScript,
-- wYCoord == 6). In pret/pokered scripts/OaksLab.asm the post-battle
-- OaksLabRivalEndBattleScript heals + flags, then on WIN prints the
-- "I picked the wrong POKéMON!" gloat, and on BOTH win and loss prints the
-- shared exit line _OaksLabRivalSmellYouLaterText ("OK! I'll make my POKéMON
-- fight to toughen it up!\012<PLAYER>! Gramps! Smell you later!") before Blue
-- walks out. The buggy onStep sequence omitted that exit line entirely, so
-- Blue left the lab silently.
--
-- Scenario WIN: after the battle Blue must gloat ("picked the wrong POKéMON")
-- AND say the exit line ("Smell you later").
-- Scenario LOSS: Blue skips the gloat (that taunt was shown in-battle via
-- Rival1WinText) but must STILL say the exit line ("Smell you later").
--
-- Both scenarios FAIL before the fix (the exit line never appears) and PASS
-- after it.
--
-- Mechanics: TextBox.new is hooked to APPEND every raw `text` arg (before
-- {PLAYER}/{RIVAL} substitution, so the token-free search substrings survive)
-- into `seen`; the whole SmellYouLater string, incl. the \012 page break,
-- arrives in one TextBox.new call. start_battle is stubbed to record the
-- result and RETURN NIL (not "end"): ScriptRunner then continues
-- synchronously into heal_party/set_flag/jump_if_false/show_text with NO
-- BattleState push and NO yield, so the run needs no party and cannot hang.
-- heal_party is a safe no-op on an empty party.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- accumulate every raw text a TextBox is built with
local TextBox = require("src.render.TextBox")
local origNew = TextBox.new
local seen = {}
TextBox.new = function(g, text, ...)
if type(text) == "string" then seen[#seen + 1] = text end
return origNew(g, text, ...)
end
-- Stub start_battle: set the win/loss result the post-battle rows branch on
-- and return nil so ScriptRunner falls through to heal_party/set_flag/
-- jump_if_false/show_text with no BattleState and no yield. ScriptRunner
-- resolves the live Commands.start_battle when no mod overrides it, so this
-- monkeypatch intercepts the onStep challenge (Commands.resolve).
local Commands = require("src.script.Commands")
local origStartBattle = Commands.start_battle
local winResult = true
Commands.start_battle = function(ctx, _kind, _a, _b)
ctx.lastBattleResult = winResult and "win" or "lose"
ctx.lastCheck = winResult -- Rival1: check = (result == "win")
return nil
end
local function restore()
TextBox.new = origNew
Commands.start_battle = origStartBattle
end
-- Set the pre-battle flag state AND clear the rival's object toggle. The
-- WIN sequence ends with hide_object OAKSLAB_RIVAL, which persists as
-- save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = false; without clearing it the
-- LOSS re-teleport spawns with the rival hidden and onStep (which returns
-- false when npcByIndex(1) is nil) never fires the challenge. Must run
-- BEFORE U.teleport, since the map spawns its objects on push.
local function resetSave()
local flags = game.save.flags or {}
game.save.flags = flags
flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
flags.EVENT_GOT_STARTER = true
flags.EVENT_CHOSE_SQUIRTLE = true
flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = nil
if game.save.objectToggles and game.save.objectToggles.OAKS_LAB then
game.save.objectToggles.OAKS_LAB.OAKSLAB_RIVAL = nil
end
end
local function seenHas(sub)
for _, t in ipairs(seen) do
if t:find(sub, 1, true) then return true end
end
return false
end
-- Walk down onto the y>=6 trigger, then page through the post-battle boxes
-- and the walk-out. Bounded so the window always quits. Captures the exit
-- line box the first time "Smell you later" appears, and a final overworld
-- shot once Blue has despawned (hide_object at the end of the sequence).
local function drive(smellShot, endShot)
local grabbedSmell = false
for _ = 1, 500 do
if (not grabbedSmell) and seenHas("Smell you later") then
U.wait(18) -- let the typewriter reveal the line before the shot
U.shot(game, smellShot)
grabbedSmell = true
end
local rival = game.overworld:npcByIndex(1)
if rival == nil then break end -- rival walked out + hid: sequence done
-- before the trigger fires the player must step down onto y>=6; after,
-- A pages every text box (IllTakeYouOn, IPicked, SmellYouLater)
local p = game.overworld.player
if p and (p.cellY or 0) < 6 then
U.hold(game, "down", 8)
end
U.tap(game, "a")
U.wait(3)
end
U.wait(6)
U.shot(game, endShot)
return grabbedSmell
end
-- ---- Scenario WIN
resetSave()
U.teleport(game, "OAKS_LAB", 4, 5, "down")
U.wait(6)
winResult = true
seen = {}
do
local p = game.overworld.player
U.log("WIN start cell:", tostring(p.cellX), tostring(p.cellY),
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
end
U.shot(game, DIR .. "/win_before.png")
drive(DIR .. "/win_smell.png", DIR .. "/win_end.png")
local winGloat = seenHas("picked the")
local winSmell = seenHas("Smell you later")
local winPass = winGloat and winSmell
U.log("WIN gloat(picked the):", tostring(winGloat),
"exit(Smell you later):", tostring(winSmell))
U.log("WIN", winPass and "PASS" or "FAIL")
-- ---- Scenario LOSS
resetSave()
U.teleport(game, "OAKS_LAB", 4, 5, "down")
U.wait(6)
winResult = false
seen = {}
do
local p = game.overworld.player
U.log("LOSS start cell:", tostring(p.cellX), tostring(p.cellY),
"rival:", tostring(game.overworld:npcByIndex(1) ~= nil))
end
U.shot(game, DIR .. "/loss_before.png")
drive(DIR .. "/loss_smell.png", DIR .. "/loss_end.png")
local lossGloat = seenHas("picked the")
local lossSmell = seenHas("Smell you later")
-- loss must skip the win gloat but still print the shared exit line
local lossPass = lossSmell and (not lossGloat)
U.log("LOSS gloat(picked the):", tostring(lossGloat),
"exit(Smell you later):", tostring(lossSmell))
U.log("LOSS", lossPass and "PASS" or "FAIL")
-- restore hooks before any assert so a failure can't leave them installed
restore()
U.log("RESULT bug231", (winPass and lossPass) and "PASS" or "FAIL")
assert(winPass,
"WIN: after the first lab rival battle Blue must gloat "
.. "('I picked the wrong POKéMON!') AND say the exit line "
.. "('Smell you later'); got gloat=" .. tostring(winGloat)
.. " exit=" .. tostring(winSmell))
assert(lossPass,
"LOSS: after losing the first lab rival battle Blue must skip the gloat "
.. "but STILL say the exit line ('Smell you later'); got gloat="
.. tostring(lossGloat) .. " exit=" .. tostring(lossSmell))
end
@@ -0,0 +1,107 @@
-- Driver: OG BLUE (GBC boot-ROM) palette correctness for Pokemon Blue (#155).
--
-- Pokemon Blue, like Red, ships no CGB code, so a Game Boy Color colorizes it
-- from the boot ROM's per-game auto-palette table. Blue's entry is NOT a
-- mirror of Red's and does NOT share Red's green characters: per Bulbapedia's
-- Generation-I GBC boot-ROM palette table (and the Gambatte hardware capture
-- attached to #155) Blue is a light-blue/blue BACKGROUND
-- with a PINK object (OBP0) palette -- the same red/pink ramp Red uses for its
-- BACKGROUND. The port had baked a fabricated "channel-swapped Red" BG and
-- kept Red's green sprites for both versions, so a Blue playthrough in COLORS =
-- OG rendered periwinkle terrain and a green player instead of blue terrain and
-- a pink player.
--
-- These checks are pure-data (version-forced via GameVersion.set) so they run
-- even on a Red-only cache; the two screenshots force the Blue OG palette over
-- the overworld for visual before/after evidence. Ground-truth RGB below is
-- Bulbapedia BG 0xFFFFFF/0x63A5FF/0x0000FF/0x000000, OBP0
-- 0xFFFFFF/0xFF8484/0x943A3A/0x000000.
--
-- Run: SHOT_DIR=/tmp/ogblue POKEPORT_IDENTITY=bug155 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/ogblue_palette_bug155_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local PaletteFX = require("src.render.PaletteFX")
local GameVersion = require("src.core.GameVersion")
local DIR = os.getenv("SHOT_DIR") or "."
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
-- deep-equal for a 4-color {r,g,b} palette table
local function palEq(a, b)
if type(a) ~= "table" or type(b) ~= "table" or #a ~= #b then return false end
for i = 1, #a do
local ca, cb = a[i], b[i]
if type(ca) ~= "table" or type(cb) ~= "table" then return false end
if ca[1] ~= cb[1] or ca[2] ~= cb[2] or ca[3] ~= cb[3] then return false end
end
return true
end
local TRUE_BG = { {255,255,255}, {99,165,255}, {0,0,255}, {0,0,0} }
local TRUE_OBJ = { {255,255,255}, {255,132,132}, {148,58,58}, {0,0,0} }
-- (1) the background constant is the real GBC Blue BG, not the periwinkle
-- 0x8484FF/0x3A3A94 mirror of Red
expect(palEq(PaletteFX.GBC_BG_BLUE, TRUE_BG),
"GBC_BG_BLUE == real GBC Blue BG 0x63A5FF/0x0000FF, got",
PaletteFX.GBC_BG_BLUE and PaletteFX.GBC_BG_BLUE[2]
and table.concat(PaletteFX.GBC_BG_BLUE[2], ","))
-- (2) a dedicated Blue OBJ (OBP0) constant exists and is the pink ramp
expect(PaletteFX.GBC_OBJ_BLUE ~= nil and palEq(PaletteFX.GBC_OBJ_BLUE, TRUE_OBJ),
"GBC_OBJ_BLUE == real GBC Blue OBP0 pink 0xFF8484/0x943A3A")
-- (3) ... and it is NOT the green Red OBJ palette
expect(PaletteFX.GBC_OBJ_BLUE ~= nil
and not palEq(PaletteFX.GBC_OBJ_BLUE, PaletteFX.GBC_OBJ),
"Blue OBJ is NOT the green Red GBC_OBJ")
-- (4) version routing: as Blue, the OG helpers resolve Blue's palettes
local savedVer = GameVersion.get()
GameVersion.set("blue")
expect(palEq(PaletteFX.ogBg(), TRUE_BG), "ogBg() -> Blue BG when isBlue()")
expect(type(PaletteFX.ogObj) == "function", "PaletteFX.ogObj() helper exists")
if type(PaletteFX.ogObj) == "function" then
local c = PaletteFX.ogObj()
expect(palEq(c, TRUE_OBJ), "ogObj() -> Blue pink OBJ when isBlue()")
end
-- (5) version routing: as Red, the OG helpers still resolve Red's palettes
-- (green player over the red field is correct and must not regress)
GameVersion.set("red")
expect(palEq(PaletteFX.ogBg(), PaletteFX.GBC_BG), "ogBg() -> Red BG when Red")
if type(PaletteFX.ogObj) == "function" then
expect(palEq(PaletteFX.ogObj(), PaletteFX.GBC_OBJ),
"ogObj() -> green Red OBJ when Red")
end
-- Visual proof: force Blue's OG palette over the (Red-cache) overworld. The
-- terrain colors come from ogBg() and the player sprite from ogObj(), so the
-- shot exercises the exact code the fix touches. Before: periwinkle terrain
-- + green player; after: light-blue/blue terrain + pink player, matching
-- the Gambatte capture attached to #155.
GameVersion.set("blue")
game.save.options = game.save.options or {}
game.save.options.colors = "ogred"
PaletteFX.setMode("ogred")
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "SQUIRTLE", 5) }
U.teleport(game, "ROUTE_1", 5, 5, "down")
U.wait(60)
U.shot(game, DIR .. "/ogblue_01_route1.png")
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(60)
U.shot(game, DIR .. "/ogblue_02_pallet.png")
GameVersion.set(savedVer)
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed")
end
@@ -0,0 +1,74 @@
-- Driver: party menu bottom context message (#147).
-- Gen1 (pokered engine/menus/party_menu.asm PartyMenuMessage) always prints
-- a message in the bottom text box: "Choose a POKéMON." (PartyMenuNormalText)
-- in the field, "Bring out which POKéMON?" (PartyMenuBattleText) in battle.
-- The recomp handled only the swap / item / TM-HM ids and printed NOTHING for
-- the default field and battle voluntary-switch cases -- reporter's "NO TEXT
-- BOX". This driver opens the party menu in both contexts, screenshots each,
-- and asserts PartyMenu:bottomMessage() returns the correct Gen1 string.
-- POKEPORT_DRIVER=tests/drivers/party_bug147_message_test.lua \
-- POKEPORT_IDENTITY=bug147 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
local Screens = require("src.ui.Screens")
local pass, fail = 0, 0
local function check(label, ok)
if ok then pass = pass + 1; U.log("PASS", label)
else fail = fail + 1; U.log("FAIL", label) end
end
game.save.party = {
Pokemon.new(game.data, "CHARMANDER", 12),
Pokemon.new(game.data, "SQUIRTLE", 10),
}
-- FIELD case: party menu opened from the overworld (StartMenu path).
U.teleport(game, "ROUTE_1", 5, 5, "down")
local ow = game.overworld
Screens.push(game, "PartyMenu")
U.wait(8)
U.shot(game, DIR .. "/party_field_message.png")
local pm = game.stack:top()
local fieldMsg = pm and pm.bottomMessage and pm:bottomMessage()
U.log("field bottomMessage:", tostring(fieldMsg))
check("field message == 'Choose a POKéMON.'",
fieldMsg == "Choose a POKéMON.")
-- back to the overworld before starting the battle
while game.stack:top() and game.stack:top() ~= ow do game.stack:pop() end
U.wait(2)
-- BATTLE case: voluntary PKMN switch (BattleState:openParty).
local battle = BattleState.newWild(game, "PIDGEY", 8)
battle.onFinish = function() end
ow:pushBattle(battle)
local function mashUntil(cond, max)
for _ = 1, max or 80 do
if cond() then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
check("reached battle menu", mashUntil(function()
return battle.phase == "menu"
end))
-- FIGHT/PKMN/ITEM/RUN: RIGHT to PKMN, then A to open the party
U.tap(game, "right"); U.wait(4)
U.tap(game, "a"); U.wait(12)
U.shot(game, DIR .. "/party_battle_message.png")
pm = game.stack:top()
local battleMsg = pm and pm.bottomMessage and pm:bottomMessage()
U.log("battle party open (onSwitch set):", pm and pm.onSwitch ~= nil)
U.log("battle bottomMessage:", tostring(battleMsg))
check("battle message == 'Bring out which\\nPOKéMON?'",
battleMsg == "Bring out which\nPOKéMON?")
U.log(("RESULT pass=%d fail=%d"):format(pass, fail))
end
+74
View File
@@ -0,0 +1,74 @@
-- Driver: the bedroom PC (Red's House 2F) must open the player's item PC
-- directly (WITHDRAW/DEPOSIT/TOSS/LOG OFF), NOT the Pokemon Center multi-PC
-- main menu (SOMEONE'S PC / <name>'s PC / LOG OFF). #228
--
-- pokered: the bedroom PC's hidden-object callback is OpenRedsPC
-- (engine/events/hidden_objects/players_pc.asm) which runs the PlayerPC
-- predef, versus the Pokemon Center PC callback which shows DisplayPCMainMenu.
--
-- SHOT_DIR=/tmp/bug228 POKEPORT_DRIVER=tests/drivers/pc_bug228_test.lua \
-- POKEPORT_IDENTITY=bug228 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Menu = require("src.ui.Menu")
local pass = true
local function check(cond, msg)
if cond then U.log("PASS: " .. msg) else pass = false; U.log("FAIL: " .. msg) end
end
-- Stand at (0,2) facing up so facingCell() = (0,1), the bedroom PC tile
-- (field.lua hiddenExtras.pcTiles.REDS_HOUSE_2F = {{facing="up",x=0,y=1}}).
U.teleport(game, "REDS_HOUSE_2F", 0, 2, "up")
-- teleport bypasses New Game, which seeds pcItems={POTION=1}
-- (src/core/SaveData.lua); seed it so the withdraw list has content.
game.save.pcItems = game.save.pcItems or { POTION = 1 }
U.tap(game, "a") -- interact -> tryHiddenObject -> the bedroom PC
U.wait(8)
local menu = game.stack:top()
check(getmetatable(menu) == Menu, "a PC menu opened on A-press")
U.shot(game, DIR .. "/pc_bug228_bedroom_menu.png")
-- Inspect labels: the multi-PC main menu carries SOMEONE'S/BILL'S/<name>'s
-- PC entries; the player's item PC does not.
local labels = {}
if menu and menu.items then
for _, it in ipairs(menu.items) do
labels[#labels + 1] = tostring(it.label)
end
end
U.log("labels: " .. table.concat(labels, " | "))
local function has(pat)
for _, l in ipairs(labels) do
if l:lower():find(pat, 1, true) then return true end
end
return false
end
-- BUG present if the multi-PC main menu opened.
check(not has("someone"), "no SOMEONE'S PC entry (not the box-PC main menu)")
check(not has("bill"), "no BILL'S PC entry")
check(not has("prof.oak"),"no PROF.OAK's PC entry")
check(not has("'s pc"), "no <name>'s PC entry (bedroom PC skips box storage)")
-- CORRECT: the player's item PC opened first-row WITHDRAW ITEM.
check(has("withdraw item"), "player item PC opened (WITHDRAW ITEM present)")
check(menu and menu.items and menu.items[1]
and tostring(menu.items[1].label) == "WITHDRAW ITEM",
"first row is WITHDRAW ITEM")
-- Visual proof: open the withdraw list to show the seeded POTION.
if menu and menu.items and menu.items[1]
and tostring(menu.items[1].label) == "WITHDRAW ITEM" then
U.tap(game, "a") -- WITHDRAW ITEM
U.wait(8)
U.shot(game, DIR .. "/pc_bug228_withdraw.png")
end
U.log(pass and "RESULT: ALL PASS" or "RESULT: SEE FAILURES ABOVE")
love.event.quit(pass and 0 or 1)
end
@@ -0,0 +1,127 @@
-- Driver: #194 Celadon prize room must require the COIN CASE.
-- engine/menus/prize_menu.asm CeladonPrizeMenu gates the prize window on the
-- COIN CASE: IsItemInBag COIN_CASE first, and with no case it prints
-- RequireCoinCaseText and returns without ever opening a window; only with the
-- case does it print ExchangeCoinsForPrizesText and then show the prize list.
-- The port used to open "PRIZES (COINS)" unconditionally with no intro line.
--
-- The three prize counters are bg-event signs at cells (2,2),(4,2),(6,2) in
-- GAME_CORNER_PRIZE_ROOM (data/generated/maps.lua). Stand south of vendor 1
-- and press A: no-case -> require box and NO list; has-case -> exchange box,
-- then the prize list; cancel returns to the overworld (onCancel == done).
--
-- SHOT_DIR=/tmp/prize194 POKEPORT_IDENTITY=bug194 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/prize_room_coincase_bug194_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
local ListMenu = require("src.ui.ListMenu")
-- a party so nothing else blocks overworld interaction
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 5) }
game.save.inventory = game.save.inventory or {}
game.save.coins = game.save.coins or 0
local function topMeta() return getmetatable(game.stack:top()) end
-- let a TextBox finish typing its current page (self.waiting) so shots
-- capture the full line instead of a mid-typewriter frame
local function settleText(maxFrames)
for _ = 1, maxFrames or 40 do
local top = game.stack:top()
if getmetatable(top) == TextBox and top.waiting then break end
U.wait(1)
end
end
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
-- ===== CASE 1: NO COIN CASE -> require box, prize list NEVER opens =====
game.save.inventory.COIN_CASE = nil
-- stand at (2,3) facing up; the faced cell is vendor sign 1 at (2,2)
U.teleport(game, "GAME_CORNER_PRIZE_ROOM", 2, 3, "up")
local ow = game.overworld
U.shot(game, DIR .. "/prize_room_0_before.png")
U.tap(game, "a")
-- scan several frames: a ListMenu must NEVER appear without the case
local sawListNoCase, sawRequire = false, false
for _ = 1, 60 do
if topMeta() == ListMenu then sawListNoCase = true end
if topMeta() == TextBox and pageText():find("COIN CASE", 1, true) then
sawRequire = true
end
U.wait(1)
end
U.log("no-case: sawRequire", sawRequire, "sawList", sawListNoCase)
settleText(40)
U.shot(game, DIR .. "/prize_room_1_nocase_requiretext.png")
assert(sawRequire,
"no-case: 'A COIN CASE is required!' box never shown (#194)")
assert(not sawListNoCase,
"no-case: prize ListMenu opened without a COIN CASE (#194)")
-- dismiss the require box; back to the overworld with no list ever opened
U.tap(game, "a")
for _ = 1, 30 do
if game.stack:top() == ow then break end
U.tap(game, "a")
U.wait(1)
end
assert(game.stack:top() == ow, "no-case: did not return to overworld")
-- ===== CASE 2: HAS COIN CASE -> exchange box, then the prize list =====
game.save.inventory.COIN_CASE = 1
U.tap(game, "a") -- talk to the same vendor sign again
local sawExchange = false
for _ = 1, 60 do
if topMeta() == TextBox and
(pageText():find("exchange", 1, true) or
pageText():find("coins for prizes", 1, true)) then
sawExchange = true
break
end
U.wait(1)
end
U.log("has-case: sawExchange", sawExchange)
settleText(140)
U.shot(game, DIR .. "/prize_room_2_exchange.png")
assert(sawExchange,
"has-case: 'We exchange your coins for prizes.' never shown (#194)")
-- advance past the exchange line; the prize list must then open
local sawList = false
for _ = 1, 60 do
if topMeta() == ListMenu then sawList = true break end
U.tap(game, "a")
U.wait(1)
end
U.log("has-case: sawList", sawList, "title",
(sawList and game.stack:top().title) or "-")
U.shot(game, DIR .. "/prize_room_3_menu.png")
assert(sawList, "has-case: prize ListMenu never opened after exchange text")
assert(game.stack:top().title == "PRIZES (COINS)",
"has-case: opened list is not the prize window")
-- cancel returns to the overworld (onCancel == done)
U.tap(game, "b")
for _ = 1, 30 do
if game.stack:top() == ow then break end
U.wait(1)
end
assert(game.stack:top() == ow, "has-case: cancel did not return to overworld")
U.log("prize_room_coincase_bug194_test: ok")
end
@@ -0,0 +1,115 @@
-- Driver: Red's-house corner staircase warp (issue #230).
--
-- REDS_HOUSE_1F/2F share an 8x8 layout with the stairs warp on the top-right
-- corner cell (7,1); the cell to its right is the map edge (widthCells-1==7),
-- so Warp.extraCheck's facingEdge branch answers "yes" to a right-bonk. Two
-- Gen1 invariants this driver pins:
--
-- 1. The warp cell you ARRIVE on is inert until you physically step off it
-- (CheckWarpsNoCollision / the arrival-disable in the completed-step
-- path). Holding right into the east wall while standing on (7,1) must
-- NOT re-fire the collision warp -- pre-fix it ping-ponged 1F<->2F every
-- input frame forever.
-- 2. A genuine wall bonk still animates the walk cycle in place (the
-- collision path runs UpdateSprites), so player:walkPhase() must reach 1
-- during the bonk while the cell stays put.
--
-- The stairs must still warp normally once the player steps off (7,1) and
-- back onto it, so the guard cannot break legitimate staircases.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/reds_house_stairs_bug230_test.lua \
-- POKEPORT_IDENTITY=bug230 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
local ow
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
local function settle(mapId)
for _ = 1, 300 do
ow = game.overworld
if ow and ow.map.id == mapId and not ow.transitioning
and #ow.scriptMoves == 0 and not ow.player.moving then
break
end
U.wait(1)
end
U.wait(4)
ow = game.overworld
end
-- 1) Arrive on the 2F stairs via a REAL warp so warpEntryCell/justWarped
-- are set. Teleporting straight onto (7,1) bypasses takeWarp and would
-- never set the arrival-inert state, so the bug could not reproduce --
-- we must walk up onto the 1F stairs and let the warp carry us.
U.teleport(game, "REDS_HOUSE_1F", 7, 3, "up")
settle("REDS_HOUSE_1F")
U.hold(game, "up", 40)
settle("REDS_HOUSE_2F")
expect(ow.map.id == "REDS_HOUSE_2F", "arrived upstairs, map:", ow.map.id)
expect(ow.player.cellX == 7 and ow.player.cellY == 1,
"standing on the stairs cell (7,1), got:",
ow.player.cellX, ow.player.cellY)
shot("reds230_arrive.png")
-- 2) Ping-pong guard (Fix 1) AND walk-in-place (Fix 2): hold right into the
-- east wall for 150 frames. Record every distinct floor id visited and
-- whether the sprite ever animates a walk frame.
local floors, order, seenPhase1 = {}, {}, false
do
local last
for _ = 1, 150 do
table.insert(game.input.pressQueue, "right")
game.input.state["right"] = true
coroutine.yield() -- Game:update runs here, processing this frame's input
local o = game.overworld
if o then
if o.map.id ~= last then table.insert(order, o.map.id); last = o.map.id end
floors[o.map.id] = true
if o.player:walkPhase() == 1 then seenPhase1 = true end
end
end
game.input.state["right"] = false
end
settle("REDS_HOUSE_2F")
shot("reds230_after_hold.png")
local distinct = 0
for _ in pairs(floors) do distinct = distinct + 1 end
expect(distinct == 1 and floors["REDS_HOUSE_2F"] == true,
"no floor ping-pong during the hold; distinct floors:", distinct,
"sequence:", table.concat(order, ">"))
expect(ow.map.id == "REDS_HOUSE_2F", "still upstairs after the hold, map:",
ow.map.id)
expect(ow.player.cellX == 7 and ow.player.cellY == 1,
"bonked in place, still at (7,1), got:",
ow.player.cellX, ow.player.cellY)
expect(not ow.player.moving and not ow.transitioning,
"settled after the hold, not mid-move/transition")
expect(seenPhase1,
"walk-in-place: player:walkPhase() reached 1 during the bonk")
-- 3) Anti-over-fix: the guard clears the instant the player steps off the
-- warp cell, so stepping south (off (7,1)) then back north still takes
-- the stairs. The exact southern cell does not matter -- only that we
-- leave (7,1) and that re-entering it still fires the warp.
U.hold(game, "down", 20)
settle("REDS_HOUSE_2F")
expect(ow.player.cellX == 7 and ow.player.cellY >= 2,
"stepped south off the stairs cell, got:",
ow.player.cellX, ow.player.cellY)
U.hold(game, "up", 40)
settle("REDS_HOUSE_1F")
expect(ow.map.id == "REDS_HOUSE_1F",
"stairs still warp after stepping off and back on, map:", ow.map.id)
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed")
end
@@ -0,0 +1,109 @@
-- Driver: Rocket Hideout elevator gates (#199).
--
-- Gen1 stamps a closed barred gate over the elevator doorway on every map
-- load, opening it only once the guarding Rockets are beaten:
-- scripts/RocketHideoutB1F.asm RocketHideoutB1FDoorCallbackScript
-- closed block $54 at (12,8), open block $0e; opens once
-- EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 is set (the guard by the lift;
-- EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the SFX bug noted
-- in the source -- so it is not part of the effective open condition).
-- scripts/RocketHideoutB4F.asm RocketHideoutB4FDoorCallbackScript
-- closed block $2d at (12,5), open block $0e; opens once BOTH
-- EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set.
-- B2F/B3F call no door callback (spinner-tile floors), so they get no gate.
--
-- The recomp shipped no stamping, so the .blk's open floor block ($0e/14)
-- showed through -- the reporter's "gate is open" screenshots. Pre-fix the
-- closed-gate asserts read 14 and fail; post-fix they read the barred block.
--
-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/rocket_hideout_gate_bug199_test.lua \
-- POKEPORT_IDENTITY=bug199 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local OW = require("src.world.OverworldController")
local failures = {}
local function check(cond, msg)
if cond then U.log("ok:", msg) else
table.insert(failures, msg); U.log("FAIL:", msg)
end
return cond
end
-- swap map, wiping event flags to a known slate, then settle until the
-- map has loaded and the player is idle so the door callback has stamped
local function load(mapId, x, y, flags)
while game.stack:top() do game.stack:pop() end
game.save.flags = {}
for k, v in pairs(flags or {}) do game.save.flags[k] = v end
game.stack:push(OW, mapId, x, y, "down")
for _ = 1, 240 do
local ow = game.overworld
if ow and ow.map and ow.map.id == mapId and not ow.transitioning
and not (ow.player and ow.player.moving) then
break
end
U.wait(1)
end
U.wait(3)
return game.overworld
end
local OPEN = 14 -- $0e floor block (doorway when unlocked)
local CLOSED_B1F = 84 -- $54 barred gate (bars in the north cell-row)
local CLOSED_B4F = 45 -- $2d barred gate (bars in the south cell-row)
-- B1F: barred until the lift guard (trainer 4) is beaten
local ow = load("ROCKET_HIDEOUT_B1F", 24, 14)
U.shot(game, DIR .. "/b1f_gate_closed.png")
check(ow.map:blockAt(12, 8) == CLOSED_B1F,
"B1F doorway (12,8) barred, got " .. tostring(ow.map:blockAt(12, 8)))
check(ow.map:blockAt(12, 9) == 44,
"B1F warp carpet (12,9) untouched, got " .. tostring(ow.map:blockAt(12, 9)))
-- B1F: opens once the guard is beaten
ow = load("ROCKET_HIDEOUT_B1F", 24, 14,
{ EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = true })
U.shot(game, DIR .. "/b1f_gate_open.png")
check(ow.map:blockAt(12, 8) == OPEN,
"B1F doorway opens after guard, got " .. tostring(ow.map:blockAt(12, 8)))
-- B4F: barred until BOTH guards are beaten
ow = load("ROCKET_HIDEOUT_B4F", 24, 8)
U.shot(game, DIR .. "/b4f_gate_closed.png")
check(ow.map:blockAt(12, 5) == CLOSED_B4F,
"B4F doorway (12,5) barred, got " .. tostring(ow.map:blockAt(12, 5)))
-- B4F: one guard is not enough
ow = load("ROCKET_HIDEOUT_B4F", 24, 8,
{ EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true })
check(ow.map:blockAt(12, 5) == CLOSED_B4F,
"B4F stays barred with only one guard, got " .. tostring(ow.map:blockAt(12, 5)))
-- B4F: opens once both guards are beaten
ow = load("ROCKET_HIDEOUT_B4F", 24, 8,
{ EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 = true,
EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1 = true })
U.shot(game, DIR .. "/b4f_gate_open.png")
check(ow.map:blockAt(12, 5) == OPEN,
"B4F doorway opens after both guards, got " .. tostring(ow.map:blockAt(12, 5)))
-- B2F has no dynamic gate in pokered (spinner floor); it must still load
ow = load("ROCKET_HIDEOUT_B2F", 24, 14)
check(ow and ow.map and ow.map.id == "ROCKET_HIDEOUT_B2F",
"B2F loads with no gate callback")
-- regression: the shared Silph Co card-key path must still stamp
ow = load("SILPH_CO_2F", 4, 4)
check(ow.map:blockAt(2, 2) == 84,
"SILPH_CO_2F door1 (2,2) still barred, got " .. tostring(ow.map:blockAt(2, 2)))
ow = load("SILPH_CO_2F", 4, 4, { EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = true })
check(ow.map:blockAt(2, 2) == 14,
"SILPH_CO_2F door1 opens with its event, got " .. tostring(ow.map:blockAt(2, 2)))
if #failures > 0 then
error(#failures .. " check(s) failed: " .. table.concat(failures, " | "))
end
U.log("all Rocket Hideout gate checks passed")
end
+1286 -133
View File
File diff suppressed because it is too large Load Diff
+103
View File
@@ -0,0 +1,103 @@
-- #223 exhaustive DOWN-hop sweep for ROUTE_4 (evidence driver).
--
-- The reporter's build (v0.1.25) and HEAD share byte-identical ledge code
-- (checkLedgeHop) and ledge data (tools/rom_manifest.json ->
-- data/generated/field.lua, the 8 rows of data/tilesets/ledge_tiles.asm), so
-- this reproduces exactly what the reporter would see.
--
-- For EVERY ROUTE_4 cell whose tile-at-feet is a south-ledge STANDING tile
-- (44/57, i.e. pokered $2C/$39) with a south-ledge tile (54/55, $36/$37)
-- directly below, teleport the player onto it facing DOWN, hold DOWN, and
-- assert the Gen1 hop fires (p.hopFrames>0) and lands two cells south.
--
-- Finding: all 139 reachable/functional south ledges hop. The only two that
-- do not -- (12,16) and (13,16) -- sit on the SOUTH boundary of the Mt Moon
-- Poke Center plaza, where the cell two south is off the map onto the border
-- mountain (tile 17); there is no landing, so the hop is correctly refused
-- (checkLedgeHop's landing-walkable gate). These are NOT the reporter's spot
-- (an open EAST plateau, cells ~62-80) and refusing a hop into the map border
-- is correct, so they are treated as EXPECTED refusals here.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/route4_downsweep_bug223.lua \
-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
end
game.save.options = game.save.options or {}
game.save.options.zoom = -2
-- same tile read as Map:cellTile: bottom-left 8x8 of the 2x2-cell block
local def = game.data.maps.ROUTE_4
local ts = game.data.tilesets[def.tileset]
local function cellTile(cx, cy)
local tx, ty = cx * 2, cy * 2 + 1
local bx, by = math.floor(tx / 4), math.floor(ty / 4)
local id
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then id = def.borderBlock
else id = def.blocks[by * def.width + bx + 1] end
local block = ts.blocks[(id or 0) + 1]
return block and block[(ty % 4) * 4 + (tx % 4) + 1] or nil
end
local W, H = def.width * 2, def.height * 2
local function holdDown(x, y, frames)
U.teleport(game, "ROUTE_4", x, y, "down")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(6)
local p = game.overworld.player
local hop = false
for _ = 1, frames do
table.insert(game.input.pressQueue, "down")
game.input.state.down = true
coroutine.yield()
if (p.hopFrames or 0) > 0 then hop = true end
end
game.input.state.down = false
U.wait(4)
return p.cellX, p.cellY, hop
end
-- (12,16)/(13,16): south ledges on the plaza's map-border edge; the cell two
-- south is off-map (border mountain), so the refusal is correct, not a bug.
local expectedRefusal = { ["12,16"] = true, ["13,16"] = true }
local standers = {}
for cy = 0, H - 2 do
for cx = 0, W - 1 do
local s = cellTile(cx, cy)
local f = cellTile(cx, cy + 1)
if (s == 44 or s == 57) and (f == 54 or f == 55) then
standers[#standers + 1] = { cx, cy }
end
end
end
U.log(("#223 sweep: %d south-ledge standing cells in ROUTE_4"):format(#standers))
local fails, refusals = 0, 0
for _, c in ipairs(standers) do
local cx, cy = c[1], c[2]
-- 2-cell hop is 32 frames (16/cell); budget past it so cellY settles
local ex, ey, hop = holdDown(cx, cy, 44)
local ok = hop and ex == cx and ey == cy + 2
if not ok then
if expectedRefusal[cx .. "," .. cy] then
refusals = refusals + 1
U.log((" refused (expected, map-border edge) (%d,%d) -> (%d,%d) hop=%s")
:format(cx, cy, ex, ey, tostring(hop)))
else
fails = fails + 1
U.log((" FAIL (%d,%d) -> (%d,%d) hop=%s"):format(cx, cy, ex, ey, tostring(hop)))
end
end
end
U.log(("#223 sweep DONE: %d hopped, %d expected border-refusals, %d unexpected FAILS")
:format(#standers - fails - refusals, refusals, fails))
if fails > 0 then error(fails .. " unexpected south-ledge DOWN-hop failure(s)") end
end
+168
View File
@@ -0,0 +1,168 @@
-- Driver: Route 4 ledges (issue #223).
--
-- #223 reports "can't jump down the lower-right cliff outside Mt. Moon". The
-- reporter (build v0.1.25, RED++/OG-RED brown palette) is standing on the open
-- EAST plateau of ROUTE_4 (the tile-57 ground with the "01/10" texture, cells
-- ~72-80, rows 3-6) near its SE corner. The owner clarified the complaint is
-- about pressing DOWN (a south-facing ledge), not the vertical side ledges the
-- first triage looked at.
--
-- Finding after an exhaustive DOWN-hop sweep of every ROUTE_4 south-ledge cell
-- (see tests/drivers/route4_downsweep_bug223.lua): every functional south
-- ledge hops. The "cliff" the reporter pressed DOWN on at the SE corner is a
-- solid mountain-wall FACE -- OVERWORLD tile 58, a 5-cell-tall vertical cliff
-- (ROUTE_4 cell 80, rows 5-9) -- which is NOT a ledge tile. Gen1 only hops the
-- three straight ledge families in data/tilesets/ledge_tiles.asm (54/55 face
-- DOWN, 39 faces LEFT, 13/29 face RIGHT); a tall cliff face and the diagonal
-- corner tiles are not hoppable in any direction (engine/overworld/ledges.asm
-- HandleLedges keys the hop on wPlayerFacingDirection + wTilePlayerStandingOn +
-- wTileInFrontOfPlayer + hJoyHeld, all four of which must match a LedgeTiles
-- row). The ledge code and data are byte-identical between v0.1.25 and HEAD,
-- so this is the same behavior the reporter saw: correct, matching Gen1.
--
-- Cases (all assert the CORRECT Gen1 behavior, so this passes on a good build
-- and would fail on any future ledge regression):
-- A a plain south ledge hops with DOWN (system works)
-- B the cx45 right-facing side ledge hops with RIGHT (correct input)
-- C the cx50 left-facing side ledge hops with LEFT
-- D DOWN along the plaza terraces descends by hopping south ledges
-- E DOWN into a solid cliff face (tile 58) correctly bonks
-- F the reporter's EAST plateau south ledges hop with DOWN (rows 4 and 6)
-- G the reporter's SE-corner cliff FACE (tile 58, cell 80) does NOT hop --
-- this is the "cliff outside Mt Moon" the report was about, working as
-- Gen1 does (a mountain wall is not a ledge)
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/route4_ledge_bug223_test.lua \
-- POKEPORT_IDENTITY=bug223 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
-- a party + starter flag so the overworld is fully usable
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
local Pokemon = require("src.pokemon.Pokemon")
if #game.save.party == 0 then
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
end
-- survey zoom-out + the reporter's RED++ palette so the shots frame the whole
-- cliff like the report
game.save.options = game.save.options or {}
game.save.options.zoom = -2
game.save.options.colors = "redpp"
require("src.render.PaletteFX").setMode("redpp")
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
-- hold a direction for n frames, returning end cell + whether a hop arc was
-- ever active (p.hopFrames>0 is set only by checkLedgeHop's jump arc)
local function holdDir(x, y, facing, dir, frames)
U.teleport(game, "ROUTE_4", x, y, facing)
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(6)
local p = game.overworld.player
local hopSeen = false
for _ = 1, frames do
table.insert(game.input.pressQueue, dir)
game.input.state[dir] = true
coroutine.yield()
if (p.hopFrames or 0) > 0 then hopSeen = true end
end
game.input.state[dir] = false
U.wait(4)
return p.cellX, p.cellY, hopSeen
end
-- A) control: a south-facing ledge must hop with DOWN. Standing at (40,8),
-- the cell in front (40,9) is a tile-55 south ledge; hop lands on (40,10).
do
U.teleport(game, "ROUTE_4", 40, 8, "down")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(6); shot("route4_south_ledge_before.png")
local x, y, hop = holdDir(40, 8, "down", "down", 60)
shot("route4_south_ledge_after.png")
expect(hop, "A: DOWN hops the south ledge (hop arc seen)")
expect(x == 40 and y >= 10, "A: landed south of the ledge, got:", x, y)
end
-- B) a RIGHT-facing side ledge (tile 29) at cx45. DOWN never crosses it;
-- RIGHT does. From (44,6) a RIGHT hop clears cx45 and lands on (46,6).
do
U.teleport(game, "ROUTE_4", 44, 6, "right")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(6); shot("route4_side_ledge_right_before.png")
local x, y, hop = holdDir(44, 6, "right", "right", 40)
shot("route4_side_ledge_right_after.png")
expect(hop, "B: RIGHT hops the cx45 side ledge (hop arc seen)")
expect(x == 46 and y == 6, "B: landed east of the side ledge, got:", x, y)
end
-- C) the mirror side ledge: cx50 (tile 39) faces LEFT; from (51,5) a LEFT hop
-- clears cx50 and lands on (49,5).
do
local x, y, hop = holdDir(51, 5, "left", "left", 40)
expect(hop, "C: LEFT hops the cx50 side ledge (hop arc seen)")
expect(x == 49 and y == 5, "C: landed west of the side ledge, got:", x, y)
end
-- D) DOWN down the plaza terraces DOES descend by hopping south ledges: from
-- the plaza top (44,5), holding DOWN walks to a south edge and hops the
-- terraces; held long enough the player ends well south of the start.
do
U.teleport(game, "ROUTE_4", 44, 5, "down")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(6); shot("route4_terrace_down_before.png")
local x, y, hop = holdDir(44, 5, "down", "down", 150)
shot("route4_terrace_down_after.png")
expect(hop, "D: DOWN down the plaza terraces hops a south ledge")
expect(y >= 12, "D: descended the terraces, got:", x, y)
end
-- E) a solid cliff face is NOT a ledge: from (80,4) the cell below (80,5) is a
-- tile-58 cliff wall. DOWN bonks in place with no hop -- matching Gen1, DOWN
-- only hops south-facing ledge tiles (54/55), not cliff faces.
do
local x, y, hop = holdDir(80, 4, "down", "down", 30)
expect(not hop, "E: DOWN into a solid cliff face does not hop")
expect(x == 80 and y == 4, "E: bonked in place at the cliff face, got:", x, y)
end
-- F) the reporter's EAST plateau (the "cliff outside Mt Moon"): the tile-57
-- ground at (70,4) and (75,4) sits above the row-5 south ledge (tiles 54/55).
-- DOWN hops each two cells south -- this is exactly the "jump down to the
-- bottom half of the cliff" the report expected, and it works.
do
U.teleport(game, "ROUTE_4", 70, 4, "down")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(8); shot("route4_east_plateau_before.png")
local x1, y1, h1 = holdDir(70, 4, "down", "down", 44)
shot("route4_east_plateau_after.png")
expect(h1 and x1 == 70 and y1 == 6, "F: (70,4) DOWN hops to (70,6), got:", x1, y1)
local x2, y2, h2 = holdDir(75, 4, "down", "down", 44)
expect(h2 and x2 == 75 and y2 == 6, "F: (75,4) DOWN hops to (75,6), got:", x2, y2)
end
-- G) the reporter's SE-corner "cliff": (80,4) is tile-57 ground whose south
-- neighbour is the tile-58 mountain-wall face (cell 80, rows 5-9). DOWN must
-- NOT hop -- a tall cliff face is not a ledge, matching Gen1. This is the
-- cliff the report was about; refusing the hop here is correct.
do
U.teleport(game, "ROUTE_4", 78, 3, "down")
require("src.render.Zoom").applyOptions(game.save.options)
U.wait(8); shot("route4_se_corner_cliff.png")
local x, y, hop = holdDir(80, 4, "down", "down", 30)
expect(not hop and x == 80 and y == 4,
"G: SE-corner cliff face does not hop (Gen1-correct), got:", x, y, hop)
end
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed -- #223: every functional ROUTE_4 south ledge hops "
.. "with DOWN; the reporter's SE 'cliff' is a solid mountain-wall face "
.. "(tile 58), correctly not hoppable, matching Gen1")
end
+206
View File
@@ -0,0 +1,206 @@
-- Driver: #221 Saffron gate guards use the WRONG dialogue.
--
-- The reporter (build 0.1.25) saw the guard ACCEPT a drink he never had --
-- "...Huh? I can have this drink? Gee, thanks!" (_SaffronGateGuardImParchedText,
-- data/generated/text.lua:2050) -- while walking up to the gate carrying NO
-- drink. Gen1 (scripts/Route5Gate.asm Route5GateDefaultScript) instead turns
-- a drink-less player back with the thirsty line
-- (_SaffronGateGuardGeeImThirstyText, text.lua:2049: "I'm on guard duty. / Gee,
-- I'm thirsty, though! ... the road's closed."), and only ACCEPTS a drink (via
-- `farcall RemoveGuardDrink`, engine/items/inventory.asm) when one is in the bag.
--
-- The guard object sits at cell (1,3), walled into an isolated 1x3 booth
-- (pokered Route5Gate object_event 1,3 SPRITE_GUARD STAY RIGHT), so he is
-- unreachable on foot: the block/dialogue is driven entirely by the onStep
-- coordinate trigger on the corridor cells (3,3)/(4,3), not the TALK handler.
-- This driver walks the player north through that trigger and asserts:
--
-- CASE A (no drink): the box is the THIRSTY line, NOT the accept line, and
-- the player is shoved back one tile and stays inside ROUTE_5_GATE.
-- CASE B (FRESH_WATER in bag): the box is the ACCEPT line, the drink is
-- consumed, EVENT_GAVE_GUARDS_DRINK is set, and the player walks on
-- through (map leaves ROUTE_5_GATE).
--
-- On the 0.1.25 bug CASE A would type the accept line and fail; current HEAD
-- (fixed by #201) passes. Run with:
--
-- SHOT_DIR=/tmp/saffron221 POKEPORT_IDENTITY=bug221 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/saffron_gate_bug221_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 10) }
game.save.flags = game.save.flags or {}
local function topText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local function isBox()
return getmetatable(game.stack:top()) == TextBox
end
-- let the typewriter finish typing up to its first pause (a <CONT> scroll
-- or page break sets self.waiting), so a screenshot shows the guard's
-- words instead of an empty just-opened box.
local function waitTyped(maxFrames)
for _ = 1, (maxFrames or 240) do
local top = game.stack:top()
if getmetatable(top) == TextBox and (top.waiting or top.done) then return end
U.wait(1)
end
end
-- walk the player north (holding "up") until a dialogue box pops or we
-- give up; returns true if a box appeared. Only presses while the
-- overworld is on top and the player is not already mid-step, so a step
-- that lands on the trigger cleanly hands control to the pushed TextBox.
local function walkNorthUntilBox(ow, maxFrames)
for _ = 1, maxFrames do
if isBox() then return true end
if game.stack:top() == ow and not ow.player.moving
and not ow.runner:isRunning() and #ow.scriptMoves == 0
and not ow.transitioning then
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
end
U.wait(1)
game.input.state.up = false
end
return isBox()
end
local function settle(ow, maxFrames)
for _ = 1, (maxFrames or 200) do
if game.stack:top() == ow and not ow.player.moving
and not ow.runner:isRunning() and #ow.scriptMoves == 0
and not ow.transitioning then
return
end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
end
-- ---------------------------------------------------------------
-- CASE A: no drink -> thirsty line + shove back, gate stays shut
-- ---------------------------------------------------------------
game.save.inventory = {}
game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil
U.teleport(game, "ROUTE_5_GATE", 3, 5, "up")
local ow = game.overworld
assert(ow and ow.map.id == "ROUTE_5_GATE",
"CASE A: teleport did not land inside ROUTE_5_GATE (got "
.. tostring(ow and ow.map.id) .. ")")
assert(ow.player.cellX == 3 and ow.player.cellY == 5,
"CASE A: player not at spawn (3,5); got "
.. ow.player.cellX .. "," .. ow.player.cellY)
U.log("CASE A start:", ow.map.id, ow.player.cellX, ow.player.cellY)
local gotBoxA = walkNorthUntilBox(ow, 400)
waitTyped(240)
U.shot(game, DIR .. "/saffron_bug221_nodrink.png")
local textA = topText()
U.log("CASE A box:", gotBoxA, "text:", textA)
assert(gotBoxA, "CASE A: no dialogue box appeared at the gate trigger")
-- CORRECT Gen1: the drink-less thirsty line, NOT the accept/parched line.
assert(textA:find("thirsty", 1, true),
"CASE A: box is not the thirsty line (got: " .. textA .. ")")
assert(not textA:find("this drink", 1, true)
and not textA:find("Gee, thanks", 1, true),
"CASE A(#221): drink-less guard spoke the ACCEPT line (got: " .. textA .. ")")
-- mash A through the (multi-page) thirsty box; the onDone shoves the
-- player back one tile. He must NOT pass -- still inside ROUTE_5_GATE.
settle(ow, 300)
U.log("CASE A after:", ow.map.id, ow.player.cellX, ow.player.cellY)
assert(ow.map.id == "ROUTE_5_GATE",
"CASE A: drink-less player passed the gate (map=" .. ow.map.id .. ")")
assert(ow.player.cellY >= 4,
"CASE A: player was not turned back (cellY=" .. ow.player.cellY .. ")")
assert(not game.save.flags.EVENT_GAVE_GUARDS_DRINK,
"CASE A: gave-drink flag set without a drink")
-- ---------------------------------------------------------------
-- CASE B: FRESH_WATER in bag -> accept line, drink taken, pass through
-- ---------------------------------------------------------------
game.save.inventory = { FRESH_WATER = 1 }
game.save.flags.EVENT_GAVE_GUARDS_DRINK = nil
U.teleport(game, "ROUTE_5_GATE", 3, 5, "up")
ow = game.overworld
assert(ow.map.id == "ROUTE_5_GATE", "CASE B: re-teleport failed")
U.log("CASE B start:", ow.map.id, ow.player.cellX, ow.player.cellY)
local gotBoxB = walkNorthUntilBox(ow, 400)
waitTyped(240)
U.shot(game, DIR .. "/saffron_bug221_drink.png")
local textB = topText()
U.log("CASE B box:", gotBoxB, "text:", textB)
assert(gotBoxB, "CASE B: no dialogue box appeared at the gate trigger")
-- CORRECT Gen1: carrying a drink triggers the parched/accept line.
assert(textB:find("this drink", 1, true) or textB:find("Gee, thanks", 1, true),
"CASE B: guard did not speak the accept line (got: " .. textB .. ")")
-- clear the accept + "you can go on through" boxes, then keep walking
-- north out the top of the gate.
for _ = 1, 400 do
if game.stack:top() ~= ow then
U.tap(game, "a")
else
break
end
U.wait(2)
end
U.log("CASE B post-accept:", "FRESH_WATER=",
tostring(game.save.inventory.FRESH_WATER),
"flag=", tostring(game.save.flags.EVENT_GAVE_GUARDS_DRINK))
assert(game.save.inventory.FRESH_WATER == nil,
"CASE B: the drink was not consumed")
assert(game.save.flags.EVENT_GAVE_GUARDS_DRINK == true,
"CASE B: EVENT_GAVE_GUARDS_DRINK not set after accepting the drink")
-- with the flag now set the corridor is free; walk on out the north side
-- and STOP the instant the gate map is left (don't wander into the town's
-- scripts -- the north warp resolves to the heal point when we teleported
-- in with no remembered outdoor side, OverworldController:takeWarp).
for _ = 1, 300 do
ow = game.overworld
if ow.map.id ~= "ROUTE_5_GATE" then break end
if game.stack:top() == ow and not ow.player.moving
and not ow.runner:isRunning() and #ow.scriptMoves == 0
and not ow.transitioning then
table.insert(game.input.pressQueue, "up")
game.input.state.up = true
end
U.wait(1)
game.input.state.up = false
end
ow = game.overworld
U.shot(game, DIR .. "/saffron_bug221_passed.png")
U.log("CASE B end:", ow.map.id, ow.player.cellX, ow.player.cellY)
assert(ow.map.id ~= "ROUTE_5_GATE",
"CASE B: player with a drink never passed the gate")
U.log("saffron_gate_bug221_test: ok")
love.event.quit()
end
@@ -0,0 +1,124 @@
-- Driver: Seafoam Islands B3F strong-current plug rocks (issue #212).
--
-- The two boulders the player pushes down through the B2F holes land on B3F
-- at cells (18,6) and (19,6) and plug the strong current (the reporter's
-- expected.png shows two round rocks sitting in the channel). The B2F
-- pluggedByHolesOn holes were wired to showObject TOGGLE_..._B3F_BOULDER_3/_4,
-- which OverworldState:toggleToObjectName resolves to SEAFOAMISLANDSB3F_
-- BOULDER3/4 -- the ALREADY-VISIBLE pushable boulders at (8,14)/(9,14), not the
-- hidden landing rocks. The real landing objects at (18,6)/(19,6) are the
-- hidden BOULDER5/6, so the plug rocks never appeared (data/generated/field.lua
-- + tools/rom_manifest*.json now point showObject at BOULDER_5/_6).
--
-- Case A asserts the CORRECT Gen1 outcome (rocks appear after plugging), so it
-- FAILS on the bug and PASSES once the toggles are repointed.
--
-- Case B is a regression guard: with only ONE rock the current stays active
-- and gates the sole water chokepoint (the 2-wide gap at (18,7)/(19,7) is the
-- only water passage between the south and north pools -- verified from the
-- B3F water map), so a surfing player stepping up into it is swept south
-- (SeafoamIslandsB3F.asm) and cannot cross north. This confirms the reported
-- "swim in the strong current" is not reproducible around the trigger cells.
--
-- Run:
-- POKEPORT_DRIVER=tests/drivers/seafoam_current_bug212_test.lua \
-- POKEPORT_IDENTITY=bug212 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local shotDir = os.getenv("POKEPORT_SHOTDIR") or "."
local function shot(name) U.shot(game, shotDir .. "/" .. name) end
local Pokemon = require("src.pokemon.Pokemon")
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_GOT_STARTER = true
game.save.party = { Pokemon.new(game.data, "LAPRAS", 40) }
-- SURF (+ STRENGTH for the real puzzle) so the surfing/current paths run
game.save.party[1].moves = { { id = "SURF" }, { id = "STRENGTH" } }
local fails = 0
local function expect(cond, ...)
if not cond then fails = fails + 1 end
U.log(cond and "PASS" or "FAIL", ...)
end
-- a visible SPRITE_BOULDER standing on cell (x,y)? self.npcs only holds
-- objects objectVisible() accepted, so a hidden-but-untoggled rock is absent
local function boulderAt(x, y)
for _, n in ipairs(game.overworld.npcs) do
local def = n.def
if def and def.sprite == "SPRITE_BOULDER"
and n.cellX == x and n.cellY == y then
return true
end
end
return false
end
-- ------------------------------------------------------------------
-- Case A: plugging both B2F holes reveals the B3F landing rocks.
-- ------------------------------------------------------------------
do
game.save.objectToggles = {}
game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = nil
game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil
-- baseline: the empty channel, no plug rocks yet
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
game.overworld.player.surfing = true
U.wait(8)
shot("b3f_channel_empty.png")
expect(not boulderAt(18, 6) and not boulderAt(19, 6),
"A0: before plugging, no rock in the channel at (18,6)/(19,6)")
-- push both plug boulders through their B2F holes via the real engine
-- path (OverworldState:boulderIntoHole sets the event flag AND the
-- destMap showObject toggle by name)
U.teleport(game, "SEAFOAM_ISLANDS_B2F", 19, 7, "up")
U.wait(6)
game.overworld:boulderIntoHole({ cellX = 19, cellY = 6 }) -- lands B3F (18,6)
game.overworld:boulderIntoHole({ cellX = 22, cellY = 6 }) -- lands B3F (19,6)
U.wait(4)
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
game.overworld.player.surfing = true
U.wait(8)
shot("b3f_channel_plugged.png")
expect(boulderAt(18, 6), "A1: plug rock visible at (18,6) after plugging both holes")
expect(boulderAt(19, 6), "A2: plug rock visible at (19,6) after plugging both holes")
end
-- ------------------------------------------------------------------
-- Case B: one rock -> current still gates the channel, no swim-through.
-- ------------------------------------------------------------------
do
game.save.objectToggles = {}
game.save.flags.EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = true
game.save.flags.EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = nil
U.teleport(game, "SEAFOAM_ISLANDS_B3F", 18, 8, "up")
local p = game.overworld.player
p.surfing = true
U.wait(6)
local startY = p.cellY
local minY = p.cellY
-- try to paddle north through the current toward the (18,4..6) pool
for _ = 1, 90 do
table.insert(game.input.pressQueue, "up")
game.input.state["up"] = true
coroutine.yield()
if p.cellY < minY then minY = p.cellY end
end
game.input.state["up"] = false
U.wait(20)
if p.cellY < minY then minY = p.cellY end
-- the north pool begins at y<=6; reaching it means the current failed to
-- gate the passage. Being swept keeps minY at 7 (the current cell) or south.
expect(minY >= 7,
"B: current gates the channel; player never crossed north (min cellY):", minY)
U.log("B: start cellY", startY, "min cellY", minY, "end cellY", p.cellY,
"surfing", tostring(p.surfing))
end
if fails > 0 then error(fails .. " check(s) failed") end
U.log("all checks passed -- #212 seafoam B3F plug rocks appear "
.. "and the strong current gates the channel")
end
@@ -0,0 +1,78 @@
-- Driver (#214): status screen page 1 must print the type's DISPLAY name.
-- pokered engine/pokemon/status_screen.asm PrintMonType prints the type's
-- entry from the TypeNames table (data/types/names.asm), which for the
-- PSYCHIC_TYPE constant is "PSYCHIC". The engine stores each species'
-- types as pokered CONSTANT names (RomExtractor:typesById), and PSYCHIC's
-- constant is "PSYCHIC_TYPE" so it does not collide with the PSYCHIC move.
-- Drawing the raw constant overflowed the TYPE field: "PSYCHIC" fills
-- x=88..144, then "_TYPE" runs into the right DrawLineBox bracket (the
-- stray "+" tick the reporter circled). SummaryMenu must route the label
-- through TypeChart.displayName like HallOfFame / BattleState already do.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local SummaryMenu = require("src.ui.SummaryMenu")
local TypeChart = require("src.battle.TypeChart")
local Font = require("src.render.Font")
game.save.player.name = "YOSHIRB"
-- Capture what SummaryMenu actually draws at the TYPE1 value slot
-- (x=88, y=80 in SummaryMenu:draw). Monkeypatch the Font table field so
-- the local Font reference inside SummaryMenu resolves to our wrapper at
-- call time; this reads the real rendered string, not TypeChart in
-- isolation, so it fails while the raw constant is drawn.
local realDraw = Font.draw
local captured
local function recordShot(game, mon, path)
local summary = SummaryMenu.new(game, mon)
game.stack:push(summary)
U.wait(4)
captured = nil
Font.draw = function(text, x, y)
if x == 88 and y == 80 then captured = text end
return realDraw(text, x, y)
end
U.shot(game, path)
Font.draw = realDraw
game.stack:pop()
U.wait(2)
return captured
end
-- Psychic mon: the bug case. MEW is pure PSYCHIC.
local mew = Pokemon.new(game.data, "MEW", 10)
local mewDrawn = recordShot(game, mew, DIR .. "/summary_type_214_mew_p1.png")
U.log("MEW raw types[1]=", tostring(game.data.pokemon.MEW.types[1]),
"drawn TYPE1=", tostring(mewDrawn))
-- Control: RATTATA is pure NORMAL, whose constant == display name, so it
-- was never affected; it should still read "NORMAL".
local ratt = Pokemon.new(game.data, "RATTATA", 5)
local rattDrawn = recordShot(game, ratt, DIR .. "/summary_type_214_rattata_p1.png")
U.log("RATTATA raw types[1]=", tostring(game.data.pokemon.RATTATA.types[1]),
"drawn TYPE1=", tostring(rattDrawn))
U.log("shots under", DIR)
-- Raw data must still carry the pokered constant: it is the shared key for
-- TypeChart matchups and move.type, so the fix lives at the display layer.
assert(game.data.pokemon.MEW.types[1] == "PSYCHIC_TYPE",
"expected MEW raw type constant PSYCHIC_TYPE, got "
.. tostring(game.data.pokemon.MEW.types[1]))
assert(TypeChart.displayName("PSYCHIC_TYPE") == "PSYCHIC",
"TypeChart.displayName(PSYCHIC_TYPE) must be PSYCHIC")
-- The bug: SummaryMenu drew the raw constant "PSYCHIC_TYPE" (overflowing).
-- The fix: it must draw the display name "PSYCHIC" (7 chars, fits 88..144).
assert(mewDrawn == "PSYCHIC",
"#214: status screen TYPE1 for MEW must render PSYCHIC, drew "
.. tostring(mewDrawn))
assert(not tostring(mewDrawn):find("_"),
"#214: TYPE1 label must not contain '_' (no glyph, overflows bracket)")
assert(rattDrawn == "NORMAL",
"control: RATTATA TYPE1 must render NORMAL, drew " .. tostring(rattDrawn))
U.log("#214 PASS: status-screen TYPE1 renders display names")
end
+86
View File
@@ -0,0 +1,86 @@
-- Driver: teaching a TM must open the party menu in Gen 1's TM/HM mode,
-- showing ABLE / NOT ABLE per mon from its learnset (no HP bars) with the
-- "Use TM on which POKeMON?" prompt (engine/items/item_effects.asm
-- ItemUseTMHM -> engine/menus/party_menu.asm PrintPartyMenu TM/HM type). #210
--
-- Party CHARIZARD 50 / MAGIKARP 15 / SNORLAX 40 with TM_TOXIC: CHARIZARD
-- and SNORLAX learn TOXIC (ABLE); MAGIKARP has an empty tmhm (NOT ABLE).
--
-- SHOT_DIR=/tmp/bug210 POKEPORT_DRIVER=tests/drivers/tmhm_able_bug210_test.lua \
-- POKEPORT_IDENTITY=bug210 POKEPORT_TOUCH=0 love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Bag = require("src.inventory.Bag")
local TextBox = require("src.render.TextBox")
local PartyMenu = require("src.ui.PartyMenu")
-- fresh party with mixed learnability
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 50),
Pokemon.new(game.data, "MAGIKARP", 15),
Pokemon.new(game.data, "SNORLAX", 40),
}
Bag.add(game.save, "TM_TOXIC", 1)
U.teleport(game, "PALLET_TOWN", 5, 5, "down")
-- expected ABLE/NOT ABLE from the same learnset the teach reads
-- (ItemEffects.use scans data.pokemon[species].tmhm for machine.move)
local move = game.data.items.TM_TOXIC.machine.move
for _, mon in ipairs(game.save.party) do
local can = false
for _, m in ipairs(game.data.pokemon[mon.species].tmhm or {}) do
if m == move then can = true break end
end
U.log("expect", mon.species, can and "ABLE" or "NOT ABLE")
end
-- open the bag; BagMenu.new returns the ITEMS ListMenu
local bag = require("src.ui.Screens").push(game, "BagMenu")
U.wait(3)
for i, it in ipairs(bag.items) do
if it.value == "TM_TOXIC" then bag.index = i break end
end
U.log("bag index on TM_TOXIC:", bag.index, bag.items[bag.index].value)
-- A -> USE / TOSS menu, then A on USE (index 1)
U.tap(game, "a"); U.wait(6)
U.tap(game, "a"); U.wait(6)
local function pageText(box)
if not box or getmetatable(box) ~= TextBox then return "" end
return table.concat(box.pages[box.pageIndex] or {}, "\n")
end
-- mash through "Booted up a TM!" / "It contained TOXIC!" until the
-- TM/HM party menu is on top; stop before tapping A on it (that would
-- pick a mon)
local function reachPartyMenu(max)
for _ = 1, max or 120 do
if getmetatable(game.stack:top()) == PartyMenu then return true end
U.tap(game, "a")
U.wait(4)
end
return false
end
local reached = reachPartyMenu()
U.log("reached PartyMenu:", tostring(reached))
U.wait(3)
U.shot(game, DIR .. "/tmhm_bug210_partymenu.png")
local top = game.stack:top()
U.log("top is PartyMenu:", getmetatable(top) == PartyMenu)
U.log("top.tmhm:", top.tmhm and ("move=" .. tostring(top.tmhm.move)
.. " kind=" .. tostring(top.tmhm.kind)) or "nil")
-- assertions: fail while the bug exists (no TM mode), pass once fixed
assert(getmetatable(top) == PartyMenu, "did not reach the party menu")
assert(top.tmhm, "party menu is not in TM/HM mode (opts.tmhm missing)")
assert(top.tmhm.move == move,
"TM/HM move mismatch: " .. tostring(top.tmhm.move))
U.log("DONE")
love.event.quit()
end
+218
View File
@@ -0,0 +1,218 @@
-- Driver: #200 Pokemon Tower 7F Rocket grunts must walk off + despawn.
-- The three grunts (POKEMONTOWER7F_ROCKET1/2/3, obj indices 1/2/3, at
-- (9,11)/(12,9)/(9,7)) are sight/talk trainers. In Gen1
-- (scripts/PokemonTower7F.asm: PokemonTower7FEndBattleScript ->
-- PokemonTower7FRocketLeaveMovementScript -> PokemonTower7FHideNPCScript)
-- each grunt, after losing, shows its EndBattle text ("I give up!"), then its
-- AfterBattle text ("I'm not going to forget this!"), then walks off toward
-- the (9,16) stairs (MoveSprite) and HideObject despawns it. The bug left
-- every beaten grunt standing in place forever -- att1 in the report shows
-- all three still lined up in the corridor after being defeated.
--
-- This driver beats each grunt in turn and, per grunt, samples ow.npcs every
-- frame: the grunt must MOVE off its start tile before it leaves ow.npcs, and
-- must end despawned (gone from ow.npcs, objectToggle == false, beat flag set).
-- Before the fix each grunt stays at its start tile and never despawns, so the
-- movement + despawn assertions fail. After the fix they pass.
--
-- SHOT_DIR=/tmp/t7f POKEPORT_IDENTITY=bug200 POKEPORT_TOUCH=0 \
-- POKEPORT_SPEED=4 \
-- POKEPORT_DRIVER=tests/drivers/tower7f_bug200_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
os.execute("mkdir -p " .. DIR)
local Pokemon = require("src.pokemon.Pokemon")
local TextBox = require("src.render.TextBox")
-- clean slate: no grunt may read as already defeated / hidden
game.save.defeatedTrainers = {}
game.save.objectToggles = game.save.objectToggles or {}
game.save.objectToggles.POKEMON_TOWER_7F = nil
game.save.flags = game.save.flags or {}
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_0 = nil
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_1 = nil
game.save.flags.EVENT_BEAT_POKEMONTOWER_7_TRAINER_2 = nil
game.save.player = game.save.player or {}
game.save.player.name = game.save.player.name or "RED"
-- a tank that one-shots any of the L19-25 Rocket parties regardless of
-- type, so the mash win is quick and deterministic (same valve as
-- gamecorner_rocket_bug198_test)
local function freshParty()
local tank = Pokemon.new(game.data, "MEWTWO", 100)
tank.moves = {
{ id = "PSYCHIC_M", pp = 99 },
{ id = "THUNDERBOLT", pp = 99 },
{ id = "ICE_BEAM", pp = 99 },
{ id = "EARTHQUAKE", pp = 99 },
}
game.save.party = { tank }
end
-- Each grunt: object index/name/id, home tile, and the tile the player
-- stands on (facing up, directly below the grunt) to talk it into a battle.
-- The talk tiles are the ones the vanilla movement table keys on, so the
-- exit walk is the exact PokemonTower7F path (not the safety fallback).
local ROCKETS = {
{ index = 1, name = "POKEMONTOWER7F_ROCKET1",
id = "POKEMON_TOWER_7F_obj_1",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_0",
afterFrag = "forget this",
rx = 9, ry = 11, standX = 9, standY = 12 },
{ index = 2, name = "POKEMONTOWER7F_ROCKET2",
id = "POKEMON_TOWER_7F_obj_2",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_1",
afterFrag = "making",
rx = 12, ry = 9, standX = 12, standY = 10 },
{ index = 3, name = "POKEMONTOWER7F_ROCKET3",
id = "POKEMON_TOWER_7F_obj_3",
beat = "EVENT_BEAT_POKEMONTOWER_7_TRAINER_2",
afterFrag = "getting", rx = 9, ry = 7, standX = 9, standY = 8 },
}
local function pageText()
local top = game.stack:top()
if getmetatable(top) ~= TextBox then return "" end
local parts = {}
for _, page in ipairs(top.pages or {}) do
if type(page) == "table" then
for _, line in ipairs(page) do parts[#parts + 1] = tostring(line) end
end
end
return table.concat(parts, " ")
end
local shotN = 0
local function shot(tag)
shotN = shotN + 1
U.shot(game, DIR .. ("/tower7f_%02d_%s.png"):format(shotN, tag))
end
-- Beat one grunt via a talk engagement, then verify it walks off + despawns.
-- shots ~= nil requests the before/walk/after screenshot trio for the report.
local function beatGrunt(r, shots)
U.teleport(game, "POKEMON_TOWER_7F", r.standX, r.standY, "up")
local ow = game.overworld
local function findGrunt()
for _, n in ipairs(ow.npcs or {}) do
if n.def and n.def.name == r.name then return n end
end
return nil
end
local function idle()
return game.stack:top() == ow and not ow.runner:isRunning()
and #ow.scriptMoves == 0 and not ow.transitioning
end
local g = findGrunt()
assert(g, r.name .. " not present at start")
assert(g.cellX == r.rx and g.cellY == r.ry,
r.name .. " not at expected start tile")
U.log(r.name, "start:", g.cellX, g.cellY, g.facing)
if shots then shot("standing") end
-- Phase A: talk, then mash through pre-battle text + the battle, stopping
-- the instant the win is registered (the beat flag is set inside the
-- battle's onFinish, before the EndBattle box is pushed) so Phase B can
-- watch the exit walk that follows.
U.tap(game, "a")
local sawAfter = false
for f = 1, 4000 do
if game.save.flags[r.beat] then break end
if pageText():find(r.afterFrag, 1, true) then sawAfter = true end
local top = game.stack:top()
if top and top.phase then
if top.phase == "menu" then top.menuIndex = 1
elseif top.phase == "moveSelect" then top.moveIndex = 1 end
U.tap(game, "a")
if f > 2400 and top.onFinish then
U.log("force-finishing stalled battle")
top.onFinish("win")
if game.stack:top() == top then game.stack:pop() end
end
else
U.tap(game, "a") -- talk / advance pre-battle text
end
U.wait(2)
end
assert(game.save.flags[r.beat], r.name .. " never registered a win")
-- Phase B: sample every logic frame while advancing the EndBattle +
-- AfterBattle boxes. The grunt must move off its start tile (mid-walk it
-- carries targetX/targetY toward the stairs, then its cell updates) before
-- HideObject removes it from ow.npcs.
local moved, walkShot = false, false
for _ = 1, 2000 do
local gg = findGrunt()
if gg then
if gg.cellX ~= r.rx or gg.cellY ~= r.ry
or (gg.targetX and gg.targetX ~= r.rx)
or (gg.targetY and gg.targetY ~= r.ry) then
moved = true
if shots and not walkShot then
walkShot = true
shot("walkoff")
end
end
elseif idle() then
break
end
if pageText():find(r.afterFrag, 1, true) then sawAfter = true end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(1)
end
for _ = 1, 400 do
if idle() then break end
if game.stack:top() ~= ow then U.tap(game, "a") end
U.wait(2)
end
U.wait(5)
if shots then shot("gone") end
local toggles = game.save.objectToggles.POKEMON_TOWER_7F
U.log(r.name, "sawAfter:", sawAfter, "moved:", moved,
"gone:", findGrunt() == nil,
"toggle:", tostring(toggles and toggles[r.name]))
-- CORRECT Gen1 behavior: EndBattle/AfterBattle speech, then the grunt
-- walks off before despawning -- it must not vanish in place.
assert(sawAfter, r.name .. " never showed its after-battle text (#200)")
assert(moved,
r.name .. " never walked off its tile before despawning (#200)")
assert(findGrunt() == nil, r.name .. " still present after the exit walk")
assert(toggles and toggles[r.name] == false,
r.name .. " objectToggle not persisted hidden")
assert(game.save.defeatedTrainers[r.id], r.name .. " not recorded defeated")
for _, n in ipairs(ow.npcs or {}) do
assert(not (n.cellX == r.rx and n.cellY == r.ry),
"an NPC still occupies " .. r.name .. "'s old tile")
end
end
freshParty()
beatGrunt(ROCKETS[1], true) -- ROCKET1 gets the report screenshot trio
freshParty()
beatGrunt(ROCKETS[2], false)
freshParty()
beatGrunt(ROCKETS[3], false)
-- all three gone: confirm the corridor up to Mr. Fuji is clear of grunts
U.teleport(game, "POKEMON_TOWER_7F", 9, 13, "up")
local ow = game.overworld
for _, n in ipairs(ow.npcs or {}) do
assert(n.def.name == "POKEMONTOWER7F_MR_FUJI",
"a Rocket grunt is still on the floor after all three were beaten")
end
local fuji
for _, n in ipairs(ow.npcs or {}) do
if n.def.name == "POKEMONTOWER7F_MR_FUJI" then fuji = n end
end
assert(fuji, "MR_FUJI missing from the top floor")
shot("fuji_clear")
U.log("tower7f_bug200_test: ok")
end
@@ -0,0 +1,156 @@
-- Driver: regression coverage for #152 "'Selector' on the world map is off and
-- character location is not shown".
--
-- pret/pokered engine/menus/town_map.asm DisplayTownMap draws the Kanto map
-- with a blinking box cursor CENTERED on the currently selected location plus a
-- separate blinking "you are here" marker at the player's current map location.
-- Two independent defects lived in src/ui/TownMap.lua's grid+background path
-- (the primary path when the extracted Kanto art is present):
--
-- DEFECT A (selector off): the 16x16 hollow cursor frame was drawn with its
-- top-left AT the 8x8 cell's top-left (markerXY), so the frame's center
-- landed +4,+4 off the cell -- the town square sat in the frame's top-left
-- quadrant instead of being enclosed. Fix centers the frame (-4,-4).
--
-- DEFECT B (character location not shown): the player marker was painted with
-- setColor(0.75,0.1,0.1) red. The TOWN MAP composites through the SGB
-- shade-remap shader (PaletteFX.shader), which keys ONLY on the red channel;
-- red 0.75 falls in the c1 bucket = TOWNMAP {165,214,255}, the exact
-- light-blue used for water and the town-square fill, so the marker was
-- painted but recolored invisible. Fix paints it red=0 -> c3 (dark dot).
--
-- This driver opens the grid TOWN MAP outdoors at PALLET TOWN, moves the cursor
-- off the player's town to VIRIDIAN CITY (so the you-are-here marker and the
-- selection cursor must BOTH be visible), and asserts -- at native resolution,
-- after replicating the Renderer's TOWNMAP shade-remap pass -- that the player
-- marker is a visible dark dot (DEFECT B) and the cursor frame is centered on
-- the selected cell (DEFECT A). Fails on the current build, passes once fixed.
--
-- Run:
-- SHOT_DIR=/tmp/bug152 POKEPORT_IDENTITY=bug152 POKEPORT_TOUCH=0 \
-- POKEPORT_DRIVER=tests/drivers/townmap_selector_bug152_test.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local PaletteFX = require("src.render.PaletteFX")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
-- The defect lives in the SGB ('gbc') mode shade-remap; pin it deterministic.
game.save.options = game.save.options or {}
game.save.options.colors = "gbc"
PaletteFX.setMode("gbc")
game.save.player = game.save.player or {}
game.save.player.name = "bryan"
-- Outdoor map so the player's map resolves to a town-map location (byMap).
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
Screens.push(game, "TownMap")
U.wait(6)
local top = game.stack:top()
-- Functional preconditions: the grid path with extracted art, and the
-- character's location known (playerLoc non-nil) -- otherwise the two draw
-- defects would not even be on the code path we mean to test.
assert(top and top.mode == "grid",
"town map must be in grid mode (extracted art), got " .. tostring(top and top.mode))
assert(top.bg ~= nil and top.bg.cursor ~= nil,
"extracted Kanto background + cursor asset must be present for this test")
assert(top.playerLoc ~= nil and top.playerLoc.name == "PALLET TOWN",
"character location must resolve to PALLET TOWN, got "
.. tostring(top.playerLoc and top.playerLoc.name))
-- Human-viewable before/after frame: cursor still on the player's own town.
top.blink = 0
U.shot(game, DIR .. "/townmap_bug152_pallet.png")
U.wait(4)
-- Move the cursor OFF the player's town up to VIRIDIAN CITY, so the
-- you-are-here marker (PALLET) and the selection cursor (VIRIDIAN) occupy
-- different cells and BOTH must render (matches the reporter's should2).
local guard = 0
while top.locs[top.sel].name ~= "VIRIDIAN CITY" and guard < 8 do
U.tap(game, "up"); U.wait(3); guard = guard + 1
end
assert(top.locs[top.sel].name == "VIRIDIAN CITY",
"up must snap the cursor to VIRIDIAN CITY, landed on "
.. tostring(top.locs[top.sel].name))
assert(top.locs[top.sel] ~= top.playerLoc,
"cursor must be on a DIFFERENT cell than the you-are-here marker")
top.blink = 0
U.shot(game, DIR .. "/townmap_bug152_viridian.png")
U.wait(4)
-- ---- deterministic native-resolution pixel gate ----
-- TownMap:draw() emits raw DMG shades; the Renderer composites the whole
-- screen through PaletteFX.shader() with the TOWNMAP SGB palette (keyed on
-- the RED channel only). Replicate that here at native 160x144 so the pixel
-- checks are scale-independent -- the same offscreen-colorize technique as
-- battle_hpbar_gbc_bug229_test.lua.
if not (love and love.graphics and love.graphics.newCanvas and PaletteFX.shader()) then
error("#152 driver: no shader/canvas support available to verify colorized output")
end
top.blink = 0 -- marker shows while blink<20, cursor while blink%16<10
local raw = love.graphics.newCanvas(160, 144)
love.graphics.setCanvas(raw)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setColor(1, 1, 1, 1)
top:draw()
love.graphics.setCanvas()
local shader = PaletteFX.shader()
local colors = PaletteFX.pal(game.data, "TOWNMAP")
assert(colors, "TOWNMAP palette must resolve")
local shaded = love.graphics.newCanvas(160, 144)
love.graphics.setCanvas(shaded)
love.graphics.clear(0, 0, 0, 1)
love.graphics.setShader(shader)
PaletteFX.sendColors(shader, colors)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(raw)
love.graphics.setShader()
love.graphics.setCanvas()
local id = shaded:newImageData()
do -- save the colorized native frame for human viewing
local d = id:encode("png")
local f = io.open(DIR .. "/townmap_bug152_viridian_native.png", "wb")
if f then f:write(d:getString()); f:close() end
end
local function sumAt(x, y)
local r, g, b = id:getPixel(x, y)
return r + g + b, r, g, b
end
-- markerXY(loc) = (loc.x*8+16, loc.y*8+8) = the 8x8 cell top-left.
-- PALLET (x2,y11) -> (32,96); the you-are-here dot fills +2,+2 4x4, center ~ (35,99).
local ms, mr, mg, mb = sumAt(35, 99)
-- VIRIDIAN (x2,y8) -> (32,72). Post-fix the centered 16x16 frame's top border
-- sits ~4px ABOVE the cell top (row ~68) and its left border ~4px LEFT (col ~28).
local ts = select(1, sumAt(32, 68))
local ls = select(1, sumAt(28, 78))
U.log(string.format("marker(35,99) rgb=%.2f/%.2f/%.2f sum=%.2f", mr, mg, mb, ms))
U.log(string.format("cursor top(32,68) sum=%.2f left(28,78) sum=%.2f", ts, ls))
-- DEFECT B: the "you are here" marker must be a VISIBLE dark dot, not the
-- map-fill light-blue (sum 2.49) the red-channel shade-remap folded it into.
assert(ms < 0.6, string.format(
"#152 DEFECT B: player-location marker invisible -- PALLET cell reads sum=%.2f "
.. "(map-fill blue); expected a dark you-are-here dot (sum<0.6)", ms))
-- DEFECT A: the 16x16 cursor frame must be CENTERED on the selected cell, so a
-- dark border pixel exists ~4px above and ~4px left of the VIRIDIAN cell top-left.
assert(ts < 0.6, string.format(
"#152 DEFECT A: cursor frame not centered -- no dark top border above VIRIDIAN "
.. "cell (sum=%.2f, expected <0.6)", ts))
assert(ls < 0.6, string.format(
"#152 DEFECT A: cursor frame not centered -- no dark left border of VIRIDIAN "
.. "cell (sum=%.2f, expected <0.6)", ls))
U.log("RESULT bug152 PASS")
U.wait(2)
end
@@ -0,0 +1,150 @@
-- Driver (#222): a completed LAN link trade must autosave immediately, so a
-- player who quits before touching the START menu keeps the received mon and
-- cannot clone the sent one by resetting.
--
-- pokered engine/link/cable_club.asm: the Cable Club calls SaveSAVtoSRAM
-- (engine/menus/save.asm) right after every trade commits, so the swap is on
-- the cartridge the instant it happens. Our LinkState:updateTrade "done"
-- branch swaps game.save.party in memory (TradeSession:apply) but, before the
-- fix, never persisted it -- SaveData.load then returned the pre-trade party
-- from disk, losing the received mon and re-materializing the sent one (the
-- classic reset-to-clone vector).
--
-- This drives the real receiver side: lay a baseline disk save with a PIDGEY
-- party (the "player saved earlier" state), negotiate a TradeSession to
-- "done" against a peer's RATTATA (RATTATA has no trade evolution, so the run
-- is deterministic), hand it to a live LinkState so the completion branch
-- runs, then RELOAD FROM DISK and assert the on-disk party holds the received
-- RATTATA and no stale PIDGEY -- fails while the autosave is missing, passes
-- once updateTrade calls game:writeSave().
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local LinkState = require("src.link.LinkState")
local Net = require("src.link.Net")
local SaveData = require("src.core.SaveData")
local TextBox = require("src.render.TextBox")
local TradeAnim = require("src.ui.TradeAnim")
local function topIs(cls)
return getmetatable(game.stack:top()) == cls
end
-- (1) a real overworld so writeSave's captureSave has a world to stamp
U.teleport(game, "PALLET_TOWN", 5, 6, "down")
U.wait(5)
-- (2) baseline party + baseline disk save: this is the state the player
-- would fall back to if the trade never persisted (must NOT contain RATTATA)
game.save.party = { Pokemon.new(game.data, "PIDGEY", 10) }
local version = game.save.version
game:writeSave()
U.shot(game, DIR .. "/bug222_00_before.png")
local baseline = SaveData.load(version)
U.log("baseline on disk:", baseline and baseline.party and baseline.party[1]
and baseline.party[1].species, "count:",
baseline and baseline.party and #baseline.party)
assert(baseline and baseline.party and baseline.party[1]
and baseline.party[1].species == "PIDGEY",
"#222 setup: baseline disk save must hold the PIDGEY party")
-- (3) drive two TradeSessions to "done" exactly like run_link_tests.lua:
-- ours is built on the LIVE game.save.party so apply() mutates it in place.
local peerParty = { Pokemon.new(game.data, "RATTATA", 8) }
peerParty[1].ot = "CULLEN"; peerParty[1].otId = 16012 -- distinct sender
local ourTrade = Protocol.TradeSession.new(game.data, game.save.party)
local peerTrade = Protocol.TradeSession.new(game.data, peerParty)
ourTrade:handle({ type = "party", mons = Protocol.packParty(peerParty) })
peerTrade:handle({ type = "party", mons = Protocol.packParty(game.save.party) })
assert(ourTrade.stage == "picking", "trade should reach picking")
local pickOurs = ourTrade:pick(1) -- gives PIDGEY
local pickPeer = peerTrade:pick(1) -- gives RATTATA
ourTrade:handle(pickPeer)
peerTrade:handle(pickOurs)
assert(ourTrade.stage == "confirming", "both picks -> confirming")
local cOurs = ourTrade:confirm(true)
local cPeer = peerTrade:confirm(true)
ourTrade:handle(cPeer)
peerTrade:handle(cOurs)
assert(ourTrade.stage == "done", "both confirms -> done")
-- (4) attach the completed session to a live LinkState in the trade stage,
-- fresh Net (no peer) so poll() is empty and the "done" branch runs at once
local ls = LinkState.new(game)
ls.net = Net.new()
ls.peerName = "CULLEN"
ls.verdict = "full"
ls.confirmed = true
ls.stage = "trade"
ls.trade = ourTrade
game.stack:push(ls)
-- (5) let the stack update ls once: the "done" branch applies the swap into
-- game.save.party, autosaves (after the fix), pops ls, pushes TradeAnim
for _ = 1, 120 do
if game.stack:top() ~= ls then break end
U.wait(1)
end
assert(game.stack:top() ~= ls, "#222: LinkState must leave the trade 'done' branch")
U.log("in-memory party after trade:", game.save.party[1]
and game.save.party[1].species)
-- advance the TradeAnim until the "Trade completed!" TextBox appears for the
-- after shot; the disk write already happened in the done branch, this is
-- cosmetic. A fresh Font require keeps this independent of TradeAnim state.
local Font = require("src.render.Font")
local sawText = false
for _ = 1, 4000 do
local top = game.stack:top()
if top == game.overworld then break end
if getmetatable(top) == TradeAnim and not top.waitingText then
top:update(1 / 60)
end
if topIs(TextBox) then sawText = true; break end
U.tap(game, "a")
U.wait(1)
end
-- fully reveal the current page ("Trade completed!") so the shot shows text
-- regardless of the typewriter speed (mirrors trade_anim_test.lua)
local tb = game.stack:top()
if getmetatable(tb) == TextBox and tb.pages and tb.pageIndex then
local page = tb.pages[tb.pageIndex]
if page then
tb.shown = {}
for _, line in ipairs(page) do
tb.shown[#tb.shown + 1] = Font.encode(line)
end
tb.lineIndex = #page
tb.charIndex = #(tb.shown[#tb.shown] or {})
tb.done = true
end
end
U.wait(2)
U.shot(game, DIR .. "/bug222_01_trade_complete.png")
U.log("reached trade-complete text:", sawText)
-- (6) RELOAD FROM DISK and assert the trade was persisted without a manual
-- save. These fail before the fix (disk still holds the baseline PIDGEY).
local disk = SaveData.load(version)
U.log("on disk after trade:", disk and disk.party and disk.party[1]
and disk.party[1].species, "count:",
disk and disk.party and #disk.party)
assert(disk and disk.party, "#222: a save file must exist on disk")
assert(disk.party[1] and disk.party[1].species == "RATTATA",
"#222: trade must autosave -- on-disk lead must be the received RATTATA, was "
.. tostring(disk.party[1] and disk.party[1].species))
assert(#disk.party == 1,
"#222: on-disk party must be exactly the swapped party (no phantom slot), had "
.. tostring(#disk.party))
for i, mon in ipairs(disk.party) do
assert(mon.species ~= "PIDGEY",
"#222: the sent PIDGEY must not survive on disk (clone vector), found at slot "
.. tostring(i))
end
U.log("#222 PASS: link trade autosaved; disk holds RATTATA, PIDGEY gone")
end
+95
View File
@@ -0,0 +1,95 @@
-- Driver (#215): a link-traded mon must keep its ORIGINAL trainer's OT
-- name and ID on the receiving game, not adopt the receiver's identity.
--
-- pokered engine/link/cable_club.asm + home/serial.asm: a trade transmits
-- the whole party data block, which carries each mon's OT ID (party_struct
-- MON_OTID offset) and the OT-names block (wPartyMonOT). The receiving game
-- copies both verbatim and never overwrites -- a differing OT/ID is exactly
-- what marks a mon as traded (boosted EXP, high-level disobedience).
--
-- The bug: Protocol.packMon/unpackMon dropped mon.ot/mon.otId on the wire,
-- so a received mon arrived ot=nil/otId=nil and SummaryMenu (status_screen.asm
-- StatusScreen) fell back to the local player's name/ID -- the reporter saw
-- the sender's CULLEN/16012 mon show up on the receiver as RED/60368. This
-- drives the receiver side headlessly: pack a CULLEN-owned mon and unpack it,
-- then read exactly what SummaryMenu draws in the IDNo/OT slots.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
local Pokemon = require("src.pokemon.Pokemon")
local Protocol = require("src.link.Protocol")
local SummaryMenu = require("src.ui.SummaryMenu")
local BattleState = require("src.battle.BattleState")
local Font = require("src.render.Font")
-- This install is the RECEIVER: player RED, ID 60368 (from the report's
-- Blue window). A correctly preserved OT must NOT match these.
game.save.player.name = "RED"
game.save.player.id = 60368
-- Capture exactly the strings SummaryMenu draws in the IDNo value slot
-- (x=96, y=112) and the OT value slot (x=96, y=128) on page 1. Monkeypatch
-- the Font table field so SummaryMenu's own `local Font` reference resolves
-- to our wrapper at call time -- this reads the real rendered strings, not
-- Protocol in isolation, so it fails while the received mon carries no OT.
local realDraw = Font.draw
local function recordShot(mon, path)
local summary = SummaryMenu.new(game, mon)
game.stack:push(summary)
U.wait(4)
local capturedId, capturedOt = nil, nil
Font.draw = function(text, x, y)
if x == 96 and y == 112 then capturedId = text end
if x == 96 and y == 128 then capturedOt = text end
return realDraw(text, x, y)
end
U.shot(game, path)
Font.draw = realDraw
game.stack:pop()
U.wait(2)
return capturedId, capturedOt
end
-- The bug case: a RATTATA caught by trainer CULLEN (id 16012), sent across
-- the wire. pack -> unpack is exactly what the trade session does to the
-- mon before it lands in the receiver's party (Protocol.TradeSession).
local sender = Pokemon.new(game.data, "RATTATA", 8)
sender.ot = "CULLEN"
sender.otId = 16012
local received = Protocol.unpackMon(game.data, Protocol.packMon(sender))
local recvId, recvOt =
recordShot(received, DIR .. "/trade_ot_215_received_p1.png")
U.log("received mon: IDNo=", tostring(recvId), " OT=", tostring(recvOt))
-- Control: a mon caught locally on THIS game gets the player stamped as OT
-- (BattleState.stampOT, engine/battle/core.asm on catch), so its summary
-- correctly reads RED / 60368. This proves the player-fallback path itself
-- is fine and it is only the received mon that was wrong.
local mine = Pokemon.new(game.data, "PIDGEY", 8)
BattleState.stampOT(game.save, mine)
local mineId, mineOt =
recordShot(mine, DIR .. "/trade_ot_215_control_p1.png")
U.log("self-caught mon: IDNo=", tostring(mineId), " OT=", tostring(mineOt))
U.log("shots under", DIR)
-- The received mon must show the ORIGINAL trainer, not the receiver.
assert(recvOt == "CULLEN",
"#215: received mon OT must render CULLEN (the original trainer), drew "
.. tostring(recvOt))
assert(recvId == "16012",
"#215: received mon IDNo must render 16012 (the original trainer ID), drew "
.. tostring(recvId))
assert(recvOt ~= game.save.player.name,
"#215: received mon must not adopt the receiver's OT name")
assert(recvId ~= ("%05d"):format(game.save.player.id),
"#215: received mon must not adopt the receiver's ID")
-- Control must still show the local player (self-caught mon is unaffected).
assert(mineOt == "RED",
"control: self-caught mon OT must render RED, drew " .. tostring(mineOt))
assert(mineId == "60368",
"control: self-caught mon IDNo must render 60368, drew " .. tostring(mineId))
U.log("#215 PASS: link-traded mon keeps the original trainer's OT/ID")
end
+73
View File
@@ -235,6 +235,79 @@ do
end
end
-- ---------------------------------------------- #206: exported map + position
-- Regression guard for issue #206 ("exported .sav loads as a glitch map /
-- crashes in an emulator"). The Gen1 continue path rebuilds the entire
-- overworld from the saved current-map byte and the player's tile coords
-- (engine/menus/main_menu.asm SpecialEnterMap -> ResetPlayerSpriteData ->
-- EnterMap -> LoadMapHeader), so the two ways a real cartridge glitches on
-- Continue are (a) wCurMap not resolving to a real map header, and (b) the
-- player's wYCoord/wXCoord falling outside the map's walk grid. A cell is 2x2
-- background tiles (home/overworld.asm), so a WxH-block map is 2W x 2H cells
-- and valid coords are 0..2W-1 / 0..2H-1. Drive the launcher's real export
-- path (import -> SaveData.load -> exportActiveSlot) and assert the emitted
-- bytes land a loadable player across interior/outdoor/cave maps, including
-- each map's far corner, so this class of corruption cannot slip back in.
do
local mapsByIndex = {}
for id, def in pairs(data.maps) do
if def.index then mapsByIndex[def.index] = id end
end
-- One export cycle through the exact glue the SAVE FILES card uses. Returns
-- the raw exported image, or nil if any stage refused (checked by callers).
local function exportedThrough(mapId, x, y)
local files = fresh()
local seed = SaveData.newGame({ playerName = "JOHN", rivalName = "BLUE" })
seed.player.map, seed.player.x, seed.player.y = mapId, x, y
-- a plausible mid-game party so the slot is not a blank new game
seed.party = { {
species = "SQUIRTLE", level = 16, exp = 4000,
dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 },
statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 },
stats = { hp = 44, attack = 28, defense = 31, speed = 26, special = 30 },
hp = 44, moves = { { id = "TACKLE", pp = 35, ppUps = 0 } },
nickname = "SQ", ot = "JOHN", otId = seed.player.id, catchRate = 45,
} }
if not SaveFileIO.importToSlot(GenSave.encode(seed, data, nil), "red") then return nil end
if not SaveData.load("red") then return nil end
if not SaveFileIO.exportActiveSlot("red") then return nil end
return files["exports/gen1recomp-red-slot1.sav"]
end
local function assertLoadable(label, mapId, x, y)
local def = data.maps[mapId]
if not def then return end -- this data set lacks the map; skip silently
local out = exportedThrough(mapId, x, y)
check(out ~= nil and #out == GenSave.SAVE_SIZE,
label .. ": exports a 32768-byte image through the launcher path")
if not out then return end
-- (a) wCurMap resolves: an unresolved index loads a garbage header, the
-- #206 glitch/crash itself
eq(mapsByIndex[out:byte(OFF.curMap + 1)], mapId,
label .. ": exported wCurMap resolves to the saved map")
-- (b) tile coords inside the 2W x 2H cell grid, and unchanged by the round trip
local cx, cy = out:byte(OFF.xCoord + 1), out:byte(OFF.yCoord + 1)
check(cx <= 2 * def.width - 1 and cy <= 2 * def.height - 1,
("%s: player cell (%d,%d) is in-bounds for the %dx%d-block map")
:format(label, cx, cy, def.width, def.height))
eq(cx, x, label .. ": wXCoord survives load -> export unchanged")
eq(cy, y, label .. ": wYCoord survives load -> export unchanged")
check(mainChecksumValid(out), label .. ": exported main-data checksum is valid")
end
assertLoadable("bedroom interior", "REDS_HOUSE_2F", 3, 6)
assertLoadable("outdoor town", "PALLET_TOWN", 5, 6)
assertLoadable("outdoor city", "CERULEAN_CITY", 27, 15)
assertLoadable("cave floor", "MT_MOON_1F", 5, 5)
-- the map's far corner (2W-1, 2H-1): proves the boundary coord writes and
-- survives without truncation or an off-by-one out-of-bounds
if data.maps.VIRIDIAN_CITY then
local d = data.maps.VIRIDIAN_CITY
assertLoadable("far corner", "VIRIDIAN_CITY", 2 * d.width - 1, 2 * d.height - 1)
end
end
love.filesystem = realFS
T.finish("save_file_io")
+10 -11
View File
@@ -101,15 +101,14 @@ end
-- ported, kept here as a regression check of the full chain).
--
-- Note: the B2F -> B3F leg's *destination* visibility (steps 5-6 below)
-- is a known pre-existing gap unrelated to this workstream: B3F's
-- toggleable_objects.asm ordinal skips BOULDER1/BOULDER4, so
-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4 (which should land on
-- SEAFOAMISLANDSB3F_BOULDER5/6) resolve through
-- OverworldController.lua's toggleToObjectName() to the wrong (already-
-- visible) BOULDER3/4 instead. That resolver lives outside this
-- workstream's port targets, so only the event flag + source-hide (both
-- correct today) are asserted for that leg; the cosmetic destination
-- reveal is left as-is.
-- was a pre-existing gap (issue #212): the plug rocks that stop the B3F
-- strong current land at (18,6)/(19,6) -- the hidden SEAFOAMISLANDSB3F_
-- BOULDER5/6 -- but field.pluggedByHolesOn wired those holes' showObject to
-- TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3/4, which toggleToObjectName() resolves
-- to the already-visible BOULDER3/4 at (8,14)/(9,14), so the landing rocks
-- never appeared. Fixed by repointing the showObject toggles to BOULDER_5/_6
-- (tools/rom_manifest*.json + the data/generated/field.lua cache), so the
-- destination reveal now works and is asserted here (checkDst = true).
local pushes = {
{ curMap = "SEAFOAM_ISLANDS_1F", hx = 17, hy = 6,
event = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE",
@@ -135,12 +134,12 @@ local pushes = {
event = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE",
srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER1",
dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER5",
checkDst = false },
checkDst = true },
{ curMap = "SEAFOAM_ISLANDS_B2F", hx = 22, hy = 6,
event = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE",
srcMap = "SEAFOAM_ISLANDS_B2F", srcName = "SEAFOAMISLANDSB2F_BOULDER2",
dstMap = "SEAFOAM_ISLANDS_B3F", dstName = "SEAFOAMISLANDSB3F_BOULDER6",
checkDst = false },
checkDst = true },
{ curMap = "SEAFOAM_ISLANDS_B3F", hx = 3, hy = 16,
event = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE",
srcMap = "SEAFOAM_ISLANDS_B3F", srcName = "SEAFOAMISLANDSB3F_BOULDER1",
+94
View File
@@ -0,0 +1,94 @@
-- Regression: the tall-grass "feet overdraw" must not fire for an off-map
-- cell during a map-connection seam step (issue #217).
--
-- Walking south out of Viridian City, crossConnection lands the player on
-- ROUTE_1 at (10, 0) and parks them one cell BEFORE the seam for the walk
-- step -- cellY = 0 - 1 = -1, one row off the new map's top edge. ROUTE_1's
-- borderBlock (11) border-extends OVERWORLD block 11, whose bottom tile row
-- is the grass tile ($52 = 82), so Map:isGrassCell(10, -1) used to return
-- TRUE and the four overworld overdraw sites painted an animated grass tuft
-- over the player's head for the ~5 frames the seam step lasted.
--
-- Tall grass ($52) is only meaningful within the loaded map view; the
-- border-block filler that back-fills off-map coordinates is never standable
-- grass (pokered engine/overworld/connections.asm loads the neighbour strip
-- but the player never collides against border filler as grass). The fix
-- guards Map:isGrassCell with an in-bounds check.
--
-- Self-contained; run via `luajit tests/parity_grass_seam.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.VIRIDIAN_CITY) then Data:load() end
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local OW = require("src.world.OverworldController")
local S = require("tests.harness").suite("parity grass seam")
local check, eq = S.check, S.eq
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack
StateStack:init()
Game.save = SaveData.newGame()
Game.overworld = OW
-- Park on Viridian City's south edge, on the exit-path column (cellX = 20),
-- facing the seam. heightCells = 18 blocks * 2 = 36, so cellY = 35 is the
-- bottom edge row.
while Game.stack:top() do Game.stack:pop() end
Game.stack:push(OW, "VIRIDIAN_CITY", 20, 35, "down")
local ow = Game.stack:top()
local south = ow.map:connection("south")
check(south and south.map == "ROUTE_1", "Viridian south connects to ROUTE_1")
check(ow:crossConnection("down", south) == true, "Viridian -> Route 1 crosses")
eq(ow.map.id, "ROUTE_1", "landed on ROUTE_1")
local p = ow.player
-- the seam parks the player one cell before the entry point, off the map
eq(p.cellY, -1, "seam step parks the player at cellY = -1 (off the top edge)")
eq(ow.map:inBounds(p.cellX, p.cellY), false, "the parked cell is off-map")
-- the border filler genuinely IS the grass tile, so the guard (not a
-- different tile) is what suppresses the phantom overdraw
eq(ow.map:cellTile(p.cellX, p.cellY), ow.map.tileset.grassTile,
"the off-map border tile decodes to the grass tile (82)")
-- THE FIX: an off-map cell is never tall grass, so no feet-overdraw fires
eq(ow.map:isGrassCell(p.cellX, p.cellY), false,
"off-map seam cell is not grass -- no phantom overdraw over the player")
-- and the target the overdraw sites also test must not fire off-map either
if p.targetX and not ow.map:inBounds(p.targetX, p.targetY) then
eq(ow.map:isGrassCell(p.targetX, p.targetY), false,
"off-map seam target is not grass either")
end
-- Positive control: a real in-bounds grass cell still reports grass, so the
-- guard did not blind the encounter roll / overdraw to actual tall grass.
local gx, gy
for cy = 0, ow.map.heightCells - 1 do
for cx = 0, ow.map.widthCells - 1 do
if ow.map:cellTile(cx, cy) == ow.map.tileset.grassTile then
gx, gy = cx, cy
break
end
end
if gx then break end
end
check(gx ~= nil, "ROUTE_1 has at least one in-bounds grass cell to test")
if gx then
check(ow.map:inBounds(gx, gy), "control grass cell is in bounds")
eq(ow.map:isGrassCell(gx, gy), true,
"real in-bounds tall grass still detected after the fix")
end
S.finish()
+75
View File
@@ -52,6 +52,26 @@ local clamped = Protocol.unpackMon(Data, packed)
eq(clamped.level, 100, "tampered level clamped")
eq(clamped.dvs.attack, 15, "tampered DV clamped")
-- OT identity survives the wire (#215): pokered's trade sends each mon's OT
-- ID (party_struct MON_OTID) and OT name (wPartyMonOT); the receiver keeps
-- them verbatim so a traded mon shows its original trainer, not the receiver.
local otMon = Pokemon.new(Data, "KADABRA", 30)
otMon.ot = "CULLEN"
otMon.otId = 16012
local otRt = Protocol.unpackMon(Data, Protocol.packMon(otMon))
eq(otRt.ot, "CULLEN", "OT name survives the wire")
eq(otRt.otId, 16012, "OT ID survives the wire")
-- a tampered OT ID is clamped into the 16-bit range like every other field
local otPack = Protocol.packMon(otMon)
otPack.otId = 999999
eq(Protocol.unpackMon(Data, otPack).otId, 65535, "over-range OT ID clamped")
otPack.otId = -5
eq(Protocol.unpackMon(Data, otPack).otId, 0, "under-range OT ID clamped")
-- an old peer that never sends OT leaves it nil (no worse than legacy; the
-- load-time stampOT backfill then fills the receiver's own, as before)
local legacy = Protocol.packMon(Pokemon.new(Data, "PIDGEY", 10))
check(Protocol.unpackMon(Data, legacy).ot == nil, "missing OT stays nil (legacy peer)")
-- ---------------------------------------------------------------- transport
local Net = require("src.link.Net")
@@ -254,6 +274,10 @@ end
-- ---------------------------------------------------------------- trade session
local partyA = { Pokemon.new(Data, "KADABRA", 30), Pokemon.new(Data, "PIDGEY", 10) }
local partyB = { Pokemon.new(Data, "MACHOKE", 32) }
-- distinct original trainers so a preserved OT is unmistakable after the swap
-- (#215): A's KADABRA belongs to CULLEN/16012, B's MACHOKE to RED/60368
partyA[1].ot = "CULLEN"; partyA[1].otId = 16012
partyB[1].ot = "RED"; partyB[1].otId = 60368
local tA = Protocol.TradeSession.new(Data, partyA)
local tB = Protocol.TradeSession.new(Data, partyB)
tA:handle({ type = "party", mons = Protocol.packParty(partyB) })
@@ -272,9 +296,14 @@ eq(tA.stage, "done", "trade completes")
local gotMon, evoTo = tA:apply(nil)
eq(gotMon.species, "MACHOKE", "A received Machoke")
eq(evoTo, "MACHAMP", "trade evolution triggers (Machoke -> Machamp)")
-- the received mon keeps its SENDER's OT, not the receiver's (#215)
eq(gotMon.ot, "RED", "A's received Machoke keeps sender B's OT name")
eq(gotMon.otId, 60368, "A's received Machoke keeps sender B's OT ID")
local gotMon2, evoTo2 = tB:apply(nil)
eq(gotMon2.species, "KADABRA", "B received Kadabra")
eq(evoTo2, "ALAKAZAM", "Kadabra -> Alakazam on trade")
eq(gotMon2.ot, "CULLEN", "B's received Kadabra keeps sender A's OT name")
eq(gotMon2.otId, 16012, "B's received Kadabra keeps sender A's OT ID")
-- declined trades cancel
local tC = Protocol.TradeSession.new(Data, partyA)
@@ -398,6 +427,52 @@ eq(battleG.player.mon.stats.hp, expectedStats.hp,
eq(gameG.save.party[1].level, 12, "the real save data keeps its actual level")
eq(gameH.save.party[1].level, 100, "...on both sides")
-- ---------------------------------------------------------------- "ANY" level ruling (#204)
-- The link "level ruling" picker cycles a string sentinel "ANY" meaning
-- "use each mon's real level" (Gen1 link cable always used the real level).
-- LinkState/Tournament.levelForWire turns that sentinel into nil on the wire;
-- a broken `x and nil or y` idiom used to let the literal "ANY" through into
-- opts.forceLevel, and Protocol.unpackMon then called math.floor("ANY") ->
-- "bad argument #1 to 'floor' (number expected, got string)", crashing the
-- host the instant parties were unpacked in newHost (see the #204 report's
-- traceback: unpackMon <- unpackParty <- newHost <- LinkState.update).
-- unpackMon now coerces forceLevel with tonumber, so any non-numeric level
-- string means "no forced level" and the mon keeps its packed real level.
local anyPk = Protocol.packMon(Pokemon.new(Data, "PIKACHU", 12))
local okAny, monAny = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "ANY" })
check(okAny, "unpackMon does not crash on the ANY sentinel string")
eq(okAny and monAny and monAny.level, 12, "ANY forceLevel keeps the mon's real level (12)")
local okStr, monStr = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = "50" })
eq(okStr and monStr and monStr.level, 50, "a numeric-string forceLevel is still honored (50)")
local okNil, monNil = pcall(Protocol.unpackMon, Data, anyPk, { forceLevel = nil })
eq(okNil and monNil and monNil.level, 12, "no forceLevel keeps the real level (12)")
-- end-to-end host path from the crash report: newHost -> unpackParty ->
-- unpackMon, with the exact bad value the bug emitted (forceLevel = "ANY").
local gameI = makeFakeGame("PIKACHU")
gameI.save.party[1] = Pokemon.new(Data, "PIKACHU", 12)
local gameJ = makeFakeGame("GEODUDE")
gameJ.save.party[1] = Pokemon.new(Data, "GEODUDE", 100)
gameJ.save.player.name = "BLUE"
local netI, netJ = Net.loopbackPair()
local packedI = Protocol.packParty(gameI.save.party)
local packedJ = Protocol.packParty(gameJ.save.party)
local seedIJ = 24680
local okHost, battleI = pcall(LinkBattle.newHost, gameI, netI, {
myParty = packedI, theirParty = packedJ, theirName = "BLUE", seed = seedIJ,
forceLevel = "ANY",
})
local okGuest, battleJ = pcall(LinkBattle.newGuest, gameJ, netJ, {
myParty = packedJ, theirParty = packedI, theirName = "RED", seed = seedIJ,
forceLevel = "ANY",
})
check(okHost and battleI ~= nil, "newHost does not crash with the ANY ruling")
check(okGuest and battleJ ~= nil, "newGuest does not crash with the ANY ruling")
eq(okHost and battleI and battleI.player.mon.level, 12,
"ANY ruling: the host keeps its mon's real level (12, not forced)")
eq(okHost and battleI and battleI.enemy.mon.level, 100,
"ANY ruling: the enemy mon keeps its real level (100, not forced)")
-- ---------------------------------------------------------------- tournament shot clock
-- opts.turnLimit only applies to tournament matches; the guest mashes
-- through its own menu every frame while the host never presses anything,
+27 -4
View File
@@ -1244,7 +1244,9 @@ do
eb2.participants = { [Game.save.party[1]] = true }
eb2:enemyMonFainted()
local boosted = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 1, true)
check(hasText(eb2, ("BULBASAUR gained\na boosted\n%d EXP. Points!"):format(boosted)),
-- _BoostedText ends in the ROM CONT code \v ("a boosted\011"), so the box
-- waits + scrolls the amount into view rather than drawing it off-screen (#216)
check(hasText(eb2, ("BULBASAUR gained\na boosted\v%d EXP. Points!"):format(boosted)),
"_BoostedText tail for traded mons")
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
@@ -1253,7 +1255,8 @@ do
eb3.participants = { [Game.save.party[1]] = true }
eb3:enemyMonFainted()
local share = Experience.gainFor(Data.pokemon.RATTATA, 10, false, 2, false)
check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\n%d EXP. Points!"):format(share)),
-- _WithExpAllText likewise ends in the CONT code \v ("with EXP.ALL,\011") (#216)
check(hasText(eb3, ("BULBASAUR gained\nwith EXP.ALL,\v%d EXP. Points!"):format(share)),
"_WithExpAllText tail on the EXP.ALL pass")
check(not hasText(eb3, "divided"), "no invented EXP.ALL summary line")
Game.save.inventory.EXP_ALL = nil
@@ -1873,6 +1876,25 @@ do
eq(vw, 276, "world fill width covers the unit window at pixel scale 7")
eq(vh, 156, "world fill height covers the unit window at pixel scale 7")
-- #208: on a dual-screen surface (AYN Thor, forced landscape) LOVE can
-- report drawable == unit size (pw/ww == 1) while its real coordinate->pixel
-- transform is 1.5. fitScale stays keyed off the drawable pixel size, but
-- the unit<->pixel conversion must use the REAL getDPIScale, not pw/ww, so
-- each GB pixel still lands on a WHOLE number of physical pixels (square) --
-- not the stretched 6-vs-9-device-px fractional pixels the reporter saw.
-- Before the fix drawScale()==Sp/(pw/ww)==7 and 7*1.5==10.5 px/GB px.
Zoom.reset()
g.getDimensions = function() return 1920, 1080 end
g.getPixelDimensions = function() return 1920, 1080 end
g.getDPIScale = function() return 1.5 end
eq(Renderer:fitScale(), 7,
"#208 divergent DPI: fitScale still 7 from drawable pixels")
local physical = Renderer:drawScale() * g.getDPIScale()
local rounded = math.floor(physical + 0.5)
check(math.abs(physical - rounded) < 1e-9,
"#208 GB pixel lands on a whole number of physical pixels (square)")
eq(rounded, 7, "#208 each GB pixel covers exactly fitScale (7) physical px")
-- missing pixel API falls back to getDimensions (headless / old stub)
g.getPixelDimensions = nil
g.getDPIScale = nil
@@ -2765,6 +2787,8 @@ do
end
-- Battle message lines skip a tile row (14 then 16), matching the menu.
-- The box renders the rolling 2-line window (self.shown); #216 reworked the
-- renderer so a 3rd line scrolls into view instead of drawing at y=144.
do
local Font = require("src.render.Font")
local ys, origCode, origBox = {}, Font.drawCode, Font.drawBox
@@ -2773,8 +2797,7 @@ do
local battle = setmetatable({
phase = "messages",
current = true,
charIndex = 999,
lines = { { 0x80 }, { 0x81 } },
shown = { { 0x80 }, { 0x81 } },
}, BattleState)
battle:drawTextArea()
Font.drawCode, Font.drawBox = origCode, origBox
+27 -8
View File
@@ -313,15 +313,34 @@ def parse_card_key_doors(pokered):
util.die(f"card_key.asm: {needle!r} not found (door tiles/blocks changed?)")
if n_doors != 22 or len(maps) != 10:
util.die(f"card key doors: expected 22 doors / 10 maps, got {n_doors}/{len(maps)}")
# closedDoors is hand-ported, not extracted: no retail .blk layout places
# a closed-door block at any of the coordinates above (the feature is
# unused/cut in the original game), so there is no ROM or disassembly
# source to derive this from. It restores that cut content by stamping
# facility.bst blocks 0x54/0x5f (2F-10F) or interior.bst block 0x20
# (11F) over each door on map load, opened by that door's
# EVENT_SILPH_CO_n_UNLOCKED_DOORn flag. Kept in sync by hand with
# tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
# closedDoors is hand-ported, not extracted: the .blk layouts ship with
# these doorways open and each floor's map script stamps the closed block
# on load, which the extractor cannot see from static map bytes. Kept in
# sync by hand with tools/rom_manifest.json's field.cardKeyDoors.closedDoors.
#
# ROCKET_HIDEOUT_* are the elevator lift gates, live in retail: the
# RocketHideoutB1F/B4F DoorCallbackScripts (scripts/RocketHideoutB1F.asm,
# RocketHideoutB4F.asm) ReplaceTileBlock a barred facility block over the
# lift doorway with floor block 0x0e until the guards are beaten. B1F
# stamps 0x54 at (12,8), opened by EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4
# (EVENT_ENTERED_ROCKET_HIDEOUT is never SetEvent -- the noted SFX bug --
# so it is not part of the open condition). B4F stamps 0x2d at (12,5),
# opened once BOTH EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0 and _1 are set
# (CheckBothEventsSet). B2F/B3F call no door callback (spinner floors).
#
# SILPH_CO_* restore cut content instead: no retail .blk places a closed
# block at those coords and no retail script stamps them (the feature is
# unused in the original game), stamping facility.bst blocks 0x54/0x5f
# (2F-10F) or interior.bst block 0x20 (11F) over each door, opened by that
# door's EVENT_SILPH_CO_n_UNLOCKED_DOORn flag.
closed_doors = {
"ROCKET_HIDEOUT_B1F": [
{"block": 0x54, "bx": 12, "by": 8, "event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", "open": 0x0e},
],
"ROCKET_HIDEOUT_B4F": [
{"block": 0x2d, "bx": 12, "by": 5,
"events": ["EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0", "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"], "open": 0x0e},
],
"SILPH_CO_2F": [
{"block": 0x54, "bx": 2, "by": 2, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", "open": 0x0e},
{"block": 0x54, "bx": 2, "by": 5, "event": "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", "open": 0x0e},
+23 -2
View File
@@ -4254,6 +4254,27 @@
},
"cardKeyDoors": {
"closedDoors": {
"ROCKET_HIDEOUT_B1F": [
{
"block": 84,
"bx": 12,
"by": 8,
"event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4",
"open": 14
}
],
"ROCKET_HIDEOUT_B4F": [
{
"block": 45,
"bx": 12,
"by": 5,
"events": [
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
],
"open": 14
}
],
"SILPH_CO_2F": [
{
"block": 84,
@@ -6777,7 +6798,7 @@
"x": 18,
"y": 6
},
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3",
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5",
"x": 19,
"y": 6
},
@@ -6788,7 +6809,7 @@
"x": 19,
"y": 6
},
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4",
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6",
"x": 22,
"y": 6
}
+23 -2
View File
@@ -4254,6 +4254,27 @@
},
"cardKeyDoors": {
"closedDoors": {
"ROCKET_HIDEOUT_B1F": [
{
"block": 84,
"bx": 12,
"by": 8,
"event": "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4",
"open": 14
}
],
"ROCKET_HIDEOUT_B4F": [
{
"block": 45,
"bx": 12,
"by": 5,
"events": [
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_0",
"EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_1"
],
"open": 14
}
],
"SILPH_CO_10F": [
{
"block": 84,
@@ -6754,7 +6775,7 @@
"x": 18,
"y": 6
},
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_3",
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_5",
"x": 19,
"y": 6
},
@@ -6765,7 +6786,7 @@
"x": 19,
"y": 6
},
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_4",
"showObject": "TOGGLE_SEAFOAM_ISLANDS_B3F_BOULDER_6",
"x": 22,
"y": 6
}