From 1f878c3098ba910cb8b154578e40859f765ee135 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Wed, 5 Aug 2026 10:18:47 -0400 Subject: [PATCH 01/12] =?UTF-8?q?Show=20the=20Pok=C3=A9dex=20entry=20for?= =?UTF-8?q?=20the=20Fighting=20Dojo=20prize=20balls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/scripts/story4.lua | 33 +++++--- src/ui/DexEntryMenu.lua | 11 ++- tests/drivers/fighting_dojo_bug197_test.lua | 10 ++- tests/parity_fighting_dojo_dex.lua | 89 +++++++++++++++++++++ 4 files changed, 124 insertions(+), 19 deletions(-) create mode 100644 tests/parity_fighting_dojo_dex.lua diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 874a2b9c..80dede5c 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -155,19 +155,26 @@ local function dojoBall(species, ownBall, otherBall, askKey) push(game, "You'll have to\nbeat the master\nfirst!", done) return end - ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) - if not yes then done() return end - flags["EVENT_GOT_" .. species] = true - flags.EVENT_DEFEATED_FIGHTING_DOJO = true - 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) - push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) - end) + -- Examining a ball shows that species' POKéDEX entry first + -- (DisplayPokedex in FightingDojo.asm, which also marks it seen), + -- then the yes/no take-it prompt (#853). + local Commands = require("src.script.Commands") + local ctx = { save = game.save, game = game, overworld = ow } + Commands.mark_seen(ctx, species) + local DexEntryMenu = require("src.ui.DexEntryMenu") + game.stack:push(DexEntryMenu.new(game, species, function() + ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) + if not yes then done() return end + flags["EVENT_GOT_" .. species] = true + flags.EVENT_DEFEATED_FIGHTING_DOJO = true + 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) + push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) + end) + end)) end end diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 11e0d808..b59e6ee1 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -6,6 +6,11 @@ -- StarterDex (engine/events/starter_dex.asm), which temporarily sets the -- owned bit so Oak's lab ball previews show height/weight/description -- without permanently marking the mon owned. +-- +-- `onDone` (optional) runs right after the page pops itself, the way a +-- TextBox onDone does; map scripts use it to continue once the player +-- closes the entry (the Fighting Dojo prize balls chain their take-it +-- prompt off it). local Font = require("src.render.Font") local Strings = require("src.core.Strings") @@ -31,9 +36,10 @@ local function resolveArgs(speciesOrOpts) return speciesOrOpts, false end -function DexEntryMenu.new(game, speciesOrOpts) +function DexEntryMenu.new(game, speciesOrOpts, onDone) local species, forceOwned = resolveArgs(speciesOrOpts) - local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) + local self = setmetatable({ game = game, forceOwned = forceOwned, + onDone = onDone }, DexEntryMenu) self.def = game.data.pokemon[species] local path, trueColor = require("src.pokemon.Sprites").path( game.data, species, "front", { kind = "dex" }) @@ -52,6 +58,7 @@ function DexEntryMenu:update(dt) local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") then self.game.stack:pop() + if self.onDone then self.onDone() end end end diff --git a/tests/drivers/fighting_dojo_bug197_test.lua b/tests/drivers/fighting_dojo_bug197_test.lua index ce529e1d..a234b4f9 100644 --- a/tests/drivers/fighting_dojo_bug197_test.lua +++ b/tests/drivers/fighting_dojo_bug197_test.lua @@ -3,7 +3,8 @@ -- BUG1 gate -- the master stops the player on the tile to his left -- 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 +-- BUG4 (verify) -- the ball ask() is the Gen1 descriptor, shown after +-- the species' dex entry (DisplayPokedex) -- 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 @@ -163,8 +164,9 @@ return function(game) 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. + -- BUG4 (verify-only): the Hitmonlee ball shows the species' Pokedex + -- entry first (DisplayPokedex, #853), then the Gen1 descriptor prompt + -- ("You want the hard kicking HITMONLEE?"). ------------------------------------------------------------------ ow = resetDojo(4, 2, "up", { EVENT_BEAT_KARATE_MASTER = true }) local leeBall = npcByName(ow, "FIGHTINGDOJO_HITMONLEE_POKE_BALL") @@ -173,7 +175,7 @@ return function(game) if leeBall then ow:talkTo(leeBall) check(sawText("hard kicking") or sawText("HITMONLEE"), - "BUG4: ball asks the Gen1 descriptor prompt (no dex entry)") + "BUG4: ball asks the Gen1 descriptor prompt after the dex entry") U.shot(game, DIR .. "/dojo_4_prompt.png") ------------------------------------------------------------------ -- BUG5: choose YES -> only the chosen ball vanishes; the other stays diff --git a/tests/parity_fighting_dojo_dex.lua b/tests/parity_fighting_dojo_dex.lua new file mode 100644 index 00000000..448ac2db --- /dev/null +++ b/tests/parity_fighting_dojo_dex.lua @@ -0,0 +1,89 @@ +-- Parity: the Fighting Dojo prize balls open the Pokédex entry before the +-- take-it prompt (#853). FightingDojo.asm runs DisplayPokedex on the +-- ball's species (marking it seen) and only then prints the yes/no ask. +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 then Data:load() end + +local S = require("tests.harness").suite("parity Fighting Dojo dex entry") +local check, eq = S.check, S.eq + +local Font = require("src.render.Font") +Font.load(Data) + +local TextBox = require("src.render.TextBox") +local DexEntryMenu = require("src.ui.DexEntryMenu") +local SaveData = require("src.core.SaveData") +local dojo = require("data.scripts.story4").FIGHTING_DOJO + +local function fakeGame() + local states = {} + local save = SaveData.newGame() + save.pokedex = { seen = {}, owned = {} } + save.flags = { EVENT_BEAT_KARATE_MASTER = true } + local game = { + data = Data, + save = save, + pressed = false, + stack = { + states = states, + push = function(_, s) states[#states + 1] = s end, + pop = function(_) states[#states] = nil end, + top = function(_) return states[#states] end, + }, + } + game.input = { wasPressed = function(_, btn) + local p = game.pressed + game.pressed = false + return p and btn == "a" + end } + return game +end + +local function pageText(box) + local out = {} + for _, page in ipairs(box.pages or {}) do + out[#out + 1] = table.concat(page, "\n") + end + return table.concat(out, "\n") +end + +-- each ball: dex entry first (seen, not owned), then the ask prompt +for _, c in ipairs({ + { textId = "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL", species = "HITMONLEE" }, + { textId = "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL", species = "HITMONCHAN" }, +}) do + local game = fakeGame() + dojo.talk[c.textId](game, {}, nil, function() end) + local top = game.stack:top() + check(getmetatable(top) == DexEntryMenu, + c.textId .. " opens the Pokédex entry first") + eq(top and top.def and top.def.id, c.species, + "the entry shows " .. c.species) + check(game.save.pokedex.seen[c.species] == true, + "the preview marks " .. c.species .. " seen") + check(not game.save.pokedex.owned[c.species], + "the preview does not mark " .. c.species .. " owned") + game.pressed = true + top:update(0) + local ask = game.stack:top() + check(getmetatable(ask) == TextBox, + "closing the entry shows the take-it prompt") + check(ask and pageText(ask):find(c.species, 1, true) ~= nil, + "the prompt names " .. c.species) +end + +-- before the Karate Master is beaten the ball still refuses, no dex entry +do + local game = fakeGame() + game.save.flags.EVENT_BEAT_KARATE_MASTER = nil + dojo.talk.TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL(game, {}, nil, + function() end) + check(getmetatable(game.stack:top()) == TextBox, + "an unbeaten master keeps the refusal text, not the dex entry") + check(not game.save.pokedex.seen.HITMONLEE, + "the refusal does not mark Hitmonlee seen") +end + +S.finish() From 8f0117a145392d14caca5f4143c62929d0772de0 Mon Sep 17 00:00:00 2001 From: johnjohto Date: Wed, 5 Aug 2026 10:25:19 -0400 Subject: [PATCH 02/12] Treat headless cache reads as misses in CacheFs The modkit validate and pack drivers run the real loader under plain luajit, with no love global. With --base imported, Data:load falls back to CacheFs.readActive for a generated module require cannot find (an optional module like data/generated/audio.lua is legitimately absent from developer and stale caches), and CacheFs.read indexed love.filesystem once there was no portable root, so validate and pack died with MK100 before the mod was even looked at. Headless there is no save directory to read from, so return nil like any other cache miss. Refs #850 --- src/import/CacheFs.lua | 3 +++ tests/engine/cache_fs_headless_test.lua | 20 ++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 tests/engine/cache_fs_headless_test.lua diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index 56974cdb..ad1b9018 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -294,6 +294,9 @@ function CacheFs.read(rel) f:close() return data end + -- headless (plain luajit, e.g. the modkit validate/pack driver): there is + -- no save directory to read from, so a cache miss is nil, not a crash + if not (love and love.filesystem) then return nil end return love.filesystem.read(rel) end diff --git a/tests/engine/cache_fs_headless_test.lua b/tests/engine/cache_fs_headless_test.lua new file mode 100644 index 00000000..286599c4 --- /dev/null +++ b/tests/engine/cache_fs_headless_test.lua @@ -0,0 +1,20 @@ +-- CacheFs stays headless-safe: plain luajit has no love global, and the +-- modkit validate/pack driver reaches CacheFs.read through Data:load when +-- an optional generated module (audio) is missing from the checkout +-- (issue #850). With no portable root and no love there is no save +-- directory to read from, so the read is a nil miss, not a crash. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +check(_G.love == nil, "suite runs with no love global") + +local CacheFs = require("src.import.CacheFs") + +check(CacheFs.read("data/generated/audio.lua") == nil, + "read is a nil miss headless, not a crash") +check(CacheFs.readActive("data/generated/audio.lua") == nil, + "readActive is a nil miss headless, not a crash") + +T.finish() From 88e34ef65c1e1a326497dec8ea6bdc59b65691e7 Mon Sep 17 00:00:00 2001 From: Dorian Burton <120594826+dburton95@users.noreply.github.com> Date: Wed, 5 Aug 2026 10:55:58 -0400 Subject: [PATCH 03/12] Added Love stub table to validate codeblock This fixes the nil value returned when running validate or pack on Fedora 43. Since Love isn't running, luajit calls on an empty table. Providing a stub table resolves the nil error. --- tools/modkit.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/modkit.py b/tools/modkit.py index 56e439fa..522b8008 100644 --- a/tools/modkit.py +++ b/tools/modkit.py @@ -528,6 +528,7 @@ def cmd_scaffold(args, repo): DRIVER_TEMPLATE = """-- generated by tools/modkit.py; drives the real loader headlessly package.path = "./?.lua;./?/init.lua;" .. package.path +love = require("tests.love_stub") local data = %s local FILES = %s local overlay = {} From f6392e8932e2f54ce83b489ca779fbe420c01fe3 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 5 Aug 2026 11:09:05 -0400 Subject: [PATCH 04/12] CLOSES #788, CLOSES #795, CLOSES #796, CLOSES #797, CLOSES #805, CLOSES #826, CLOSES #833, CLOSES #835, CLOSES #837, CLOSES #844, CLOSES #845, CLOSES #846, CLOSES #847 --- data/scripts/story.lua | 46 +-- data/scripts/victories.lua | 46 ++- src/battle/BattleState.lua | 63 +++- src/battle/EffectRegistry.lua | 24 +- src/core/ChipSynth.lua | 50 ++- src/core/Music.lua | 17 +- src/core/SaveData.lua | 19 +- src/core/Sound.lua | 99 +++++- src/import/RomImporter.lua | 31 ++ src/inventory/ItemEffects.lua | 7 + src/render/PaletteFX.lua | 16 + src/script/Commands.lua | 17 +- src/ui/BagMenu.lua | 31 +- src/ui/HallOfFame.lua | 116 +++++-- src/ui/NamingScreen.lua | 15 +- src/world/OverworldController.lua | 102 +++++-- .../battle_choice_paper_bug822_test.lua | 216 +++++++++++++ .../champion_alt_tempo_bug847_test.lua | 286 ++++++++++++++++++ tests/drivers/hall_of_fame_bug704_test.lua | 12 + tests/drivers/hit_sfx_bug826_test.lua | 262 ++++++++++++++++ .../drivers/pika_entrance_cry_bug837_test.lua | 172 +++++++++++ tests/engine/move_sfx_channel_gate_bug844.lua | 219 ++++++++++++++ tests/engine/naming_empty_confirm_bug833.lua | 126 ++++++++ .../engine/options_write_readback_bug828.lua | 113 +++++++ tests/engine/rare_candy_bag_open_bug796.lua | 198 ++++++++++++ tests/parity_applying_attack_anim.lua | 9 +- tests/parity_escape_rope_bug805.lua | 131 ++++++++ tests/parity_gym_tm_bag_full_bug797.lua | 154 ++++++++++ tests/parity_hof.lua | 14 +- tests/parity_surf_clears_bike_bug846.lua | 187 ++++++++++++ tests/rom_importer_last_version_test.lua | 89 ++++++ tests/run_tests.lua | 3 + 32 files changed, 2776 insertions(+), 114 deletions(-) create mode 100644 tests/drivers/battle_choice_paper_bug822_test.lua create mode 100644 tests/drivers/champion_alt_tempo_bug847_test.lua create mode 100644 tests/drivers/hit_sfx_bug826_test.lua create mode 100644 tests/drivers/pika_entrance_cry_bug837_test.lua create mode 100644 tests/engine/move_sfx_channel_gate_bug844.lua create mode 100644 tests/engine/naming_empty_confirm_bug833.lua create mode 100644 tests/engine/options_write_readback_bug828.lua create mode 100644 tests/engine/rare_candy_bag_open_bug796.lua create mode 100644 tests/parity_escape_rope_bug805.lua create mode 100644 tests/parity_gym_tm_bag_full_bug797.lua create mode 100644 tests/parity_surf_clears_bike_bug846.lua create mode 100644 tests/rom_importer_last_version_test.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index 8bcabcd0..cc540257 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -1031,23 +1031,31 @@ local championsRoomRivalScript = { { "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10 -- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo -- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after - -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in - { "play_music", "Music_Cities1", { keep = true } }, -- 11 - { "show_text", "_ChampionsRoomOakText" }, -- 12 - { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 13 - { "move_npc", 2, "up", 5 }, -- 14 OakEntranceAfterVictoryMovement + -- defeating RIVAL3), then Oak's "{PLAYER}!" + reveal + walk in. + -- audio/alternate_tempo.asm Music_Cities1AlternateTempo is not a plain + -- PlayMusic: it fades the current song out (wAudioFadeOutControl = 10), + -- waits 100 frames for the fade, then restarts Cities1 with channel 1 + -- pointed at Music_Cities1_Ch1_AlternateTempo -- `tempo 232` where the + -- normal Music_Cities1_Ch1 opens `tempo 144`, i.e. the slower, heavier + -- reading of the town theme this scene is known for (#847). + { "fade_music", 10 }, -- 11 + { "wait", 100 }, -- 12 + { "play_music", "Music_Cities1", { keep = true, tempo = 232 } }, -- 13 + { "show_text", "_ChampionsRoomOakText" }, -- 14 + { "show_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 15 + { "move_npc", 2, "up", 5 }, -- 16 OakEntranceAfterVictoryMovement -- OakCongratulatesPlayerScript: rival faces left, Oak faces down - { "face_object", 1, "left" }, -- 15 - { "face_object", 2, "down" }, -- 16 - { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 17 + { "face_object", 1, "left" }, -- 17 + { "face_object", 2, "down" }, -- 18 + { "show_text", "_ChampionsRoomOakCongratulatesPlayerText" }, -- 19 -- OakDisappointedWithRivalScript: Oak turns to the rival (right) - { "face_object", 2, "right" }, -- 18 - { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 19 + { "face_object", 2, "right" }, -- 20 + { "show_text", "_ChampionsRoomOakDisappointedWithRivalText" }, -- 21 -- OakComeWithMeScript: Oak faces down again, then exits up - { "face_object", 2, "down" }, -- 20 - { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 21 - { "move_npc", 2, "up", 2 }, -- 22 OakExitChampionsRoomMovement - { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 23 + { "face_object", 2, "down" }, -- 22 + { "show_text", "_ChampionsRoomOakComeWithMeText" }, -- 23 + { "move_npc", 2, "up", 2 }, -- 24 OakExitChampionsRoomMovement + { "hide_object", "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK" }, -- 25 -- ChampionsRoomPlayerFollowsOakScript / WalkToHallOfFame_RLEMovement -- (PAD_UP 4, PAD_LEFT 1): the player walks out after Oak instead of the -- screen just fading on the spot (#704). The entrance walk leaves the @@ -1057,12 +1065,14 @@ local championsRoomRivalScript = { -- trailing UP/LEFT are dropped. Scripted steps ignore collision here just -- as they do in the original (CollisionCheckOnLand skips its checks while -- wSimulatedJoypadStatesIndex is non-zero), so stepping through the - -- rival's cell at (4,2) is the ported behavior, not a clip. - { "move_player", "up", 3 }, -- 24 + -- rival's cell at (4,2) is the ported behavior, not a clip. Re-reported + -- as a clip in #847 and re-checked against home/overworld.asm + -- CollisionCheckOnLand, which is still the authority: do not "fix" it. + { "move_player", "up", 3 }, -- 26 -- hand the induction off to the HALL_OF_FAME room (consumed by its -- onEnter), then warp up into it (destWarp 1 lands at (4,7) facing up) - { "set_field", "pendingHallOfFame", true }, -- 25 - { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 26 + { "set_field", "pendingHallOfFame", true }, -- 27 + { "warp", "HALL_OF_FAME", 4, 7, "up" }, -- 28 } M.CHAMPIONS_ROOM = { diff --git a/data/scripts/victories.lua b/data/scripts/victories.lua index b1aeaa9f..8a05264c 100644 --- a/data/scripts/victories.lua +++ b/data/scripts/victories.lua @@ -18,6 +18,12 @@ -- script). Leaders are not def_trainers entries, so engageTrainer has -- no header.won -- checkVictoryRewards shows this chain instead of a -- synthetic "received badge/TM" stub. +-- +-- `itemDialogue` is the tail of that chain the original prints only after +-- GiveItem succeeds; `bagFull` is the gym's *TM*NoRoomText it prints +-- instead when the bag is at capacity (`jr nc, .BagFull` in +-- PewterGymScriptReceiveTM34, scripts/PewterGym.asm, and its siblings). +-- The badge lands either way -- .gymVictory runs from both paths (#797). local function range(prefix, first, last) local t = {} @@ -42,71 +48,93 @@ return { "_PewterGymBrockReceivedBoulderBadgeText", "_PewterGymBrockBoulderBadgeInfoText", "_PewterGymBrockWaitTakeThisText", + }, + itemDialogue = { "_PewterGymReceivedTM34Text", "_TM34ExplanationText", - } }, + }, + bagFull = "_PewterGymTM34NoRoomText" }, ["OPP_MISTY#1"] = { badge = "CASCADEBADGE", flag = "EVENT_BEAT_MISTY", item = "TM_BUBBLEBEAM", deactivate = range("EVENT_BEAT_CERULEAN_GYM_TRAINER_", 0, 1), dialogue = { "_CeruleanGymMistyReceivedCascadeBadgeText", "_CeruleanGymMistyCascadeBadgeInfoText", - "_CeruleanGymMistyReceivedTM11Text", - } }, + }, + itemDialogue = { "_CeruleanGymMistyReceivedTM11Text" }, + bagFull = "_CeruleanGymMistyTM11NoRoomText" }, ["OPP_LT_SURGE#1"] = { badge = "THUNDERBADGE", flag = "EVENT_BEAT_LT_SURGE", item = "TM_THUNDERBOLT", deactivate = range("EVENT_BEAT_VERMILION_GYM_TRAINER_", 0, 2), dialogue = { "_VermilionGymLTSurgeReceivedThunderBadgeText", "_VermilionGymLTSurgeThunderBadgeInfoText", + }, + itemDialogue = { "_VermilionGymLTSurgeReceivedTM24Text", "_TM24ExplanationText", - } }, + }, + bagFull = "_VermilionGymLTSurgeTM24NoRoomText" }, ["OPP_ERIKA#1"] = { badge = "RAINBOWBADGE", flag = "EVENT_BEAT_ERIKA", item = "TM_MEGA_DRAIN", deactivate = range("EVENT_BEAT_CELADON_GYM_TRAINER_", 0, 6), dialogue = { "_CeladonGymErikaReceivedRainbowBadgeText", "_CeladonGymRainbowBadgeInfoText", + }, + itemDialogue = { "_CeladonGymReceivedTM21Text", "_TM21ExplanationText", - } }, + }, + bagFull = "_CeladonGymTM21NoRoomText" }, ["OPP_KOGA#1"] = { badge = "SOULBADGE", flag = "EVENT_BEAT_KOGA", item = "TM_TOXIC", deactivate = range("EVENT_BEAT_FUCHSIA_GYM_TRAINER_", 0, 5), dialogue = { "_FuchsiaGymKogaReceivedSoulBadgeText", "_FuchsiaGymKogaSoulBadgeInfoText", + }, + itemDialogue = { "_FuchsiaGymKogaReceivedTM06Text", "_FuchsiaGymKogaTM06ExplanationText", - } }, + }, + bagFull = "_FuchsiaGymKogaTM06NoRoomText" }, ["OPP_SABRINA#1"] = { badge = "MARSHBADGE", flag = "EVENT_BEAT_SABRINA", item = "TM_PSYWAVE", deactivate = range("EVENT_BEAT_SAFFRON_GYM_TRAINER_", 0, 6), dialogue = { "_SaffronGymSabrinaReceivedMarshBadgeText", "_SaffronGymSabrinaMarshBadgeInfoText", + }, + itemDialogue = { "_SaffronGymSabrinaReceivedTM46Text", "_TM46ExplanationText", - } }, + }, + bagFull = "_SaffronGymSabrinaTM46NoRoomText" }, ["OPP_BLAINE#1"] = { badge = "VOLCANOBADGE", flag = "EVENT_BEAT_BLAINE", item = "TM_FIRE_BLAST", deactivate = range("EVENT_BEAT_CINNABAR_GYM_TRAINER_", 0, 6), dialogue = { "_CinnabarGymBlaineReceivedVolcanoBadgeText", "_CinnabarGymBlaineVolcanoBadgeInfoText", + }, + itemDialogue = { "_CinnabarGymBlaineReceivedTM38Text", "_CinnabarGymBlaineTM38ExplanationText", - } }, + }, + bagFull = "_CinnabarGymBlaineTM38NoRoomText" }, ["OPP_GIOVANNI#3"] = { badge = "EARTHBADGE", flag = "EVENT_BEAT_GIOVANNI", item = "TM_FISSURE", deactivate = range("EVENT_BEAT_VIRIDIAN_GYM_TRAINER_", 0, 7), dialogue = { "_ViridianGymGiovanniReceivedEarthBadgeText", "_ViridianGymGiovanniEarthBadgeInfoText", + }, + itemDialogue = { "_ViridianGymGiovanniReceivedTM27Text", "_ViridianGymGiovanniTM27ExplanationText", - } }, + }, + bagFull = "_ViridianGymGiovanniTM27NoRoomText" }, -- Silph Co. Giovanni: unlocks the president's Master Ball gift. -- SilphCo11FGiovanniStartBattleScript (scripts/SilphCo11F.asm) hands the diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index f225fcf0..c0aaf737 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -1362,6 +1362,23 @@ function BattleState:sendOutText(name) return self:romText("_EnemysWeakText", "The enemy's weak!\nGet'm! %s!", name) end +-- The cry a mon makes as it takes the field. Yellow does not run its +-- starter Pikachu through PlayCry at all: SendOutMon branches to +-- .starterPikachu (engine/battle/core.asm:1807-1817) and voices PCM +-- PikachuCry11, the short "Pika!", or PikachuCry37 when the Pikachu is +-- asleep (IsPlayerPikachuAsleepInParty); PrintBeginningBattleText does the +-- same for the BATTLE_TYPE_PIKACHU intro (engine/battle/common_text.asm: +-- 12-19). Without a clip the bare playCry reached for clip 1, the long +-- title-screen "Pikachuuu" (#837). Every PIKACHU gets it here, the same +-- starter approximation the rest of the port makes +-- (PikachuFollower.starterInParty). +function BattleState:playEntranceCry(battler) + local mon = battler and battler.mon + if not mon then return end + require("src.core.Sound").playCry(self.data, mon.species, + mon.status == "SLP" and 37 or 11) +end + -- audio/play_battle_music.asm: gym leaders (wGymLeaderNo) get the -- gym-leader theme, Lance does too, and the Champion (OPP_RIVAL3) -- gets the final-battle theme @@ -1495,7 +1512,7 @@ function BattleState:enter() -- a different point in each battle kind, so queue it per branch local function queueEnemyCry() self:act(function() - require("src.core.Sound").playCry(self.data, self.enemy.mon.species) + self:playEntranceCry(self.enemy) end) end -- PrintBeginningBattleText (engine/battle/common_text.asm:10-19): a wild @@ -1606,7 +1623,7 @@ function BattleState:enter() -- SendOutMon (core.asm:1757-1762): after the poof the mon grows -- out of the ball (AnimateSendingOutMon at hlcoord 4,11) self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) + self:playEntranceCry(self.player) end) self:markParticipant() end @@ -2355,7 +2372,7 @@ function BattleState:resolveSwitch(newMon) self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) + self:playEntranceCry(self.player) end) end) self:act(function() @@ -2823,7 +2840,16 @@ function BattleState:applyHitFx(hit) local t = hit.animType if not t and hit.blink then t = hit.blink.isPlayer and 1 or 4 end if hit.sfx then - require("src.core.Sound").play(self.data, hit.sfx) + local Sound = require("src.core.Sound") + -- EffectRegistry hands the row the PlayApplyingAttackSound sound WITH its + -- wFrequencyModifier byte, so it goes through the same pitch/tempo path + -- move sounds use (#826). A bare string -- an older row, or a mod that + -- built its own hit fx -- still plays unmodified. + if type(hit.sfx) == "table" then + Sound.playMove(self.data, hit.sfx) + else + Sound.play(self.data, hit.sfx) + end end if not t or not self:animationsOn() then return end if t == 1 then @@ -3894,7 +3920,7 @@ function BattleState:enemyMonFainted() self.enemySendingOut = false self:startGrowIn(self.enemy) self:actNext(function() - require("src.core.Sound").playCry(self.data, self.enemy.mon.species) + self:playEntranceCry(self.enemy) end) end) end) @@ -3933,7 +3959,7 @@ function BattleState:enemyMonFainted() self:actNext(function() self.sendingOut = false self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) + self:playEntranceCry(self.player) end) end) return @@ -4127,7 +4153,7 @@ function BattleState:openReplacementMenu() self.sendingOut = false -- SendOutMon (core.asm:1757-1762): poof, then the grow-in self:startGrowIn(self.player) - require("src.core.Sound").playCry(self.data, self.player.mon.species) + self:playEntranceCry(self.player) end) end, }) @@ -5116,11 +5142,32 @@ function BattleState:drawZonePass(src, sx, sy) local shader = PaletteFX.shader() local pals = self:sgbBattlePals() local bgp = self:activeBgp() + -- #822: OG / OG INV / CLASSIC are forced-mono modes, so sgbPalettes() being + -- nil here makes PaletteFX.ensureZones invent a whole-screen zone and the + -- WHOLE finished frame is re-thresholded through the shade shader at blit + -- time -- which is why picImage already hands those modes raw DMG grays. + -- This pass has to leave DMG shades behind for the same reason: sendColors + -- runs the mode substitution HERE too, and the frame-level pass then + -- substitutes a second time. OG INV inverts twice and comes out upright; + -- CLASSIC's color 0 (155,188,15) has red 0.61, which falls in the shader's + -- c1 bucket, so the paper darkens one shade. Either way the battle stops + -- matching the YES/NO box an overlay state draws over it, since that box + -- only ever sees the frame-level pass. OG is the identity, which is why + -- only the other two showed it. Keep this mode set in sync with picImage / + -- PaletteFX.ensureZones / WideBattle.monoMode. + local mono = PaletteFX.mode == "og" or PaletteFX.mode == "og_inv" + or PaletteFX.mode == "classic" love.graphics.setColor(1, 1, 1, 1) love.graphics.setShader(shader) local shaking = sx ~= 0 or sy ~= 0 for _, z in ipairs(BATTLE_ZONES) do - PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp)) + if mono then + -- the BGP fade still runs, just in gray: the frame-level pass colors + -- whatever DMG shade this leaves behind + PaletteFX.sendShades(shader, PaletteFX.permute(PaletteFX.GRAYS, bgp)) + else + PaletteFX.sendColors(shader, PaletteFX.permute(pals[z.pal], bgp)) + end local zx, zy = z[1] * 8, z[2] * 8 local zw, zh = (z[3] - z[1] + 1) * 8, (z[4] - z[2] + 1) * 8 love.graphics.setScissor(zx, zy, zw, zh) diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index de443a72..05c219d9 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -209,8 +209,28 @@ function EffectRegistry.runDamaging(battle, ctx, record) -- announcement-time moveAnimRow, later hits queue fresh anim rows. -- Thrash/rage continuations have no announcement anim -- a bare -- hitRow carries the blink instead. - local hitSfx = info.typeMult > 10 and "Super_Effective" - or info.typeMult < 10 and "Not_Very_Effective" or "Damage" + -- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after + -- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10 + -- is neutral, above it super effective, below it not very -- and sets + -- wFrequencyModifier/wTempoModifier alongside it: $20/$30 damage, $e0/$ff + -- super effective, $50/$01 not very. All three programs live on the noise + -- channel (audio/sfx/{damage,super_effective,not_very_effective}.asm, + -- `channel 8`), where the frequency modifier is added to the polynomial + -- counter and so IS the pitch of the hit, while the tempo modifier is + -- skipped outright (audio/engine_2.asm Audio2_note_length: `cp CHAN8 / + -- jr z, .skip` keeps the noise channel at the default $100). Playing them + -- bare made the super effective hit a dull thud and the not very effective + -- one a bright crack, which is why they sounded swapped (#826); the tempo + -- byte is deliberately not carried, since applying it would stretch notes + -- the hardware never stretches. + local hitSfx + if info.typeMult > 10 then + hitSfx = { sound = "Super_Effective", pitch = 0xe0 } + elseif info.typeMult < 10 then + hitSfx = { sound = "Not_Very_Effective", pitch = 0x50 } + else + hitSfx = { sound = "Damage", pitch = 0x20 } + end -- GetPlayerAnimationType / GetEnemyAnimationType (engine/battle/core.asm -- :3159 / :5555): wAnimationType is 4 (blink the enemy pic) or 1 (shake -- the screen vertically) for a damaging move with no added effect, and diff --git a/src/core/ChipSynth.lua b/src/core/ChipSynth.lua index dfbc5cc1..1ca31740 100644 --- a/src/core/ChipSynth.lua +++ b/src/core/ChipSynth.lua @@ -202,6 +202,30 @@ local function headerChannels(banks, header) return channels end +-- Which software channels (CHAN5-8) an sfx occupies: its header carries one +-- 3-byte descriptor per channel. Audio2_PlaySound walks exactly this list to +-- decide whether a new sfx may start at all (audio/engine_2.asm +-- .sfxChannelLoop), so Sound.playMove needs the set to reproduce that gate. +-- nil = not knowable here (a file def, or the banks are not readable yet), +-- which callers read as "no conflict". +function ChipSynth.effectChannels(data, def) + if type(def) ~= "table" then return nil end + local chip = def.chip + local specs = chip and chip.channels + if not specs then + if not def.address then return nil end + local ok, banks = pcall(engineBanks, data, chip) + if not ok then return nil end + local read + ok, read = pcall(headerChannels, banks, def) + if not ok then return nil end + specs = read + end + local channels = {} + for _, spec in ipairs(specs) do channels[#channels + 1] = spec.number end + return channels +end + local function fadeValue(nibble) if bit.band(nibble, 8) ~= 0 then return -bit.band(nibble, 7) end return nibble @@ -406,7 +430,15 @@ function Channel:nextEvent() elseif command == 0xEC then self.duty = bit.band(self:byte(), 3) elseif command == 0xED then - self.engine.tempo = self:byte() * 0x100 + self:byte() + local high = self:byte() + local low = self:byte() + -- a header carrying its own tempo is one of audio/alternate_tempo.asm's + -- Music_*AlternateTempo entry points, which re-point channel 1 at a + -- stub that sets the tempo and jumps into the normal body -- the body's + -- own tempo command never runs there, so ignore it here (#847) + if not self.engine.tempoLocked then + self.engine.tempo = high * 0x100 + low + end elseif command == 0xEE then self.engine.pan = self:byte() elseif command == 0xEF or command == 0xF0 then @@ -458,7 +490,15 @@ function Channel:nextEvent() local volume = bit.rshift(packed, 4) local fade = fadeValue(bit.band(packed, 0x0F)) if self.noise then - local parameter = self:byte() + -- Audio2_ApplyWavePatternAndFrequency adds wFrequencyModifier to the + -- frequency low byte for every channel at or past CHAN5, the noise + -- channel included (audio/engine_2.asm Audio2_ApplyFrequencyModifier). + -- On CHAN8 that byte is the polynomial counter, so the modifier moves + -- the noise pitch; it wraps at 8 bits, the carry landing in the high + -- byte that noise does not use for frequency. Dropping it left the + -- battle hit sounds at their unmodified pitches, where super effective + -- reads as the duller of the two (#826). + local parameter = bit.band(self:byte() + self.frequencyOffset, 0xFF) return self:noiseEvent( self:durationTicks(length), volume, fade, parameter) end @@ -753,6 +793,12 @@ function Engine.new(data, header, options) noiseInstruments = {}, channels = {}, }, Engine) + -- header.tempo: the Music_*AlternateTempo override Music.play stamps onto + -- a copy of the song def (audio/alternate_tempo.asm) (#847) + if header.tempo then + engine.tempo = header.tempo + engine.tempoLocked = true + end for _, spec in ipairs(chip and chip.channels or headerChannels(banks, header)) do local frameTicks = options.frameTicks diff --git a/src/core/Music.lua b/src/core/Music.lua index b5d54908..be6ba0e1 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -82,6 +82,7 @@ state = { fanfare = nil, -- fanfare SFX source; the song pauses while it plays fanfareResume = false, -- start/resume state.source when the fanfare ends fade = nil, -- active volume-ramp fade-out (see Music.fadeOut) + tempo = nil, -- alternate-tempo override in force for `current` failed = {}, -- labels whose def could not be started; logged once } @@ -234,11 +235,23 @@ function Music.play(data, song, loop, ctx) if not song then return end if not love.audio then return end -- headless test stub song = selectSong(song, ctx) + -- ctx.tempo is a Music_*AlternateTempo cue (audio/alternate_tempo.asm): + -- the same song restarted with channel 1 re-pointed at a stub whose only + -- difference is its `tempo`, so the same label at a different tempo is a + -- different cue and must not be deduped away (#847) + local tempo = ctx and ctx.tempo or nil -- a hook may silence the cue outright, or swap in a label the dedupe -- below has to compare against - if not song or song == state.current then return end + if not song or (song == state.current and tempo == state.tempo) then return end local def = songDef(data, song) if not def or state.failed[song] then return end + if tempo then + -- shallow copy: the registry def is shared, only this playback is slowed + local slowed = {} + for key, value in pairs(def) do slowed[key] = value end + slowed.tempo = tempo + def = slowed + end local wantLoop = loop ~= false local src, loopSrc, isChip, err = startSong(data, def, wantLoop) if not src then @@ -274,6 +287,7 @@ function Music.play(data, song, loop, ctx) local previous = state.current state.source, state.loopSource, state.chip = src, loopSrc, isChip state.current = song + state.tempo = tempo if Runtime.wants("music.started") then Runtime.emit("music.started", { song = song, previous = previous, chip = isChip, @@ -288,6 +302,7 @@ function Music.stop() stopSource(state.loopSource) require("src.core.ChipAudio").stopMusic() state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil + state.tempo = nil state.chip = false state.pendingRestore = nil if previous and Runtime.wants("music.stopped") then diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 6ce20c58..e05ac52b 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -368,11 +368,26 @@ function SaveData.saveOptions(opts, fs) end opts.modOptions = merged end - local ok, err = fs.write(OPTIONS_FILENAME, SaveSerializer.encode(opts)) + local encoded = SaveSerializer.encode(opts) + local ok, err = fs.write(OPTIONS_FILENAME, encoded) if not ok then Logger.error("options save failed: %s", tostring(err)) + return nil end - return ok and opts or nil + -- #828: settings "reset" on Android and Steam Deck with nothing in the log. + -- Every options write is a WHOLE-FILE rewrite, so a write that reports + -- success without the bytes landing (an external-storage volume that went + -- away mid-session, a read-only or full save dir) is indistinguishable from + -- "the launcher never saved". Read the file back and fail loudly instead: + -- callers already treat nil as a failed write, and the log line is what the + -- next report from those platforms needs to carry. + local wrote = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME) + if wrote ~= encoded then + Logger.error("options save did not land (%d bytes written, %s on disk)", + #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") + return nil + end + return opts end function SaveData.loadOptions(fs) diff --git a/src/core/Sound.lua b/src/core/Sound.lua index e0427380..f3fd35b0 100644 --- a/src/core/Sound.lua +++ b/src/core/Sound.lua @@ -208,29 +208,91 @@ end -- sfx table; older audio.lua builds without the variants fall back to -- the unmodified sound. -- anim: a moves.lua anim table { sound, pitch, tempo }. +-- +-- Whether a row sound is heard at all is Audio2_PlaySound's channel gate +-- (audio/engine_2.asm .playSfx/.sfxChannelLoop): for every channel the new +-- sfx wants, a channel still busy with a LOWER sound id aborts the whole +-- request (`cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`), while +-- an equal or lower id takes those channels over. A sound id is +-- (header address - SFX_Headers_1) / 3 (constants/music_constants.asm +-- music_const), so a def's header address orders ids inside one engine +-- bank. Blizzard's animation is two rows, BLIZZARD then HYDRO_PUMP +-- (data/moves/animations.asm BlizzardAnim), and SFX_BATTLE_29 (CHAN5+8) is +-- still sounding when the second row starts, so the original never plays +-- SFX_BATTLE_2A (CHAN5+6+8) at all -- unguarded, its tail is heard running +-- past the end of the animation (#844). +local lastMoveSfx -- { src, rank, engine, channels } of the last row sound + +local function channelsOverlap(a, b) + if not (a and b) then return false end + for _, x in ipairs(a) do + for _, y in ipairs(b) do + if x == y then return true end + end + end + return false +end + +-- would PlaySound start this def now? Taking a channel over also stops the +-- sound that held it, the way .playChannel resets the channel. +local function sfxChannelGate(data, def) + local cur = lastMoveSfx + if not cur then return true end + local ok, playing = pcall(cur.src.isPlaying, cur.src) + if not (ok and playing) then + lastMoveSfx = nil + return true + end + -- an unrankable def (file asset, or another engine's bank) has no + -- comparable sound id: leave it to the mixer, as before + if type(def) ~= "table" or not def.address or def.engine ~= cur.engine then + return true + end + local channels = require("src.core.ChipSynth").effectChannels(data, def) + if not channelsOverlap(channels, cur.channels) then return true end + if def.address > cur.rank then return false end + pcall(cur.src.stop, cur.src) + lastMoveSfx = nil + return true +end + +local function noteMoveSfx(data, def, src) + if not src or type(def) ~= "table" or not def.address then + lastMoveSfx = nil + return + end + lastMoveSfx = { + src = src, rank = def.address, engine = def.engine, + channels = require("src.core.ChipSynth").effectChannels(data, def), + } +end + function Sound.playMove(data, anim) if not anim or not anim.sound then return end local sfx = data.audio and data.audio.sfx if not sfx then return end local name = anim.sound local pitch, tempo = anim.pitch or 0, anim.tempo or 0x80 + local def = sfx[name] + if not sfxChannelGate(data, def) then return end + local src -- a chip program synthesizes the modified variant on demand; a file def -- can only reach for a pre-rendered one - if isChipDef(sfx[name]) then - if playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), - sfx[name], pitch, tempo) then - played("move", name) - end - return - end - if pitch ~= 0 or tempo ~= 0x80 then + if isChipDef(def) then + src = playPath(data, ("%s@%02x%02x"):format(name, pitch, tempo), + def, pitch, tempo) + else local key = ("%s@%02x%02x"):format(name, pitch, tempo) - if sfx[key] then - if playPath(data, key, sfx[key]) then played("move", name) end - return + if (pitch ~= 0 or tempo ~= 0x80) and sfx[key] then + src = playPath(data, key, sfx[key]) + else + src = playPath(data, name, def) end end - if playPath(data, name, sfx[name]) then played("move", name) end + if src then + played("move", name) + noteMoveSfx(data, def, src) + end end -- A derived cry ({ base = "RHYDON", pitch, length }) borrows another @@ -304,12 +366,18 @@ end -- returns the source (nil headless) so callers that block on the cry -- like the original's PlayCry -> WaitForSoundToFinish can poll it -function Sound.playCry(data, species) +function Sound.playCry(data, species, pikaClip) if not love.audio then return nil end -- Yellow voices every Pikachu cry with the PCM clips (the chip cry is - -- never used for the species there); clip 1 is the everyday "Pika!" + -- never used for the species there). Which clip is a property of the + -- call site in the original -- every caller of PlayPikachuSoundClip sets + -- its own `ldpikacry e, PikachuCryN` -- so pikaClip carries that choice + -- in; it is ignored for every other species. Clip 1 is the LONG + -- title-screen "Pikachuuu" (engine/movie/title.asm:146), kept as the + -- default only for the sites that have not been given their own clip + -- yet; battle entrances pass 11/37 (#837). if species == "PIKACHU" then - local src = Sound.playPikaCry(data, 1) + local src = Sound.playPikaCry(data, pikaClip or 1) if src then return src end end local cries = data.audio and data.audio.cries @@ -451,6 +519,7 @@ end -- hot reload / jukebox A-B: drop one key's sources (its pitch-tempo -- variants included) or all of them, so the next play re-resolves the def function Sound.invalidate(name) + lastMoveSfx = nil -- its source is about to be dropped or stopped local function evict(store, key) local src = store[key] if src then pcall(src.stop, src) end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 0ecbfc2a..c44f2fd1 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -997,6 +997,25 @@ local function updaterAllowed() return true end +-- #835: which column the launcher opens on. `tab` starts at the --game +-- shortcut's version (LaunchOptions.pendingTab) or Red; this then prefers the +-- game play() last handed off, so relaunching lands on the game that was last +-- played instead of always Red. An explicit --game still wins, and a +-- remembered version whose cache is gone or stale is ignored, since opening a +-- column with no Play button would read as the launcher losing the import. +-- Called from new() once self.ready is filled, which is what that check needs. +function RomImporter:_applyLastVersionTab() + local okLO, LO = pcall(require, "src.core.LaunchOptions") + if okLO and LO.pendingTab then return end + local okOpt, opts = pcall(function() + return require("src.core.SaveData").loadOptions() + end) + local last = okOpt and opts and opts.lastVersion + if last and GameVersion.VERSIONS[last] and self.ready[last] then + self.tab = last + end +end + -- The launcher runs each GameVersion as an independent tab. Each dropped or -- chosen ROM is routed to its version by SHA-1, extracted into that version's -- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all @@ -1128,6 +1147,7 @@ function RomImporter.new(onComplete, opts) self.romName[version] = "pokemon_" .. info.id .. (info.id == "yellow" and ".gbc" or ".gb") end + self:_applyLastVersionTab() -- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a -- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped @@ -2220,6 +2240,17 @@ function RomImporter:play(version) if self.workState == "working" then return end if not self.ready[version] then return end self._handedOff = true + -- #835: remember the game being launched so the next launcher start opens on + -- its column (_applyLastVersionTab). It rides options.lua rather than a file + -- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it + -- with the rest of the launcher's persisted state. A failed write only + -- costs the memory of the choice, so it must never block the boot. + pcall(function() + local SaveData = require("src.core.SaveData") + local opts = SaveData.loadOptions() + opts.lastVersion = version + SaveData.saveOptions(opts) + end) resetPointerCursor(self) -- The game draws with raw love.graphics from here on; drop the view's -- element tree and canvases before the handoff. diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index f7c1b9b3..81a5362a 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -489,6 +489,13 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) if battle then return "failed", { notTime(data, save) } end + -- ItemUseBicycle (engine/items/item_effects.asm) opens with + -- `cp 2 ; is the player surfing?` -> jp z, ItemUseNotTime, so the + -- BICYCLE refuses on the water with the same OAK text as the rods + -- above (#846) + if ow and ow.player and ow.player.surfing then + return "failed", { notTime(data, save) } + end return "bicycle" end diff --git a/src/render/PaletteFX.lua b/src/render/PaletteFX.lua index 9de4f271..ea654d9d 100644 --- a/src/render/PaletteFX.lua +++ b/src/render/PaletteFX.lua @@ -868,4 +868,20 @@ function PaletteFX.sendColors(shader, c) shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) end +-- The same send with NO display-mode substitution and no shade map: the four +-- colors reach the shader exactly as given. Only for an INTERMEDIATE pass +-- whose output is re-thresholded downstream -- the classic battle's zone pass +-- under a forced-mono mode, where ensureZones' whole-screen zone already +-- substitutes once at blit time and doing it again here applies the mode +-- twice (#822). Everything that draws a final pixel wants sendColors. +function PaletteFX.sendShades(shader, c) + -- headless (no love.graphics) leaves shader() nil; sendColors reaches the + -- same no-op through effectiveColors returning nil for an absent palette + if not shader or not c then return end + shader:send("c0", { c[1][1] / 255, c[1][2] / 255, c[1][3] / 255 }) + shader:send("c1", { c[2][1] / 255, c[2][2] / 255, c[2][3] / 255 }) + shader:send("c2", { c[3][1] / 255, c[3][2] / 255, c[3][3] / 255 }) + shader:send("c3", { c[4][1] / 255, c[4][2] / 255, c[4][3] / 255 }) +end + return PaletteFX diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 9d815504..20792244 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -1075,9 +1075,13 @@ function Commands.march_in_place(ctx, objIndex, on) end -- play_music [opts]: switch map music now; opts.keep marks it --- to survive the next warp (the story files' keepMusic idiom) +-- to survive the next warp (the story files' keepMusic idiom). +-- opts.tempo is the Music_*AlternateTempo override (audio/alternate_tempo.asm +-- re-points channel 1 at a stub that only changes the song's `tempo`) (#847). function Commands.play_music(ctx, songId, opts) - require("src.core.Music").play(ctx.game.data, songId) + local tempo = opts and opts.tempo + require("src.core.Music").play(ctx.game.data, songId, nil, + tempo and { tempo = tempo } or nil) if opts and opts.keep and ctx.overworld then ctx.overworld.keepMusicOnce = true end @@ -1087,6 +1091,15 @@ function Commands.stop_music(ctx) require("src.core.Music").stop() end +-- fade_music [control]: FadeOutAudio (home/fade_audio.asm) -- ramp the +-- current song to silence over 7 * control frames and stop it, the way +-- Music_Cities1AlternateTempo does before it restarts Cities1 (#847). +-- Non-blocking, like the ROM's write to wAudioFadeOutControl: pair it with +-- the `wait` that stands in for the following DelayFrames. +function Commands.fade_music(ctx, control) + require("src.core.Music").fadeOut(control or 10) +end + -- play_default_music: PlayDefaultMusic -- resume the current map's own -- theme (data.audio.mapSongs) after a cutscene override, keeping the -- bike/surf substitution rules. Headless-safe no-op without an overworld. diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index fc9539c4..e522533f 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -261,11 +261,30 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) Evolution.evolve(game, target, extra.evolveTo) return end + -- refresh counts in the list. Hoisted out of the tail below because the + -- RARE CANDY path returns early and still leaves the list on screen, so + -- both paths have to agree on what the row reads (#796). + local function refreshCounts() + for i, it in ipairs(list.items) do + if it.value == id then + local left = game.save.inventory[id] + if left then it.right = "x" .. left else table.remove(list.items, i) end + break + end + end + list.index = math.min(list.index, math.max(1, #list.items)) + end -- RARE CANDY: after the level text, the stat window, any level-up -- moves and a level evolution follow (item_effects.asm .useRareCandy -- runs PrintStatsBox, LearnMoveFromLevelUp and TryEvolvingMon) if extra and extra.leveledTo and target then - list:close() + -- .useItem_partyMenu re-enters StartMenu_Item after the stat box, it + -- does not CloseStartMenu, so out of battle the bag list stays up with + -- the decremented count showing. In battle the item spends the turn + -- and the bag has to go (dead today: ItemUseVitamin refuses RARE CANDY + -- mid-battle, kept so the boundary stays explicit). #796 + refreshCounts() + if battle then list:close() end showMessages(game, payload, function() local StatBox = require("src.battle.BattleState").StatBox game.stack:push(StatBox.new(game, target, function() @@ -304,15 +323,7 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) end) return end - -- refresh counts in the list - for i, it in ipairs(list.items) do - if it.value == id then - local left = game.save.inventory[id] - if left then it.right = "x" .. left else table.remove(list.items, i) end - break - end - end - list.index = math.min(list.index, math.max(1, #list.items)) + refreshCounts() -- HP medicine: fill the bar in the still-open picker first, then print -- and close, the order item_effects.asm .doneHealing runs in -- (SFX_HEAL_HP -> UpdateHPBar2 -> RedrawPartyMenu prints the message). diff --git a/src/ui/HallOfFame.lua b/src/ui/HallOfFame.lua index 610df29f..848fde8e 100644 --- a/src/ui/HallOfFame.lua +++ b/src/ui/HallOfFame.lua @@ -1,6 +1,7 @@ -- Hall of Fame induction (engine/movie/hall_of_fame.asm): each party --- member's front sprite scrolls onto the right side of the screen --- (HoFShowMonOrPlayer's .ScrollPic), then HoFDisplayMonInfo draws the +-- member's back sprite sweeps across the screen and its front sprite then +-- scrolls onto the right side (HoFShowMonOrPlayer's two .ScrollPic passes, +-- #847), then HoFDisplayMonInfo draws the -- left-side LEVEL/TYPE box, plays the cry, holds, and pops the bottom -- "HALL OF FAME" text box before fading to the next mon. After the -- party, the player pic scrolls in and HoFDisplayPlayerStats shows the @@ -41,6 +42,25 @@ end local SCROLL_SPEED = 4 -- px/frame @ 60fps local PIC_X, PIC_Y = 12 * 8, 5 * 8 +-- The BACK pic pass that runs in front of every front pic (#847). +-- HoFShowMonOrPlayer loads the back pic (predef LoadMonBackPic, or +-- RedPicBack through HoFLoadPlayerPics) into the same 7x7 window at +-- hlcoord 12,5, points the tilemap at base tile $31, and sweeps hSCX from +-- $c0 to $a0 at e = 4 -- in screen pixels the pic enters at x = 160 and +-- leaves at x = -64, 56 frames. Only then is the window re-pointed at the +-- front pic (base tile 0) with hSCY back to 0 and e = -4, so the front pic +-- starts at x = -64 rather than at its own width. +-- hSCY = $d0 during the back pass puts the window's top row at y = 88, so +-- its 7 tiles sit flush with the bottom of the screen. ScaleSpriteByTwo +-- (engine/battle/scale_sprites.asm) doubles the 32x32 back sprite into that +-- 7x7 = 56x56 buffer with the last 4 source rows and the last source column +-- dropped, so what shows is the sprite's top-left 28x28 at 2x. +local BACK_START_X, BACK_END_X = 160, -64 +local BACK_Y = 88 +local BACK_SCALE = 2 +local BACK_CROP = 28 +local FRONT_START_X = -64 + -- After HoFDisplayAndRecordMonInfo: 80 DelayFrames, then the bottom -- HALL OF FAME box for 180 DelayFrames, then GBFadeOutToWhite. local INFO_HOLD = 80 @@ -74,6 +94,8 @@ function HallOfFame.new(game, onDone) self.phase = "mons" self.sprites = {} -- species -> image or false self.spriteTrueColor = {} -- species -> full-color art flag (#637) + self.backs = {} -- species (or "@player") -> back image or false (#847) + self.backTrueColor = {} local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath( game.data, "front", { kind = "hof" }) @@ -98,20 +120,14 @@ function HallOfFame:nextMon() local mon = self.game.save.party[self.index] self.showHofBanner = false self.fade = 0 - if mon then - self.phase = "mons" - self.timer = INFO_HOLD - Sound.playCry(self.game.data, mon.species) - local sprite = self:spriteFor(mon.species) - local w = sprite and sprite:getWidth() or 56 - self.scrollX = -w - else - -- HoFShowMonOrPlayer with wHoFMonOrPlayer = player - self.phase = "player" - self.timer = 0 - local w = self.playerPic and self.playerPic:getWidth() or 56 - self.scrollX = -w - end + self.backQuad = nil + -- HoFShowMonOrPlayer runs the back pic sweep for a party member and for + -- the player alike (wHoFMonOrPlayer only picks which pics get loaded), so + -- both enter through the "back" phase and the front pic follows it (#847) + self.phase = "back" + self.afterBack = mon and "mons" or "player" + self.timer = 0 + self.scrollX = BACK_START_X end function HallOfFame:spriteFor(species) @@ -126,6 +142,30 @@ function HallOfFame:spriteFor(species) return cached or nil end +-- The back pic for the pass ahead of the current front pic: the party +-- member's own back sprite (predef LoadMonBackPic), or RedPicBack once the +-- party is done (HoFLoadPlayerPics). Cached like spriteFor (#847). +function HallOfFame:backPicFor() + local mon = self.game.save.party[self.index] + local key = mon and mon.species or "@player" + local cached = self.backs[key] + if cached == nil then + local Sprites = require("src.pokemon.Sprites") + local path, trueColor + if mon then + path, trueColor = Sprites.path(self.game.data, mon.species, "back", + { kind = "hof" }) + else + path, trueColor = Sprites.playerPath(self.game.data, "back", + { kind = "hof" }) + end + cached = tryImage(path) or false + self.backs[key] = cached + self.backTrueColor[key] = cached and trueColor or false + end + return cached or nil, self.backTrueColor[key] +end + -- HoFDisplayPlayerStats' DisplayDexRating tally (also -- OverworldController:dexRating / PokedexMenu.new's seen+owned counts) function HallOfFame:dexSeenOwned() @@ -154,9 +194,28 @@ function HallOfFame:update(dt) local input = self.game.input local skip = input:wasPressed("a") + -- .ScrollPic with d = $a0, e = 4: the back pic crosses the screen right to + -- left and is gone before the front pic starts. Both scrolls are plain + -- DelayFrame loops in the ROM, so neither takes a button (#847). + if self.phase == "back" then + self.scrollX = self.scrollX - SCROLL_SPEED + if self.scrollX <= BACK_END_X then + self.phase = self.afterBack + self.timer = self.phase == "mons" and INFO_HOLD or 0 + self.scrollX = FRONT_START_X + end + return + end + if self.phase == "mons" or self.phase == "fade" then if self.phase == "mons" and self.scrollX < PIC_X then self.scrollX = math.min(PIC_X, self.scrollX + SCROLL_SPEED) + -- PlayCry is the tail of HoFDisplayMonInfo, which runs only after + -- HoFShowMonOrPlayer's two scrolls have both settled (#847) + if self.scrollX >= PIC_X then + local mon = self.game.save.party[self.index] + if mon then Sound.playCry(self.game.data, mon.species) end + end return end if self.phase == "fade" then @@ -277,6 +336,27 @@ function HallOfFame:drawPic(img, trueColor) end end +-- The back pic sweep (see the BACK_* constants): the same 7x7 window as the +-- front pic, one screen lower, cropped to the 28x28 ScaleSpriteByTwo keeps. +function HallOfFame:drawBackPic() + local img, trueColor = self:backPicFor() + if not img then return end + local w = math.min(img:getWidth(), BACK_CROP) + local h = math.min(img:getHeight(), BACK_CROP) + if not self.backQuad then + self.backQuad = love.graphics.newQuad(0, 0, w, h, img:getDimensions()) + end + love.graphics.setColor(1, 1, 1, 1) + love.graphics.draw(img, self.backQuad, self.scrollX, BACK_Y, 0, + BACK_SCALE, BACK_SCALE) + -- same whole-screen palette exemption the front pic takes (#637) + if trueColor then + require("src.render.PaletteFX").markTrueColor(self.scrollX, BACK_Y, + w * BACK_SCALE, + h * BACK_SCALE) + end +end + -- HoFDisplayPlayerStats boxes + labels (player pic already on the right) function HallOfFame:drawPlayerStats() local save = self.game.save @@ -301,7 +381,9 @@ function HallOfFame:draw() love.graphics.setColor(1, 1, 1, 1) love.graphics.rectangle("fill", 0, 0, 160, 144) - if self.phase == "mons" or self.phase == "fade" then + if self.phase == "back" then + self:drawBackPic() + elseif self.phase == "mons" or self.phase == "fade" then local mon = self.game.save.party[self.index] if mon then self:drawPic(self:spriteFor(mon.species), diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 6073ca44..c35f4b9d 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -99,7 +99,20 @@ end function NamingScreen:confirm() local name = table.concat(self.glyphs) if name == "" then - name = (self.presets and self.presets[1]) or self.default or "A" + -- An empty confirm (START, or the ED cell with nothing typed) must not + -- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with + -- '@' (engine/menus/naming_screen.asm) and every caller checks that first + -- byte: AskName falls through to .declinedNickname, copying the species + -- name over the nick slot -- vanilla's "un-nicknamed", which this port + -- models as mon.nickname == nil (src/save_convert/GenSave.lua), so + -- evolution can still rename the mon. DisplayNameRaterScreen takes + -- .playerCancelled and keeps the old nick, which is why an explicit + -- opts.default still wins here. Player/rival naming + -- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an + -- empty result; the port keeps its preset fallback for that. + -- Contract for callers: "" means NO name -- BattleState:askNicknameUI and + -- Commands.give_pokemon both guard on #name > 0 before setting nickname. + name = (self.presets and self.presets[1]) or self.default or "" end Sound.play(self.game.data, "Press_AB") self.game.stack:pop() diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 85a7a5e1..10efbea7 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -2362,8 +2362,21 @@ function OverworldState:trySurf(fx, fy, onClose) Game.stack:push(TextBox.new(Game, text, function() if onClose then onClose() end p.surfing = true + -- walking / biking / surfing is ONE state byte in the original: + -- ItemUseSurfboard (engine/items/item_effects.asm) writes 2 over + -- whatever wWalkBikeSurfState held, so mounting a surf ends the bike + -- outright -- no bike step cadence in Player:tryMove and no bike + -- theme on the water (#846). Music.playMap re-picks the override + -- with BOTH flags, which setSurfing alone cannot do: effectiveMapSong + -- (src/core/Music.lua) prefers state.onBike over state.surfing. + Game.save.onBike = false self:syncSurfingPikachu() - require("src.core.Music").setSurfing(Game.data, true) + local Music = require("src.core.Music") + if self.map then + Music.playMap(Game.data, self.map.id, false, true) + else + Music.setSurfing(Game.data, true) -- headless harness with no map loaded + end Game.stack:push(require("src.render.Transition").whiteFlash(Game, nil, function() self:stepForwardOrCrossEdge(p.facing) end)) end)) @@ -3036,9 +3049,16 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) if reward.badge then Game.save.inventory[reward.badge] = 1 end + -- GiveItem can refuse at the bag cap: the leaders' post-battle scripts + -- (`call GiveItem` / `jr nc, .BagFull` in scripts/PewterGym.asm + -- PewterGymScriptReceiveTM34 and its siblings) then skip the received-TM + -- text and print the gym's *TM*NoRoomText instead, while .gymVictory + -- still awards the badge. Going through Bag.add keeps the cap and the + -- wBagItems order honest -- the raw inventory write bypassed both (#797). + local gotItem = false if reward.item then - local inv = Game.save.inventory - inv[reward.item] = (inv[reward.item] or 0) + 1 + gotItem = require("src.inventory.Bag").add( + Game.save, reward.item, 1, Game.data) local idef = Game.data.items[reward.item] -- GiveItem -> CopyToStringBuffer for "{RAM:wStringBuffer}" received texts Game.stringBuffer = idef and idef.name or reward.item @@ -3046,7 +3066,21 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) local lines = {} if reward.dialogue then local text = Game.data.text or {} - for _, label in ipairs(reward.dialogue) do + -- itemDialogue is the received-TM tail the original prints only when + -- GiveItem succeeded; bagFull is the alternate line for when it did not + local chain = reward.dialogue + if reward.itemDialogue or reward.bagFull then + chain = {} + for _, label in ipairs(reward.dialogue) do chain[#chain + 1] = label end + if gotItem then + for _, label in ipairs(reward.itemDialogue or {}) do + chain[#chain + 1] = label + end + elseif reward.bagFull then + chain[#chain + 1] = reward.bagFull + end + end + for _, label in ipairs(chain) do if text[label] and text[label] ~= "" then table.insert(lines, text[label]) end @@ -3057,7 +3091,7 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex) or reward.badge table.insert(lines, Strings("%s received\nthe %s!", Game.save.player.name, name)) end - if reward.item then + if reward.item and gotItem then local name = Game.stringBuffer or reward.item table.insert(lines, Strings("%s received\n%s!", Game.save.player.name, name)) end @@ -3620,9 +3654,13 @@ function OverworldState:checkForcedMovement() return true end elseif tile.mode == "surf" then + -- scripts/SeafoamIslandsB4F.asm writes wWalkBikeSurfState = 2 and + -- jp ForceBikeOrSurf, so a forced surf clears the bike state the + -- same way the party-menu mount does (#846) p.surfing = true + Game.save.onBike = false self:syncSurfingPikachu() - require("src.core.Music").setSurfing(Game.data, true) + require("src.core.Music").playMap(Game.data, self.map.id, false, true) end return false end @@ -3913,25 +3951,47 @@ function OverworldState:warpToHealPoint(onDone, opts) -- 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 + -- (LoadSpecialWarpData .usedFlyWarp, engine/overworld/special_warps.asm), + -- and that map is ALWAYS an outdoor one: SetLastBlackoutMap copies + -- wLastMap (engine/events/set_blackout_map.asm) and WarpFound2 only + -- writes wLastMap on outside maps (home/overworld.asm), with the landing + -- cell read from FlyWarpDataPtr. Prefer the canonical Fly landing + -- (field.flyWarps, one tile south of the PC door warp), else the + -- remembered outdoor door cell. + -- + -- A heal record naming no outdoor town, or naming a map that is not + -- outdoors at all, is never a legal escape-warp destination: a .sav + -- import stamps lastHeal from wherever the cartridge was saved + -- (SaveConvert mergeDefaults), so ESCAPE ROPE was dropping the player + -- into the dungeon that save sat in, whose LAST_MAP exits then still + -- pointed at the door they had walked in through (#805). Vanilla's + -- zero-filled wLastBlackoutMap is map 0, so an unusable record falls + -- back to the boot heal town exactly as a never-healed game does. + local out = heal.outdoor or { id = heal.map, x = heal.x, y = heal.y } + local fw = (Game.data.field.flyWarps or {})[out.id] + local outX = fw and fw.x or out.x + local outY = fw and fw.y or out.y + local outDef = Game.data.maps[out.id] + if not (outDef and outX and outY + and Map.isOutside(outDef, + FieldDefaults.field(Game.data, "outsideTilesets"))) then + local zeroFill = require("src.core.SaveData") + .defaultHeal(Game.data.field.boot) + out, outX, outY = { id = zeroFill.map }, zeroFill.x, zeroFill.y end + map, x, y = out.id, outX, outY end 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 + -- the remembered town door. The teleport branch re-points at the town it + -- just landed on: PrepareForSpecialWarp (engine/overworld/special_warps.asm) + -- writes the special-warp destination straight back into wLastMap for every + -- fly/escape warp that is not a dungeon warp, so the next LAST_MAP exit + -- resolves against that town instead of the dungeon door the player walked + -- in through before using the rope (#805). + if teleport then + self:rememberOutdoor(map, x, y) + elseif heal.outdoor then self:rememberOutdoor(heal.outdoor.id, heal.outdoor.x, heal.outdoor.y) end end diff --git a/tests/drivers/battle_choice_paper_bug822_test.lua b/tests/drivers/battle_choice_paper_bug822_test.lua new file mode 100644 index 00000000..77c6897a --- /dev/null +++ b/tests/drivers/battle_choice_paper_bug822_test.lua @@ -0,0 +1,216 @@ +-- Eye check: a YES/NO box over a classic battle wears the same paper as the +-- field behind it (#822). pokered data/sgb/sgb_packets.asm BlkPacket_Battle +-- attributes all 18 rows, and home/yes_no.asm InitYesNoTextBoxParameters puts +-- the box at hlcoord 14,7 -- inside the player-HP-bar region, pal 0. +-- POKEPORT_DRIVER=tests/drivers/battle_choice_paper_bug822_test.lua POKEPORT_IDENTITY=bug822 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED: it scales the logic clock only, and these frames are judged as drawn. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local PaletteFX = require("src.render.PaletteFX") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local ChoiceBox = require("src.ui.ChoiceBox") + local Theme = require("src.ui.Theme") + + -- pokered data/maps/objects/Route1.asm puts the two youngsters at (5,24) and + -- (15,13) and the sign at (9,27), so the top of the road is empty grass; the + -- battle is pushed rather than walked into, the cell is only somewhere to stand. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 6, facing = "down" } + local PARTY = { { "BULBASAUR", 12 }, { "PIDGEOTTO", 18 } } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- machine-checkable preconditions ------------------------------------ + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL SFX volume is 0, so the box's A/B click is gone and there is no") + U.log(" way to tell a dead box from a live one you cannot hear. Set SFX to 7.") + end + check(("SFX volume %d, so the YES/NO box clicks when you answer it"):format(sfxVol), + sfxVol > 0) + check("the shade-remap shader compiled (no shader, no colorization at all)", + PaletteFX.shader() ~= nil) + check("PaletteFX.sendShades exists -- the raw sender the #822 fix needs", + type(PaletteFX.sendShades) == "function") + check("all 7 COLORS modes are on the ladder (" + .. table.concat(PaletteFX.MODES, ", ") .. ")", #PaletteFX.MODES == 7) + local cl = PaletteFX.CLASSIC + check(("CLASSIC paper is the light pea green %d,%d,%d and its second shade" + .. " the darker %d,%d,%d -- the pair #822 confused") + :format(cl[1][1], cl[1][2], cl[1][3], cl[2][1], cl[2][2], cl[2][3]), + cl[1][1] == 155 and cl[1][2] == 188 and cl[2][1] == 139) + check("OG's GRAYS start at pure white, so OG is the shader identity", + PaletteFX.GRAYS[1][1] == 255) + local box = Theme.choiceBox + check(("the YES/NO box sits at tile %d,%d (%dx%d) -- InitYesNoTextBoxParameters'" + .. " hlcoord 14,7"):format(box.tx, box.ty, box.tw, box.th), + box.tx == 14 and box.ty == 7) + + -- ---- get into a battle with the box up ---------------------------------- + game.save.party = {} + for _, slot in ipairs(PARTY) do + table.insert(game.save.party, Pokemon.new(game.data, slot[1], slot[2])) + end + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the road: any free neighbour will do, nothing here + -- depends on the cell beyond having somewhere legal to stand + for _, d in ipairs({ { 0, 1 }, { 0, -1 }, { 1, 0 }, { -1, 0 } }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(20) + ow = game.overworld + break + end + end + end + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + + local battle = BattleState.newWild(game, "RATTATA", 5) + battle.onFinish = function() end + ow:pushBattle(battle) + for _ = 1, 400 do + if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end + U.wait(1) + end + for _ = 1, 120 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", battle.phase == "menu") + + -- The same bare box BattleState pushes for the switch offer (BattleState.lua + -- :1291): no anchor, so it sits over the battle rather than riding a dialogue + -- box. Pushed directly because the reporter's screen is the box on top of a + -- live battle, and how it got there changes nothing about the colorization. + local choice = ChoiceBox.new(game, function() end) + game.stack:push(choice) + U.wait(10) + check("a YES/NO box is on top of the battle", game.stack:top() == choice) + + -- ---- measure both papers, mode by mode ---------------------------------- + -- Replays what Game:draw does for a classic battle with an overlay above it: + -- every state paints onto the one 160x144 UI canvas, then the topmost state + -- with sgbPalettes owns the screen. BattleState:sgbPalettes returns nil for + -- the classic layout, so PaletteFX.ensureZones decides whether a whole-screen + -- shade pass runs at all -- it does in OG / OG INV / CLASSIC, and does not in + -- the colorized modes. Offscreen so the sample boxes stay in clean 160x144 + -- space whatever the window is doing; the U.shot next to each reading is the + -- same frame as presented. + local g = love.graphics + local shader = PaletteFX.shader() + + -- The empty pocket the SGB attribute map leaves at tiles 9,4 - 10,6: below + -- the enemy HP bar (rows 0-3), right of the player mon (cols 0-8), left of + -- the enemy mon (col 11 on). Nothing is ever drawn here, so it is the field + -- paper and nothing else. + local FIELD = { 74, 36, 85, 52 } + -- Inside the YES/NO box, clear of its border tiles. The glyphs and cursor + -- live in here too, which is why the reading is the most common color rather + -- than one pixel. + local BOXI = { 120, 64, 151, 87 } + + local function modal(id, r) + local counts, best, bestN = {}, nil, -1 + for y = r[2], r[4] do + for x = r[1], r[3] do + local pr, pg, pb = id:getPixel(x, y) + local key = math.floor(pr * 255 + 0.5) .. "," .. math.floor(pg * 255 + 0.5) + .. "," .. math.floor(pb * 255 + 0.5) + counts[key] = (counts[key] or 0) + 1 + if counts[key] > bestN then best, bestN = key, counts[key] end + end + end + return best + end + + local function sample() + 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) -- the battle letterbox is white (letterboxWhite) + g.setColor(1, 1, 1, 1) + battle:draw() + choice:draw() + g.setCanvas(b) + g.clear(0, 0, 0, 1) + local zones = PaletteFX.ensureZones(nil) + if zones and zones[1] then + g.setShader(shader) + PaletteFX.sendColors(shader, PaletteFX.GRAYS) + end + g.setColor(1, 1, 1, 1) + g.draw(a, 0, 0) + g.setShader() + g.setCanvas(prev) + local id = b:newImageData() + return modal(id, FIELD), modal(id, BOXI), zones ~= nil and zones[1] ~= nil + end + + local mismatched = {} + for _, m in ipairs(PaletteFX.MODES) do + -- set the SAVED option too: Game:applyOptions re-reads save.options.colors, + -- so a bare setMode gets reverted underneath the next frame + game.save.options = game.save.options or {} + game.save.options.colors = m + PaletteFX.setMode(m) + U.wait(20) + local field, boxp, framePass = sample() + local label = PaletteFX.modeLabel(m) + local same = field == boxp + local known = (m == "gbc" or m == "gbc_inv") + U.log(("%-9s field %-13s box %-13s %s"):format( + label, field, boxp, + framePass and "whole-screen pass" or "no whole-screen pass")) + if m == "classic" then + check("CLASSIC field is the light pea green 155,188,15, not the darker" + .. " 139,172,15 one bucket down", field == "155,188,15") + end + if known then + -- the other half of #822, left open on purpose: with no frame-level pass + -- the overlay paints raw DMG white onto a canvas the battle has already + -- colorized, and nothing local to drawZonePass can reach it + U.log((" %s draws its overlays raw, so a mismatch here is the known" + .. " open half, not this fix failing"):format(label)) + else + if not same then mismatched[#mismatched + 1] = label end + check(label .. " box paper matches the field behind it", same) + end + U.shot(game, DIR .. "/bug822_" .. m .. ".png") + end + check("no forced-mono or ADVANCED/OG RED mode left the box a different color" + .. " from the field", #mismatched == 0) + if #mismatched > 0 then + U.log(" mismatched on:", table.concat(mismatched, ", ")) + end + + -- ---- over to you -------------------------------------------------------- + PaletteFX.setMode("classic") + game.save.options.colors = "classic" + U.wait(20) + U.log("You are looking at a YES/NO box sitting on a wild RATTATA battle in") + U.log("CLASSIC. The paper inside the box and the empty field around the mons") + U.log("should be the one same pea green, with no seam where the box begins;") + U.log("press 2 through the ladder and OG INV should go black-on-black the same") + U.log("way, while OG, OG RED and ADVANCED look exactly as they always did.") + U.log("The near miss is a box that is only slightly lighter than the field --") + U.log("that is the old one-bucket slip, not a border. SGB and SGB INV still") + U.log("show a white box over a tinted field; that half of #822 is open.") + U.log("Shots: " .. DIR .. "/bug822_*.png. A or B answers the box and it goes.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/champion_alt_tempo_bug847_test.lua b/tests/drivers/champion_alt_tempo_bug847_test.lua new file mode 100644 index 00000000..bf22c76b --- /dev/null +++ b/tests/drivers/champion_alt_tempo_bug847_test.lua @@ -0,0 +1,286 @@ +-- Driver for #847: slowed Cities1 (scripts/ChampionsRoom.asm:112 farcall +-- Music_Cities1AlternateTempo, audio/alternate_tempo.asm), the scripted walk +-- over the rival (home/overworld.asm CollisionCheckOnLand) and the back-pic +-- sweep (engine/movie/hall_of_fame.asm HoFShowMonOrPlayer). Never add +-- POKEPORT_SPEED here: it scales the logic clock only and audio is the test. +-- POKEPORT_DRIVER=tests/drivers/champion_alt_tempo_bug847_test.lua POKEPORT_IDENTITY=hof847 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots 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 Sprites = require("src.pokemon.Sprites") + local Music = require("src.core.Music") + local ChipSynth = require("src.core.ChipSynth") + local Commands = require("src.script.Commands") + local HallOfFame = require("src.ui.HallOfFame") + + local failures = 0 + local function check(label, ok) + if not ok then failures = failures + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- audio/music/cities1.asm: Music_Cities1_Ch1 opens `tempo 144`, the + -- Music_Cities1_Ch1_AlternateTempo stub `tempo 232` (macros/scripts/audio.asm + -- emits db HIGH(x), LOW(x), so these are the literal engine values). + local NORMAL_TEMPO, ALT_TEMPO = 144, 232 + local SONG = "Music_Cities1" + + -- --------------------------------------------------------------- + -- party + options + -- --------------------------------------------------------------- + local SPECIES = { "CHARIZARD", "SNORLAX", "PIKACHU" } + game.save.party = {} + for i, name in ipairs(SPECIES) do + game.save.party[i] = Pokemon.new(game.data, name, 100 - (i - 1) * 27) + end + game.save.player.name = "BRYAN" + local options = game.save.options + check("sfxVol is non-zero, so the cries are audible", (options.sfxVol or 0) > 0) + check("musicVol is non-zero, so the tempo swap is audible", + (options.musicVol or 0) > 0) + + -- --------------------------------------------------------------- + -- machine checks: the parts an ear cannot separate from a bad build + -- --------------------------------------------------------------- + -- read the live registry, so a mod's map_scripts contribution is inspected + local mapScripts = require("data.scripts.init") + local champ = mapScripts.get("CHAMPIONS_ROOM") + local rows = champ and champ.talk and champ.talk.TEXT_CHAMPIONSROOM_RIVAL + check("CHAMPIONS_ROOM keeps its rival script", type(rows) == "table") + rows = rows or {} + + local iFade, iWait, iCue, iWalk, iWarp, cueOpts + for i, row in ipairs(rows) do + if row[1] == "fade_music" and not iFade then + iFade = i + elseif row[1] == "wait" and iFade and not iWait then + iWait = i + elseif row[1] == "play_music" and row[2] == SONG and not iCue then + iCue, cueOpts = i, row[3] + elseif row[1] == "move_player" and row[2] == "up" and not iWarp then + iWalk = i + elseif row[1] == "warp" and row[2] == "HALL_OF_FAME" then + iWarp = iWarp or i + end + end + check("the script fades the battle theme out before Cities1 (#847)", + iFade ~= nil and iCue ~= nil and iFade < iCue) + check("it waits out the fade, like the ld c, 100 / call DelayFrames", + iWait ~= nil and iWait > iFade and iWait < iCue + and (rows[iWait or 1][2] or 0) >= 100) + check("the Cities1 cue carries the alternate tempo " .. ALT_TEMPO, + type(cueOpts) == "table" and cueOpts.tempo == ALT_TEMPO) + check("it still walks the player out before the warp (#704)", + iWalk ~= nil and iWarp ~= nil and iWalk < iWarp) + check("Commands.fade_music exists for that first row", + type(Commands.fade_music) == "function") + + -- the tempo has to survive the song's own `tempo` command: without the + -- override the body's 144 wins and Cities1 plays as the ordinary town theme + local def = game.data.audio and game.data.audio.songs + and game.data.audio.songs[SONG] + check(SONG .. " is in the extracted song table", type(def) == "table") + if type(def) == "table" then + local slowed = {} + for k, v in pairs(def) do slowed[k] = v end + slowed.tempo = ALT_TEMPO + local okAlt, alt = pcall(ChipSynth.newEngine, game.data, slowed, + { allowLoops = true }) + local okPlain, plain = pcall(ChipSynth.newEngine, game.data, def, + { allowLoops = true }) + check("an overridden song header starts locked at " .. ALT_TEMPO, + okAlt and alt and alt.tempo == ALT_TEMPO and alt.tempoLocked == true) + check("a plain header is left unlocked, free to take its own tempo " + .. NORMAL_TEMPO, + okPlain and plain and not plain.tempoLocked) + end + + -- HoFShowMonOrPlayer loads a back pic for every party member and for the + -- player; a missing key would silently draw nothing during the sweep + for _, name in ipairs(SPECIES) do + local path = Sprites.path(game.data, name, "back", { kind = "hof" }) + check(name .. " resolves a back pic for the induction", + type(path) == "string" and path ~= "") + end + local playerBack = Sprites.playerPath(game.data, "back", { kind = "hof" }) + check("the player resolves RedPicBack for the closing sweep", + type(playerBack) == "string" and playerBack ~= "") + + -- --------------------------------------------------------------- + -- stand where ChampionsRoomPlayerEntersScript leaves the player + -- --------------------------------------------------------------- + -- pokered data/maps/objects/ChampionsRoom.asm: CHAMPIONSROOM_RIVAL at (4,2), + -- both HALL_OF_FAME warps on row 0, and RivalEntrance_RLEMovement (up 1, + -- right 1, up 3) from warp 1 lands the player at (4,3), facing the rival. + local STAND = { x = 4, y = 3 } + U.teleport(game, "CHAMPIONS_ROOM", STAND.x, STAND.y, "up") + U.wait(20) + local ow = game.overworld + local rival + for _, npc in ipairs(ow and ow.npcs or {}) do + if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then rival = npc end + end + check("the rival object is on the map", rival ~= nil) + if rival and (ow.player.cellX ~= rival.cellX + or ow.player.cellY ~= rival.cellY + 1) then + -- a map edit or a mod moved the object: stand on any free walkable + -- neighbour instead of facing a wall. {dx, dy, facing} is the offset from + -- the rival to the stand cell plus the direction that looks back at him. + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = rival.cellX + s[1], rival.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d, %d) is not free; standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, "CHAMPIONS_ROOM", cx, cy, s[3]) + U.wait(10) + ow = game.overworld + for _, npc in ipairs(ow.npcs or {}) do + if npc.def and npc.def.name == "CHAMPIONSROOM_RIVAL" then + rival = npc + end + end + break + end + end + end + + -- run the tail of the script, from after the rival battle, so nobody has to + -- win OPP_RIVAL3 to hear this. Only rows before that point carry jump + -- targets, so the slice needs no reindexing -- assert that. + local from + for i, row in ipairs(rows) do + if row[1] == "show_text" + and row[2] == "_ChampionsRoomRivalAfterBattleText" then + from = i + break + end + end + check("found the post-battle row to start from", from ~= nil) + local slice, jumpy = {}, false + for i = from or 1, #rows do + local row = rows[i] + if row[1] == "jump" or row[1] == "jump_if_true" + or row[1] == "jump_if_false" then + jumpy = true + end + slice[#slice + 1] = row + end + check("the tail of the script has no jump targets to reindex", not jumpy) + + if failures > 0 then + U.log("stopping before the cutscene:", failures, + "check(s) failed above, so what you would hear means nothing") + while true do coroutine.yield() end + end + + -- watch the cues the script actually issues: on speakers a dedupe that ate + -- the restart and a fade that never fired sound like the same nothing + local realPlay, realFade = Music.play, Music.fadeOut + local cues, faded = {}, false + Music.play = function(data, song, loop, ctx) + cues[#cues + 1] = { song = song, tempo = ctx and ctx.tempo } + return realPlay(data, song, loop, ctx) + end + Music.fadeOut = function(control) + faded = true + return realFade(control) + end + + ow:queueScript(slice, { npc = rival }) + + -- --------------------------------------------------------------- + -- the walk out, over the rival's cell + -- --------------------------------------------------------------- + local startY = ow.player.cellY + local minY, sharedCell, walkShot = startY, false, false + local hof + for i = 1, 6000 do + local top = game.stack:top() + if getmetatable(top) == HallOfFame or (top and top.drawMonInfo) then + hof = top + break + end + local w = game.overworld + if w and w.map and w.map.id == "CHAMPIONS_ROOM" and rival then + local px, py = w.player.cellX, w.player.cellY + if py < minY then minY = py end + if px == rival.cellX and py == rival.cellY then + sharedCell = true + if not walkShot then + walkShot = U.shot(game, DIR .. "/bug847_over_rival.png") + end + end + end + if i % 6 == 0 then U.tap(game, "a") else U.wait(1) end + end + Music.play, Music.fadeOut = realPlay, realFade + + check("the battle theme was faded, not cut", faded) + local altCue + for _, c in ipairs(cues) do + if c.song == SONG and c.tempo == ALT_TEMPO then altCue = c end + end + check("Cities1 was restarted at the alternate tempo, not deduped away", + altCue ~= nil) + check("the player walked out of the room before the warp (#704)", + minY < startY) + -- CollisionCheckOnLand skips its checks while wSimulatedJoypadStatesIndex is + -- non-zero, so passing through (4,2) is the original behavior, not a clip + check("the scripted walk passed through the rival's cell (#847, not a bug)", + sharedCell) + check("walk-over screenshot", walkShot) + check("the induction started", hof ~= nil) + if not hof then + while true do coroutine.yield() end + end + + -- --------------------------------------------------------------- + -- the back-pic sweep ahead of the first front pic + -- --------------------------------------------------------------- + check("the induction opens on the back pic sweep, at the right edge", + hof.phase == "back" and (hof.scrollX or 0) > 96) + local sweepShot = false + for _ = 1, 400 do + if hof.phase ~= "back" then break end + if not sweepShot and (hof.scrollX or 0) <= 56 then + sweepShot = U.shot(game, DIR .. "/bug847_back_sweep.png") + end + U.wait(1) + end + check("back sweep screenshot", sweepShot) + check("the front pic phase follows the sweep, entering from the left", + hof.phase == "mons" and (hof.scrollX or 0) < 0) + for _ = 1, 200 do + if (hof.scrollX or 96) > 8 then break end + U.wait(1) + end + check("front scroll screenshot", U.shot(game, DIR .. "/bug847_front.png")) + for _ = 1, 400 do + if hof.phase == "mons" and (hof.scrollX or 0) >= 96 then break end + U.wait(1) + end + check("the front pic settles at hlcoord (12,5)", (hof.scrollX or 0) == 96) + + U.log(failures == 0 and "all checks passed" or ("FAILURES: " .. failures)) + U.log("input is yours now; the rest of the party and the player's own page") + U.log("follow on their own, so just watch and listen.") + U.log("after the rival's last line the battle music should fade out over") + U.log("about a second, go quiet for another second and a half, and then") + U.log("Pewter City comes back noticeably slower and heavier than it sounds") + U.log("in town -- and it stays that slow across the warp until the hall of") + U.log("fame theme takes over. For each mon a back sprite sweeps right to") + U.log("left low on the screen, then the front sprite slides in from the") + U.log("left and only cries once it stops.") + U.log("the near miss to listen for: Cities1 at its ordinary town tempo, or") + U.log("cutting in with no gap -- that is the old behavior, not the fix.") + U.log("the other near miss: a cry that fires while a sprite is still moving.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/hall_of_fame_bug704_test.lua b/tests/drivers/hall_of_fame_bug704_test.lua index 765c4fc2..d46dcabe 100644 --- a/tests/drivers/hall_of_fame_bug704_test.lua +++ b/tests/drivers/hall_of_fame_bug704_test.lua @@ -226,6 +226,18 @@ return function(game) -- --------------------------------------------------------------- -- mid scroll: .ScrollPic nudges hSCX 4px a frame, and the exemption has to -- travel with the pic instead of sitting at its resting column (#637) + -- #847: HoFShowMonOrPlayer sweeps the BACK pic across the screen (low, at + -- y=88) before the front pic scrolls in. Catch it mid-sweep, wait the + -- sweep out, then catch the front pic partway through its own scroll. + for _ = 1, 300 do + if hof.phase ~= "back" or (hof.scrollX or 0) <= 56 then break end + U.wait(1) + end + check("back-pic sweep screenshot", U.shot(game, DIR .. "/hof847_back.png")) + for _ = 1, 300 do + if hof.phase ~= "back" then break end + U.wait(1) + end for _ = 1, 200 do if (hof.scrollX or PIC_X) > 8 then break end U.wait(1) diff --git a/tests/drivers/hit_sfx_bug826_test.lua b/tests/drivers/hit_sfx_bug826_test.lua new file mode 100644 index 00000000..fc99603d --- /dev/null +++ b/tests/drivers/hit_sfx_bug826_test.lua @@ -0,0 +1,262 @@ +-- The super effective / not very effective hit sounds played at the wrong +-- pitch, so the weak-sounding hit landed on the weakness (#826). pokered +-- PlayApplyingAttackSound (engine/battle/animations.asm) sets +-- wFrequencyModifier with the sound, and audio/engine_2.asm +-- Audio2_ApplyFrequencyModifier adds it to the noise channel's polynomial +-- counter. Ears only, so never under POKEPORT_SPEED -- the pitch is the test. +-- POKEPORT_DRIVER=tests/drivers/hit_sfx_bug826_test.lua POKEPORT_IDENTITY=bug826 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 ChipSynth = require("src.core.ChipSynth") + local TypeChart = require("src.battle.TypeChart") + local Sound = require("src.core.Sound") + + -- GOLEM is ROCK/GROUND, so one attacker covers both ends of the routine + -- with no switching: WATER_GUN is 2x on each type and EMBER is 0.5x on + -- ROCK. SPLASH on the foe keeps the lead alive for as many replays as + -- the listener wants. + local FOE, FOE_LEVEL = "GOLEM", 60 + local LEAD, LEAD_LEVEL = "BULBASAUR", 25 + local WEAK_MOVE, STRONG_MOVE = "EMBER", "WATER_GUN" + -- PlayApplyingAttackSound's wFrequencyModifier per sound, and the + -- polynomial-counter byte of each program's first note (audio/sfx/ + -- {damage,super_effective,not_very_effective}.asm) before and after it. + local SOUNDS = { + { name = "Damage", pitch = 0x20, raw = 0x44, want = 0x64 }, + { name = "Super_Effective", pitch = 0xe0, raw = 0x34, want = 0x14 }, + { name = "Not_Very_Effective", pitch = 0x50, raw = 0x55, want = 0xa5 }, + } + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + local function hex(v) return v and ("$%02x"):format(v) or "nil" end + + -- ---- data the moment depends on ---------------------------------------- + local sfx = game.data.audio and game.data.audio.sfx or {} + for _, row in ipairs(SOUNDS) do + local def = sfx[row.name] + check(row.name .. " resolves to a chip program in the generated audio", + type(def) == "table" and def.address ~= nil and def.bank ~= nil) + end + local moveWeak, moveStrong = game.data.moves[WEAK_MOVE], game.data.moves[STRONG_MOVE] + local foeDef = game.data.pokemon[FOE] + check(WEAK_MOVE .. " and " .. STRONG_MOVE .. " resolve in the move table", + moveWeak ~= nil and moveStrong ~= nil) + check(FOE .. " resolves with a dual type", foeDef ~= nil and #foeDef.types == 2) + local weakMult, strongMult + if moveWeak and moveStrong and foeDef then + weakMult = TypeChart.effectiveness(moveWeak.type, foeDef.types) + strongMult = TypeChart.effectiveness(moveStrong.type, foeDef.types) + U.log(("%s on %s is x%.1f, %s is x%.1f (the x10 scale Damage.lua uses)") + :format(WEAK_MOVE, FOE, weakMult / 10, STRONG_MOVE, strongMult / 10)) + end + check(WEAK_MOVE .. " is the resisted side of the pair", (weakMult or 10) < 10) + check(STRONG_MOVE .. " is the super effective side", (strongMult or 10) > 10) + + local vol = game.save.options and game.save.options.sfxVol + check("sfx volume is up (" .. tostring(vol) .. "/7)", (vol or 0) > 0) + if (vol or 0) == 0 then + U.log("with sfxVol 0 both hits are silent and this run proves nothing;", + "raise it in OPTION and start over") + end + + -- ---- the synth half: does the modifier reach the noise channel? --------- + -- Sample each program once at offset 0 and again at its own modifier. A + -- port that drops wFrequencyModifier reports the same byte twice, which is + -- the whole of #826: unpitched, Super_Effective ends duller than + -- Not_Very_Effective ends. + local function firstNoise(header, offset) + if not header then return nil end + local engine = ChipSynth.newEngine(game.data, header, { + sfx = true, allowLoops = false, frequencyOffset = offset, + }) + for _, channel in ipairs(engine.channels) do + channel:sample() + local event = channel.event + if event and event.noiseParameter then return event.noiseParameter end + end + return nil + end + for _, row in ipairs(SOUNDS) do + local bare = firstNoise(sfx[row.name], 0) + local pitched = firstNoise(sfx[row.name], row.pitch) + check(("%s reads NR43 %s unmodified, as in the asm") + :format(row.name, hex(row.raw)), bare == row.raw) + check(("...and %s once %s is applied"):format(hex(row.want), hex(row.pitch)), + pitched == row.want) + U.log(("%s: %s -> %s, shift clock %d -> %d (higher shift = duller)") + :format(row.name, hex(bare), hex(pitched), + math.floor((bare or 0) / 16), math.floor((pitched or 0) / 16))) + end + + -- ---- the battle half: what does a hit row actually carry? --------------- + -- Offscreen scratch turn, no animation timing in the way. The row has to + -- name the sound AND its modifier byte, and must not carry a tempo byte: + -- Audio2_note_length skips Audio2_SetSfxTempo on CHAN8 (`cp CHAN8 / jr z, + -- .skip`), so the hardware never retimes these three. + -- the move is handed in whole, so the party lead keeps both its slots for + -- the live battle below + local function rowSfx(moveId) + local scratch = BattleState.newWild(game, FOE, FOE_LEVEL) + scratch.onFinish = function() end + -- Damage.accuracyRoll is `rng(0, 255) < acc` (src/battle/Damage.lua:105), + -- so the roll has to be pinned LOW to guarantee a hit. Pinning it high + -- misses every time, the row never gets a .hit, and this scan reads nil. + scratch.rng = function(lo) return lo end + scratch:performMove(scratch.player, scratch.enemy, { id = moveId, pp = 20 }) + for _, row in ipairs(scratch.queue) do + if row.hit and row.hit.sfx then return row.hit.sfx end + end + return nil + end + do + local lead = Pokemon.new(game.data, LEAD, LEAD_LEVEL) + lead.moves = { + { id = WEAK_MOVE, pp = 25, maxPP = 25 }, + { id = STRONG_MOVE, pp = 25, maxPP = 25 }, + } + game.save.party = { lead } + for _, case in ipairs({ + { move = STRONG_MOVE, want = "Super_Effective", pitch = 0xe0 }, + { move = WEAK_MOVE, want = "Not_Very_Effective", pitch = 0x50 }, + }) do + local row = rowSfx(case.move) + check(case.move .. " queues a hit sound with its modifier", + type(row) == "table" and row.sound == case.want + and row.pitch == case.pitch) + check("...and no tempo byte, the way CHAN8 ignores one", + type(row) == "table" and row.tempo == nil) + U.log(("%s -> %s pitch %s"):format(case.move, + type(row) == "table" and tostring(row.sound) or tostring(row), + type(row) == "table" and hex(row.pitch) or "nil")) + end + end + + -- ---- reach the moment --------------------------------------------------- + -- ROUTE_1 is open field (data/generated/maps.lua ROUTE_1); the stand cell + -- is read back off the loaded map, and a map edit degrades to the first + -- free cell instead of dropping the player into a wall. + U.teleport(game, "ROUTE_1", 5, 5, "down") + U.wait(10) + local map = game.overworld.map + if not map:isWalkableCell(5, 5) then + local fx, fy + for cy = 0, map.heightCells - 1 do + for cx = 0, map.widthCells - 1 do + if map:isWalkableCell(cx, cy) then fx, fy = cx, cy break end + end + if fx then break end + end + if fx then + U.log("cell (5, 5) is not walkable, standing on", fx, fy) + U.teleport(game, "ROUTE_1", fx, fy, "down") + U.wait(10) + end + end + local ow = game.overworld + check("player stands on a walkable ROUTE_1 cell", + ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY)) + + -- listen in on the real playback path so the log can tell "the fix is not + -- wired to the battle" from "the fix is wired but you did not like it" + local heard = {} + local realPlayMove = Sound.playMove + Sound.playMove = function(data, anim) + if type(anim) == "table" and anim.sound then + for _, row in ipairs(SOUNDS) do + if anim.sound == row.name then + heard[#heard + 1] = { sound = anim.sound, pitch = anim.pitch } + end + end + end + return realPlayMove(data, anim) + end + + local function mashUntil(cond, max) + for _ = 1, max or 160 do + if cond() then return true end + U.tap(game, "a") + U.wait(4) + end + return cond() + end + + local function newFight() + local battle = BattleState.newWild(game, FOE, FOE_LEVEL) + battle.onFinish = function(result) ow:afterBattle(result, battle) end + -- SPLASH so the foe's turn cannot end the run, or drown the hit under a + -- damage sound of its own + battle.enemy.mon.moves = { { id = "SPLASH", pp = 40, maxPP = 40 } } + battle.enemy.curMoves = battle.enemy.mon.moves + ow:pushBattle(battle) + U.wait(220) -- the send-out intro plays before the menu is reachable + mashUntil(function() return battle.phase == "menu" end) + return battle + end + + local battle = newFight() + check("the wild " .. FOE .. " battle reached its FIGHT menu", + battle.phase == "menu") + U.shot(game, DIR .. "/bug826_menu.png") + + -- slot 1 first: the resisted hit, so the pair is heard weak then strong + local function swing(slotDown, label) + local before = #heard + U.tap(game, "a") -- FIGHT + U.wait(16) + if slotDown then U.tap(game, "down"); U.wait(8) end + U.tap(game, "a") + for frame = 1, 1200 do + if battle.phase == "menu" and #battle.queue == 0 and not battle.draining then + break + end + if not battle.draining and frame % 8 == 0 then U.tap(game, "a") end + U.wait(1) + end + local row = heard[before + 1] + U.log(("%s played %s at pitch %s"):format(label, + row and row.sound or "nothing", + row and hex(row.pitch) or "nil")) + return row + end + + local weakHeard = swing(false, WEAK_MOVE) + U.shot(game, DIR .. "/bug826_not_very_effective.png") + local strongHeard = swing(true, STRONG_MOVE) + U.shot(game, DIR .. "/bug826_super_effective.png") + check("the resisted hit reached the mixer as Not_Very_Effective $50", + weakHeard ~= nil and weakHeard.sound == "Not_Very_Effective" + and weakHeard.pitch == 0x50) + check("the super effective hit reached it as Super_Effective $e0", + strongHeard ~= nil and strongHeard.sound == "Super_Effective" + and strongHeard.pitch == 0xe0) + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand the pad over -------------------------------------------------- + Sound.playMove = realPlayMove + if battle.phase ~= "menu" then + U.log("(the menu did not come back on its own: mash A to reach FIGHT)") + end + U.log("Both hits have already sounded once. GOLEM is still standing and") + U.log("EMBER and WATER_GUN sit in slots 1 and 2, so play them back to back") + U.log("as often as you like.") + U.log("WATER_GUN, under \"It's super effective!\", should be the brighter") + U.log("and sharper of the two -- a high crack. EMBER, under \"It's not very") + U.log("effective...\", should be a low dull rumble underneath it.") + U.log("The near miss to listen for: the two are close in brightness, or the") + U.log("crack lands on EMBER and the thud on WATER_GUN. That is the modifier") + U.log("going missing again, and it is what #826 sounded like.") + U.log("The neutral hit changed too: any move that is neither, on any foe,") + U.log("is now a shade duller than it used to be, and that is correct.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pika_entrance_cry_bug837_test.lua b/tests/drivers/pika_entrance_cry_bug837_test.lua new file mode 100644 index 00000000..e98e7d20 --- /dev/null +++ b/tests/drivers/pika_entrance_cry_bug837_test.lua @@ -0,0 +1,172 @@ +-- Manual check that a Pikachu taking the field says the short "Pika!" (#837). +-- pokeyellow engine/battle/core.asm SendOutMon .starterPikachu (:1807-1817) +-- voices PikachuCry11, or PikachuCry37 when IsPlayerPikachuAsleepInParty; the +-- port called PlayCry bare and got clip 1, the long title "Pikachuuu" +-- (engine/movie/title.asm:146). Never add POKEPORT_SPEED here: it scales the +-- logic clock and not audio, so the cries stop lining up with what you see. +-- POKEPORT_DRIVER=tests/drivers/pika_entrance_cry_bug837_test.lua POKEPORT_IDENTITY=bug837 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local Sound = require("src.core.Sound") + local GameVersion = require("src.core.GameVersion") + local BattleState = require("src.battle.BattleState") + + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function idle() + while true do coroutine.yield() end + end + + -- Red and Blue carry no PCM clips at all (RomExtractor extractPikachuCries + -- only runs on Yellow), so playPikaCry returns nil there and every Pikachu + -- keeps its chip cry from CryData: nothing below is observable off Yellow. + local audio = game.data.audio + local clips = audio and audio.pikaCries + local onYellow = check("running Yellow", GameVersion.isYellow()) + local haveClips = check("the cache carries the PCM clip set", + type(clips) == "number") + if not (onYellow and haveClips) then + U.log("Import a Yellow ROM and rerun with POKEPORT_VERSION=yellow. On Red") + U.log("and Blue there is no voiced Pikachu to get wrong.") + idle() + end + -- NUM_PIKA_CRIES is 42 (pokeyellow constants/music_constants.asm), and the + -- importer writes cry_01..cry_42.wav in PikachuCriesPointerTable order, so + -- clip 37 existing is what makes the asleep case reachable at all. + check("clip 37 fits inside the " .. tostring(clips) .. " clips extracted", + clips >= 37) + + -- resolve the two asset keys for real: a missing or unreadable wav makes + -- playPikaCry return nil and the cry falls through to the chip cry, which + -- is a different wrong sound from the one this issue is about. Stopped in + -- the same frame, so neither is audible here. + local function resolves(n) + local src = Sound.playPikaCry(game.data, n) + if src then src:stop() end + return src ~= nil + end + check("pika_cries/cry_11.wav loads", resolves(11)) + check("pika_cries/cry_37.wav loads", resolves(37)) + + local opts = game.save.options or {} + check("sfxVol is not muted (it reads " .. tostring(opts.sfxVol) .. ")", + (opts.sfxVol or 0) > 0) + check("PIKACHU VOL is not muted (it reads " .. tostring(opts.pikaVol) .. ")", + (opts.pikaVol or 0) > 0) + + -- Sound.playPikaCry emits "sound.played" with name = "PIKACHU_PCM_"; + -- this is the same feed mods read and tests/mod_audio_tests.lua subscribes + -- to, so the number below is the clip the engine actually asked for. + local heardCries = {} + local events = game.mods and game.mods.events + if not check("the sound.played feed is live", events ~= nil and events.on ~= nil) then + idle() + end + events:on("sound.played", function(p) + if p and p.kind == "cry" then heardCries[#heardCries + 1] = p.name end + end, nil, "bug837driver") + + game.save.party = { + Pokemon.new(game.data, "PIKACHU", 20), + Pokemon.new(game.data, "CHARMANDER", 20), + } + game.save.player.name = "RED" + check("PIKACHU leads the party", game.save.party[1].species == "PIKACHU") + + -- Route 1 so the handoff below has tall grass in reach. The route sign + -- sits at (9, 27) (pokered data/maps/objects/Route1.asm bg_event), and the + -- cell under it is the open path you read it from. + local MAP = "ROUTE_1" + local STAND = { x = 9, y = 28, facing = "up" } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + if not check("the overworld is up on " .. MAP, ow ~= nil) then idle() end + + -- a map edit or a mod can wall that cell off; widen out to any free + -- walkable neighbour rather than stranding the player inside scenery + local function freeNear(map, x, y) + for r = 1, 6 do + for dy = -r, r do + for dx = -r, r do + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) + and not ow:npcAtCell(cx, cy) then + return cx, cy + end + end + end + end + return nil + end + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + local cx, cy = freeNear(ow.map, STAND.x, STAND.y) + if cx then + U.log(("(%d, %d) is blocked, standing on"):format(STAND.x, STAND.y), + cx, cy) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(10) + ow = game.overworld + end + end + check("the player is standing somewhere walkable", + ow.map:isWalkableCell(ow.player.cellX, ow.player.cellY)) + + -- Push the encounter rather than walking into grass: the cry under test is + -- the player's own send-out, and a stepped encounter would put the wild + -- rolls and a second cry ahead of it. RATTATA keeps the enemy's cry a chip + -- cry, so it can never be confused with the PCM clip being checked. + local function runEntrance(asleep, label, shotPath) + for i = #heardCries, 1, -1 do heardCries[i] = nil end + game.save.party[1].status = asleep and "SLP" or nil + local wild = BattleState.newWild(game, "RATTATA", 3) + wild.onFinish = function() end + game.overworld:pushBattle(wild) + local pcm + for _ = 1, 500 do + for _, name in ipairs(heardCries) do + if name:find("PIKACHU_PCM_", 1, true) == 1 then pcm = name end + end + if pcm then break end + U.tap(game, "a") + U.wait(3) + end + U.log(label .. " recorded:", table.concat(heardCries, ", ")) + if shotPath then U.shot(game, shotPath) end + return pcm, wild + end + + -- asleep first, so the run ends on the everyday case and what is ringing + -- during the handoff is the clip the issue is really about + local slept = runEntrance(true, "asleep send-out", + DIR .. "/bug837_1_asleep.png") + check("an asleep PIKACHU is sent out with PCM clip 37 (PikachuCry37)", + slept == "PIKACHU_PCM_37") + + U.teleport(game, MAP, ow.player.cellX, ow.player.cellY, STAND.facing) + U.wait(10) + local awake = runEntrance(false, "awake send-out", + DIR .. "/bug837_2_awake.png") + check("a healthy PIKACHU is sent out with PCM clip 11 (PikachuCry11)", + awake == "PIKACHU_PCM_11") + check("clip 1, the long title-screen cry, is not what was played", + awake ~= "PIKACHU_PCM_1") + + U.log("You are in the second battle, the one with PIKACHU awake. The cry") + U.log("as it grew out of the ball should be the short bright \"Pika!\", the") + U.log("same one you hear pressing START on the Yellow title screen. The bug") + U.log("played the other title cry instead: the long drawn-out \"Pikachuuu\"") + U.log("that opens the title, roughly a second and a half of it, which is") + U.log("easy to miss as merely slow rather than wrong. Run away and walk") + U.log("into the grass north of here for as many more send-outs as you like;") + U.log("put PIKACHU to sleep and it turns into the sleepy clip 37 instead.") + U.log("Screenshots of both entrances are in " .. DIR .. ".") + + idle() +end diff --git a/tests/engine/move_sfx_channel_gate_bug844.lua b/tests/engine/move_sfx_channel_gate_bug844.lua new file mode 100644 index 00000000..b7666e43 --- /dev/null +++ b/tests/engine/move_sfx_channel_gate_bug844.lua @@ -0,0 +1,219 @@ +-- Animation-row SFX must obey PlaySound's channel-occupancy gate (#844). +-- +-- Blizzard's animation is two rows -- `battle_anim BLIZZARD, ...` then +-- `battle_anim HYDRO_PUMP, ...` (data/moves/animations.asm, BlizzardAnim) -- +-- and PlaySubanimation issues a PlaySound for every row +-- (engine/battle/animations.asm, PlaySubanimation). The extracted data and +-- the row timing are both faithful; what the port was missing is that the +-- original never actually starts that second sound. Audio2_PlaySound's +-- .playSfx/.sfxChannelLoop (audio/engine_2.asm) walks the channels the new +-- sfx declares and, for each one already busy, does +-- `ld a,[wSoundID] / cp [hl] / jr z,.playChannel / jr c,.playChannel / ret`: +-- a channel held by a LOWER sound id aborts the whole request, while an +-- equal or lower id takes the channel over (and .playChannel resets the +-- channel, cutting the old sound off). SFX_BATTLE_29 (BLIZZARD, CHAN5+8) is +-- still sounding when the HYDRO_PUMP row starts, and SFX_BATTLE_2A wants +-- CHAN5+6+8, so on hardware it is dropped outright. Unguarded, the port +-- layered it and its watery tail outlived the animation. +-- +-- Sound ids order by header address: `DEF \1 EQUS "((\2 - SFX_Headers_1) / 3)"` +-- (constants/music_constants.asm, music_const), so a def's `address` is the +-- comparable rank inside one engine bank -- which is what Sound.playMove +-- compares and what ChipSynth.effectChannels supplies the channel set for. +-- +-- ROM-free: ChipAsm blobs stand in for the sfx headers, so nothing here +-- reads data/generated/. +-- luajit tests/engine/move_sfx_channel_gate_bug844.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +love = require("tests.love_stub") + +-- ------- love.audio stub +-- tests/love_stub carries no love.audio (headless suites never play), and +-- the gate reads Source:isPlaying on the previously accepted row sound. +-- These stub sources never finish on their own, which is exactly the +-- "previous sfx is still sounding" state a mid-animation row sees. +local sources = {} + +local Source = {} +Source.__index = Source +function Source:play() self.playing = true; self.plays = self.plays + 1 end +function Source:stop() self.playing = false end +function Source:isPlaying() return self.playing end +function Source:pause() self.playing = false end +function Source:setLooping(value) self.looping = value end +function Source:setVolume(value) self.volume = value end +function Source:setPitch(value) self.pitch = value end +function Source:setFilter() end +function Source:getDuration() return 1 end + +love.audio = { + newSource = function(what, mode) + local src = setmetatable({ + file = what, mode = mode, plays = 0, playing = false, + }, Source) + sources[#sources + 1] = src + return src + end, +} + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") +local Sound = require("src.core.Sound") +local Runtime = require("src.mods.Runtime") + +-- ------- sfx fixtures +-- One audible note per channel. ChipAsm.sfx numbers effect channels hw+4, +-- so hw 1/2/4 assemble as CHAN5/CHAN6/CHAN8 -- the same software channels +-- the real sfx headers claim. `address` is the sound-id rank and `engine` +-- names the bank the rank is comparable within. +local function sfxDef(address, hws) + local channels = {} + for _, hw in ipairs(hws) do + local program + if hw == 4 then + program = { { noiseNote = { len = 8, volume = 15, fade = 1, + parameter = 0x11 } } } + else + program = { { squareNote = { len = 8, volume = 15, fade = 1, + frequency = 0x600 } } } + end + channels[#channels + 1] = { hw = hw, program = program } + end + local def = ChipAsm.sfx{ channels = channels } + def.address, def.engine = address, 2 + return def +end + +-- Battle_29 = CHAN5,8 at rank 16975; Battle_2A = CHAN5,6,8 at 16981. The +-- ranks below are the same ordering, scaled small for readability. +local defs = { + Blizzard_Sfx = sfxDef(100, { 1, 4 }), -- SFX_BATTLE_29 shape + HydroPump_Sfx = sfxDef(106, { 1, 2, 4 }), -- SFX_BATTLE_2A shape + Disable_Sfx = sfxDef(100, { 4 }), -- SFX_BATTLE_1B shape: CHAN8 + Leer_Sfx = sfxDef(106, { 1, 2 }), -- SFX_BATTLE_31 shape: CHAN5,6 + Loud_Sfx = sfxDef(106, { 1, 4 }), -- a high-ranked incumbent + Quiet_Sfx = sfxDef(100, { 1 }), -- a lower id that takes over + Unranked_Sfx = ChipAsm.sfx{ channels = { { hw = 1, program = { + { squareNote = { len = 8, volume = 15, fade = 1, frequency = 0x600 } }, + } } } }, -- a mod def: no header address +} + +local data = { audio = { sfx = defs, cries = {}, songs = {} } } + +-- the gate is only observable through what actually started, so watch the +-- Runtime event the mod SDK exposes for exactly that +local savedEvents, savedHooks = Runtime.events, Runtime.hooks +local events = require("src.mods.Events").new() +Runtime.install(events, require("src.mods.Hooks").new()) + +local played = {} +events:on("sound.played", function(p) played[#played + 1] = p end, nil, "test") + +local function reset() + Sound.invalidate() -- also clears the tracked row sound + for index = #sources, 1, -1 do sources[index] = nil end + for index = #played, 1, -1 do played[index] = nil end +end + +local function playMove(name) + Sound.playMove(data, { sound = name, pitch = 0, tempo = 0x80 }) +end + +local function names() + local out = {} + for _, p in ipairs(played) do out[#out + 1] = p.name end + return table.concat(out, ",") +end + +-- ------- the channel sets the gate reads +eq(table.concat(ChipSynth.effectChannels(data, defs.Blizzard_Sfx), ","), + "5,8", "effectChannels reads CHAN5+8 off the Blizzard-shaped header") +eq(table.concat(ChipSynth.effectChannels(data, defs.HydroPump_Sfx), ","), + "5,6,8", "effectChannels reads CHAN5+6+8 off the Hydro Pump-shaped header") +check(ChipSynth.effectChannels(data, "assets/beep.wav") == nil, + "a file def has no knowable channel set") + +-- ------- 1. higher id + overlapping channels is dropped (the Blizzard case) +reset() +playMove("Blizzard_Sfx") +check(#sources == 1 and sources[1].playing, "the Blizzard row sound starts") +playMove("HydroPump_Sfx") +eq(#played, 1, "the second Blizzard row is dropped, not layered (" .. names() .. ")") +eq(played[1] and played[1].name, "Blizzard_Sfx", + "the sound that survives is the Blizzard row") +eq(#sources, 1, "the dropped row never even builds a source") +check(sources[1].playing, "the incumbent keeps sounding through the drop") + +-- ------- 2. higher id + disjoint channels still plays (Disable/Leer) +-- The regression guard: SFX_BATTLE_1B is CHAN8 and SFX_BATTLE_31 is +-- CHAN5+6, so nothing is busy and both sounds are heard. +reset() +playMove("Disable_Sfx") +playMove("Leer_Sfx") +eq(#played, 2, "a higher id on disjoint channels is not gated (" .. names() .. ")") +eq(played[2] and played[2].name, "Leer_Sfx", "the second sound is the later row") +check(sources[1].playing and sources[2].playing, + "neither disjoint sound cuts the other off") + +-- ------- 3. a lower id takes the channels over +-- .playChannel zeroes the channel state, which stops whatever held it. +reset() +playMove("Loud_Sfx") +local incumbent = sources[1] +playMove("Quiet_Sfx") +eq(#played, 2, "a lower id is allowed to start (" .. names() .. ")") +check(not incumbent.playing, + "taking CHAN5 over stops the sound that held it") +check(sources[2] and sources[2].playing, "the taking-over sound is playing") + +-- ------- 4. an equal id restarts the sound +-- Repeated rows of one sound (Wrap, Metronome) must not be swallowed. +reset() +playMove("Blizzard_Sfx") +playMove("Blizzard_Sfx") +eq(#played, 2, "the same sound replayed is not gated against itself") +eq(#sources, 1, "the replay reuses the cached source") +eq(sources[1].plays, 2, "the cached source is restarted") +check(sources[1].playing, "and is sounding afterwards") + +-- ------- 5. an unrankable def is left exactly as it was +-- A mod's chip sfx has no header address, so there is no comparable sound +-- id and the gate must not invent one. +reset() +playMove("Blizzard_Sfx") +playMove("Unranked_Sfx") +playMove("Unranked_Sfx") +eq(#played, 3, "unrankable defs play regardless of what is sounding (" + .. names() .. ")") + +-- an unrankable def must also not become an incumbent that gates the next +-- ranked row +reset() +playMove("Unranked_Sfx") +playMove("HydroPump_Sfx") +eq(#played, 2, "an unrankable def gates nothing after it (" .. names() .. ")") + +-- ------- 6. a finished sound gates nothing +reset() +playMove("Blizzard_Sfx") +sources[1].playing = false -- the incumbent ran out +playMove("HydroPump_Sfx") +eq(#played, 2, "a row sound that already ended blocks nothing") + +-- ------- 7. an invalidate (hot reload / cache flush) drops the tracking +-- Sound.invalidate stops and forgets the cached sources; a stale reference +-- would gate the next row against a dead source. +reset() +playMove("Blizzard_Sfx") +Sound.invalidate() +playMove("HydroPump_Sfx") +eq(#played, 2, "invalidate clears the tracked row sound") + +Runtime.install(savedEvents, savedHooks) + +T.finish("move sfx channel gate (#844)") diff --git a/tests/engine/naming_empty_confirm_bug833.lua b/tests/engine/naming_empty_confirm_bug833.lua new file mode 100644 index 00000000..a184b62a --- /dev/null +++ b/tests/engine/naming_empty_confirm_bug833.lua @@ -0,0 +1,126 @@ +-- Empty confirm on the naming screen (#833). DisplayNamingScreen seeds +-- wStringBuffer with '@' (engine/menus/naming_screen.asm), so a name the +-- player never typed reads back as the terminator, and every caller checks +-- that first byte: AskName falls through to .declinedNickname and copies the +-- species name over the nick slot (vanilla's "un-nicknamed", which this port +-- models as mon.nickname == nil, src/save_convert/GenSave.lua), while +-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname. +-- Nothing in the original invents a letter, so NamingScreen:confirm must hand +-- the caller "" rather than the literal "A" when nothing was typed -- both via +-- START and via the ED cell. The two fallbacks that are load bearing stay: +-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never +-- accepts an empty name) and opts.default for the Name Rater cancel. +-- luajit tests/engine/naming_empty_confirm_bug833.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- NamingScreen reaches for Sound at the top of the module; seeding +-- package.loaded before it loads keeps the suite ROM-free and silent. +package.loaded["src.core.Sound"] = { play = function() end } + +local NamingScreen = require("src.ui.NamingScreen") + +-- The three things the screen touches: a stack it pops itself off, an input +-- queue that is exactly one fixed step of edges, and a non-nil `data` for the +-- click cue. +local function newGame() + local game = { data = {} } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + game.input = { + queue = {}, + wasPressed = function(self, btn) return self.queue[btn] or false end, + isDown = function() return false end, + } + return game +end + +-- builds a pushed screen plus a `result` table the onDone writes into +local function newScreen(opts) + local game = newGame() + local result = { fired = false, name = nil } + opts = opts or {} + opts.onDone = function(n) + result.fired = true + result.name = n + end + local ns = NamingScreen.new(game, opts) + game.stack:push(ns) + return ns, game, result +end + +-- one fixed step with `btn` on its edge +local function press(ns, game, btn) + game.input.queue = { [btn] = true } + ns:update(1 / 60) + game.input.queue = {} +end + +-- the ED cell's coordinates on whatever grid the screen is showing +local function edCell(ns) + for r, row in ipairs(ns:grid()) do + for c, cell in ipairs(row) do + if cell == "ED" then return r, c end + end + end + return nil, nil +end + +-- ---------------------------------------------------------------- START, nothing typed +-- The nickname callers (BattleState caught-mon, Commands gift/starter) push +-- the screen with only title/maxLen/onDone: no presets, no default. +local ns, game, res = newScreen({ title = "NICK?", maxLen = 10 }) +press(ns, game, "start") +check(res.fired, "START confirms an untyped name") +eq(res.name, "", "START with nothing typed delivers the empty name") +check(res.name ~= "A", "an untyped confirm does not invent the letter A (#833)") +eq(#game.stack.states, 0, "confirm pops the naming screen") + +-- the caller-shaped guard both nickname sites use +local mon = {} +if res.name and #res.name > 0 then mon.nickname = res.name end +check(mon.nickname == nil, + "an empty name leaves the mon un-nicknamed, so evolution can rename it") + +-- ---------------------------------------------------------------- ED cell, nothing typed +ns, game, res = newScreen({ title = "NICK?", maxLen = 10 }) +local edRow, edCol = edCell(ns) +eq(edRow, 5, "ED sits on row 5 of the vanilla grid (data/text/alphabets.asm)") +eq(edCol, 9, "ED is the last cell of that row") +ns.row, ns.col = edRow, edCol +press(ns, game, "a") +check(res.fired, "A on the ED cell confirms") +eq(res.name, "", "ED with nothing typed delivers the empty name too") + +-- ---------------------------------------------------------------- typed names are untouched +ns, game, res = newScreen({ title = "NICK?", maxLen = 10 }) +ns.row, ns.col = 1, 1 -- "A" +press(ns, game, "a") +press(ns, game, "start") +eq(res.name, "A", "a genuinely typed A still comes back as A") + +-- ---------------------------------------------------------------- presets fallback (player / rival) +-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm) +-- compare wStringBuffer to '@' and re-open rather than accept an empty name; +-- the port answers the same need with its presets fallback, which #833 must +-- not disturb. +ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } }) +press(ns, game, "start") +eq(res.name, "RED", "an empty confirm with presets still yields presets[1]") + +-- ---------------------------------------------------------------- default fallback (Name Rater) +-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the +-- existing nickname; data/scripts/story4.lua passes it as opts.default. +ns, game, res = newScreen({ title = "RATTATA's name?", maxLen = 10, default = "SPLASH" }) +press(ns, game, "start") +eq(res.name, "SPLASH", "an empty confirm with a default keeps the old nickname") + +T.finish("naming_empty_confirm_bug833") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua new file mode 100644 index 00000000..3d68fac2 --- /dev/null +++ b/tests/engine/options_write_readback_bug828.lua @@ -0,0 +1,113 @@ +-- #828: launcher settings "reset" on Android and Steam Deck, with nothing in +-- the log. Every options write is a WHOLE-FILE rewrite out of the caller's +-- table (src/core/SaveData.lua saveOptions), so a filesystem that reports a +-- successful write without the bytes surviving -- an external-storage volume +-- that went away mid-session (conf.lua sets t.externalstorage on Android), a +-- read-only or full save dir -- is indistinguishable from "the launcher never +-- saved at all". saveOptions therefore reads the file back and fails loudly. +-- +-- This suite pins that contract against injected filesystem stubs, the same +-- { getInfo, read, write, remove } shape tests/engine/save_slots.lua and +-- tests/engine/save_file_io_tests.lua use. It is ROM-free (T2 engine tier). +-- +-- What it does NOT do: prove #828 is fixed. The launcher -> options.lua -> +-- bootGame chain already round-trips correctly on desktop, so the readback is +-- instrumentation for the two platforms that report the loss, and the real +-- verification is a platform run (see the issue). What is testable here is +-- that a silent no-op write is now reported instead of swallowed. +-- luajit tests/engine/options_write_readback_bug828.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local Logger = require("src.core.Logger") +local SaveData = require("src.core.SaveData") + +local OPTIONS = "options.lua" + +-- An in-memory love.filesystem stub. `mode` decides what write() does with +-- the bytes AFTER reporting success, which is the whole point of the suite: +-- "honest" -- stores them (a working save dir) +-- "drop" -- reports true, stores nothing (the volume vanished) +-- "truncate" -- reports true, stores a short prefix (a full save dir) +-- "fail" -- reports false plus an error string (the pre-existing path) +local function memfs(mode) + local files = {} + return { + files = files, + write = function(path, content) + if mode == "fail" then return false, "no space left on device" end + if mode == "drop" then return true end + if mode == "truncate" then + files[path] = tostring(content):sub(1, 16) + return true + end + files[path] = content + return true + end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } +end + +-- SaveData.persistFs hands an injected fs straight back only when it differs +-- from love.filesystem, so the suite never touches the real save directory. +local function logged(pattern) + for i = #Logger.history, 1, -1 do + if Logger.history[i]:find(pattern, 1, true) then return Logger.history[i] end + end + return nil +end + +-- ---- the write that lands: unchanged success contract + +local fs = memfs("honest") +local saved = SaveData.saveOptions({ battleLayout = "wide" }, fs) +check(saved ~= nil, "a write that lands returns the merged options table") +eq(saved and saved.battleLayout, "wide", "the caller's key survives the merge") +eq(saved and saved.textSpeed ~= nil, true, "defaults are filled in around it") +check(fs.files[OPTIONS] ~= nil, "options.lua is written to the injected fs") + +local loaded = SaveData.loadOptions(fs) +eq(loaded and loaded.battleLayout, "wide", + "loadOptions reads back what saveOptions wrote (the launcher -> game hop)") + +-- ---- the write that silently does not land: the #828 failure mode + +local dropMark = #Logger.history +local dropped = SaveData.saveOptions({ battleLayout = "wide" }, memfs("drop")) +eq(dropped, nil, "a write that reports success but stores nothing returns nil") +check(logged("options save did not land"), + "the vanished write is logged, so the next Android report can carry it") +check(#Logger.history > dropMark, "a log line was actually emitted") + +-- ---- a partial write is just as lost, and just as loud + +local truncated = SaveData.saveOptions({ battleLayout = "wide" }, memfs("truncate")) +eq(truncated, nil, "a truncated write is treated as a failed write") +check(logged("options save did not land"), "the truncated write is logged too") + +-- ---- the pre-existing honest failure still behaves exactly as before + +local failMark = #Logger.history +local failed = SaveData.saveOptions({ battleLayout = "wide" }, memfs("fail")) +eq(failed, nil, "a write that returns false still returns nil") +check(logged("options save failed"), + "the false-return path keeps its own distinct log line") +check(#Logger.history > failMark, "the false-return path still logs") + +-- A dropped write must not be reported through the false-return message: +-- the two are different diagnoses and the platform reports need to tell +-- them apart. +local last = Logger.history[#Logger.history] +check(last and last:find("options save failed", 1, true) ~= nil, + "the last failure logged is the false-return one, not the readback one") + +T.finish("options_write_readback_bug828") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua new file mode 100644 index 00000000..6aee10cf --- /dev/null +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -0,0 +1,198 @@ +-- A RARE CANDY used from the field bag must leave the bag open (#796). +-- +-- engine/menus/start_sub_menus.asm, StartMenu_Item / .useOrTossItem sorts the +-- chosen item with IsInArray against UsableItems_CloseMenu first and +-- UsableItems_PartyMenu second. RARE_CANDY is in the party-menu array +-- (data/items/use_party.asm), so it reaches .useItem_partyMenu, which after +-- `call UseItem` -- when wActionResultOrTookBattleTurn is not $02 -- +-- restores the screen and `jp StartMenu_Item`, i.e. re-enters the item list +-- instead of CloseStartMenu. StartMenu_Item reloads wBagSavedMenuItem into +-- wCurrentMenuItem before DisplayListMenuID, so the cursor comes back on the +-- row you just used: that is what lets a stack of candies be mashed through. +-- engine/items/item_effects.asm ItemUseVitamin .useRareCandy ends with +-- RedrawPartyMenu / PrintStatsBox / WaitForTextScrollButtonPress / +-- LearnMoveFromLevelUp / TryEvolvingMon and `jp RemoveUsedItem` -- it never +-- whites out and never closes the start menu. Only .useItem_closeMenu items +-- (UsableItems_CloseMenu: bike, escape rope, rods) jump to CloseStartMenu. +-- +-- The port popped the bag ListMenu at the head of the leveledTo branch, which +-- also skipped the "xN" refresh below it, so the level text played over the +-- overworld and the player was dumped out of the menu per candy. +-- luajit tests/engine/rare_candy_bag_open_bug796.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Lazily-required inside the use branches, so seeding package.loaded before +-- the UI modules load is enough to keep the suite silent and love-free. +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, +} +-- Real TextBoxes want a Font atlas. This flow only cares that a message +-- opened, what it says, and what its onDone does. +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +-- BagMenu and PartyMenu bind TextBox at require time, so they load against +-- the stub; Screens caches its factory per id and must be told to forget. +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +require("src.ui.Screens").invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Pokemon = require("src.pokemon.Pokemon") + +local Data = Fixtures.fresh() +-- The fixture item table has no candy of its own; ItemEffects keys the +-- level-up branch on the id, and BagMenu only reads name/keyItem off the def. +Data.items.RARE_CANDY = { + id = "RARE_CANDY", index = 90, name = "RARE CANDY", price = 4800, + tossable = true, +} + +-- The mon: FIXMON_C has an empty `evolutions` and a learnset that stops at +-- level 1, so the 5 -> 6 candy prints its line and nothing else follows it. +-- That keeps the assertions on the bag rather than on the stat box, which +-- would need real graphics. +local function freshGame(candies) + local mon = Pokemon.new(Data, "FIXMON_C", 5) + local game = { + data = Data, + save = { + party = { mon }, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + -- one button edge per update, the way Input reports a fixed step + game.input = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + -- FIX POTION first so the candy is never row 1: an index that silently + -- reset to the top would otherwise pass the cursor assertion by accident + Bag.add(game.save, "FIX_POTION", 1) + Bag.add(game.save, "RARE_CANDY", candies) + return game, mon +end + +local function isPicker(s) return getmetatable(s) == PartyMenu end +local function isBox(s) return type(s) == "table" and s.textBox == true end + +local function inStack(stack, pred) + for _, s in ipairs(stack.states) do + if pred(s) then return true end + end + return false +end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +-- Open the bag, put the cursor on `id`, choose it, take USE off the +-- USE/TOSS box, then press A on the party picker. Returns the bag list. +local function useFromBag(game, battle, id) + local list = BagMenu.new(game, { battle = battle }) + game.stack:push(list) + local row = rowFor(list, id) + if not row then return nil, "no " .. id .. " row in the bag" end + list.index = row + list.onChoose(list.items[row], list) + local sub = game.stack:top() + if not battle and sub and sub.items and sub.items[1] + and sub.items[1].onSelect then + game.stack:pop() -- the USE/TOSS Menu pops itself on select + sub.items[1].onSelect() + end + local picker = game.stack:top() + if not isPicker(picker) then return nil, "party picker never opened" end + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + return list +end + +-- The bug: three candies in the bag, use one in the field. +do + local game, mon = freshGame(3) + local list, why = useFromBag(game, nil, "RARE_CANDY") + if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then + eq(mon.level, 6, "the candy leveled the mon 5 -> 6") + check(not inStack(game.stack, isPicker), + "the pickOnly picker popped itself before onSwitch") + check(inStack(game.stack, function(s) return s == list end), + "the bag list is STILL on the stack (.useItem_partyMenu re-enters " + .. "StartMenu_Item, it does not CloseStartMenu) (#796)") + + local row = rowFor(list, "RARE_CANDY") + if check(row ~= nil, "the RARE CANDY row survived the use") then + eq(list.items[row].right, "x2", "and its count followed the inventory") + eq(list.index, row, "with the cursor left on it (wBagSavedMenuItem), " + .. "so the next candy is one A press away") + end + eq(game.save.inventory.RARE_CANDY, 2, "one candy was consumed") + + local box = game.stack:top() + if check(isBox(box), "the grew-to-level line prints over the open bag") then + check(box.text:find("level 6", 1, true) ~= nil, + "and it names the new level: " .. tostring(box.text)) + end + end +end + +-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the +-- cursor clamps to a real row -- but the list itself still must not close. +do + local game = freshGame(1) + local list = useFromBag(game, nil, "RARE_CANDY") + if check(list ~= nil, "the bag reached the picker with a single candy") then + check(inStack(game.stack, function(s) return s == list end), + "the last candy does not close the bag either (#796)") + check(rowFor(list, "RARE_CANDY") == nil, "its row was removed") + eq(game.save.inventory.RARE_CANDY, nil, "and the slot is empty") + check(list.index >= 1 and list.index <= #list.items, + "the cursor clamped to a valid row (index " .. tostring(list.index) + .. " of " .. #list.items .. ")") + end +end + +-- Boundary the fix must not have moved: a candy is refused mid-battle, so the +-- field behavior above can never be mistaken for a battle regression. +-- item_effects.asm:800-803, ItemUseVitamin reads wIsInBattle and +-- `jp nz, ItemUseNotTime` before it ever falls into ItemUseMedicine, which is +-- why the port's battle-side branch is a guard rather than a live path. A +-- bare table stands in for the battle: nothing on this route reads it. +do + local game, mon = freshGame(3) + local list = useFromBag(game, { fakeBattle = true }, "RARE_CANDY") + if check(list ~= nil, "the battle bag reached the picker") then + eq(mon.level, 5, "a RARE CANDY mid-battle levels nothing (ItemUseVitamin " + .. "-> ItemUseNotTime)") + eq(game.save.inventory.RARE_CANDY, 3, "and is not consumed") + local box = game.stack:top() + if check(isBox(box), "the refusal prints") then + check(box.text:find("time to use", 1, true) ~= nil, + "with ItemUseNotTime's line: " .. tostring(box.text)) + end + end +end + +T.finish() diff --git a/tests/parity_applying_attack_anim.lua b/tests/parity_applying_attack_anim.lua index fc11dd56..4cb22bed 100644 --- a/tests/parity_applying_attack_anim.lua +++ b/tests/parity_applying_attack_anim.lua @@ -90,7 +90,14 @@ end -- type-4 turn can still use it do local _, _, rows = typeOf("BUBBLEBEAM", true) - eq(rows[1].sfx, "Damage", "the row carries the damage sound") + -- PlayApplyingAttackSound sets wFrequencyModifier alongside the sound + -- ($20 for SFX_DAMAGE), and the noise channel's polynomial counter IS + -- that modifier, so the row carries both now (#826) + eq(type(rows[1].sfx) == "table" and rows[1].sfx.sound, "Damage", + "the row carries the damage sound") + eq(rows[1].sfx.pitch, 0x20, "with its PlayApplyingAttackSound pitch byte") + eq(rows[1].sfx.tempo, nil, + "and no tempo byte: Audio2_note_length skips the sfx tempo on CHAN8") local _, _, plain = typeOf("TACKLE", true) check(plain[1].blink ~= nil, "a type-4 row carries the pic to blink") end diff --git a/tests/parity_escape_rope_bug805.lua b/tests/parity_escape_rope_bug805.lua new file mode 100644 index 00000000..f1025b1c --- /dev/null +++ b/tests/parity_escape_rope_bug805.lua @@ -0,0 +1,131 @@ +-- Parity test (#805): ESCAPE ROPE / DIG / TELEPORT must land on an outdoor +-- fly-warp cell, and must re-point the LAST_MAP memory at it. +-- +-- pret: ItemUseEscapeRope (engine/items/item_effects.asm) sets BIT_FLY_WARP +-- and BIT_ESCAPE_WARP, and LoadSpecialWarpData's .usedFlyWarp path +-- (engine/overworld/special_warps.asm) warps to wLastBlackoutMap with the +-- landing cell read from FlyWarpDataPtr. wLastBlackoutMap is ALWAYS an +-- outdoor map: SetLastBlackoutMap (engine/events/set_blackout_map.asm) +-- copies wLastMap, and WarpFound2 (home/overworld.asm) only writes wLastMap +-- when CheckIfInOutsideMap passes. PrepareForSpecialWarp +-- (engine/overworld/special_warps.asm) then does `ld [wLastMap], a` with +-- that destination for every fly/escape warp that is not a dungeon warp. +-- +-- The port broke both halves. A .sav import stamps lastHeal from wherever +-- the cartridge was saved (src/save_convert/SaveConvert.lua mergeDefaults, +-- which records no outdoor), so a save made inside Seafoam Islands made +-- ESCAPE ROPE warp the player back into that cave; and the teleport branch +-- skipped rememberOutdoor, so the first LAST_MAP exit after the rope still +-- resolved against the dungeon door walked in through. +-- +-- Self-contained; run via `luajit tests/parity_escape_rope_bug805.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.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity escape rope #805") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local Pokemon = require("src.pokemon.Pokemon") +local Map = require("src.world.Map") +local FieldDefaults = require("src.world.FieldDefaults") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() +Game.save = SaveData.newGame() +Game.save.party = { Pokemon.new(Data, "SQUIRTLE", 20) } + +-- The player is deep in a cave, having walked in from the outdoor door +-- cell the LAST_MAP exits still point at. +Game.stack:push(OW, "SEAFOAM_ISLANDS_B2F", 5, 5, "down") +local ow = Game.stack:top() +Game.overworld = ow + +-- Capture the warp target instead of running the real Transition. +local dest +local realStart = ow.startWarpTo +ow.startWarpTo = function(self, mapId, x, y, facing, onDone, opts) + dest = { map = mapId, x = x, y = y } + self.arriveWarp = nil + self.transitioning = false +end + +local outsideTilesets = FieldDefaults.field(Data, "outsideTilesets") +local function isOutside(mapId) + local def = Data.maps[mapId] + return def ~= nil and Map.isOutside(def, outsideTilesets) +end + +local flyWarps = Data.field.flyWarps or {} +local bootHeal = SaveData.defaultHeal(Data.field.boot) + +-- --------------------------------------------------------------- 1. healthy +-- A save healed by a nurse records the outdoor town alongside the interior +-- heal cell; the rope lands on that town's FlyWarpDataPtr cell. +Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3, + outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } } +ow:rememberOutdoor("ROUTE_23", 8, 60) -- the Victory Road door walked in from +dest = nil +ow:warpToHealPoint(nil, { arrive = "teleport" }) + +check(flyWarps.VIRIDIAN_CITY ~= nil, "Viridian City has a fly warp cell") +eq(dest.map, "VIRIDIAN_CITY", "healthy heal record: rope lands on the town") +eq(dest.x, flyWarps.VIRIDIAN_CITY.x, "rope lands on the FlyWarpDataPtr x") +eq(dest.y, flyWarps.VIRIDIAN_CITY.y, "rope lands on the FlyWarpDataPtr y") + +-- 3. PrepareForSpecialWarp: the destination becomes the new wLastMap, so a +-- LAST_MAP exit taken after the rope resolves against the town just landed +-- in, not the dungeon door from before. +eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY", + "teleport warp re-points wLastMap at the destination (#805)") +eq(Game.save.lastOutdoor.x, dest.x, "wLastMap x follows the landing cell") +eq(Game.save.lastOutdoor.y, dest.y, "wLastMap y follows the landing cell") + +-- --------------------------------------------------------------- 2. imported +-- Exactly what SaveConvert stamps for a cartridge save made in a cave: the +-- player's own cell, no outdoor town. wLastBlackoutMap can never name an +-- indoor map, so this record is unusable and falls back to the boot heal +-- town (vanilla's zero-filled wLastBlackoutMap is map 0, Pallet Town). +Game.save.lastHeal = { map = "SEAFOAM_ISLANDS_B2F", x = 5, y = 5 } +ow:rememberOutdoor("ROUTE_23", 8, 60) +dest = nil +ow:warpToHealPoint(nil, { arrive = "teleport" }) + +check(not isOutside("SEAFOAM_ISLANDS_B2F"), + "Seafoam Islands B2F is not an outside map") +check(dest.map ~= "SEAFOAM_ISLANDS_B2F", + "imported heal record does not dump the rope back in the cave (#805)") +check(isOutside(dest.map), "escape-warp destination is always an outside map") +eq(dest.map, bootHeal.map, "unusable heal record falls back to the boot town") +eq(dest.x, bootHeal.x, "boot-town fallback keeps its landing x") +eq(dest.y, bootHeal.y, "boot-town fallback keeps its landing y") +eq(Game.save.lastOutdoor.id, bootHeal.map, + "fallback landing is remembered as wLastMap too") + +-- --------------------------------------------------------------- 3. blackout +-- A blackout (no opts) still lands on the interior heal cell and re-points +-- LAST_MAP exits at the remembered town door: HandleBlackOut never sets +-- BIT_FLY_WARP, so it is not a special warp destination of its own. +Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3, + outdoor = { id = "VIRIDIAN_CITY", x = 23, y = 27 } } +ow:rememberOutdoor("ROUTE_23", 8, 60) +dest = nil +ow:warpToHealPoint() + +eq(dest.map, "VIRIDIAN_POKECENTER", "blackout still lands at the heal cell") +eq(Game.save.lastOutdoor.id, "VIRIDIAN_CITY", + "blackout re-points wLastMap at the remembered town door") +eq(Game.save.lastOutdoor.x, 23, "blackout keeps the recorded door x") +eq(Game.save.lastOutdoor.y, 27, "blackout keeps the recorded door y") + +ow.startWarpTo = realStart +S.finish() diff --git a/tests/parity_gym_tm_bag_full_bug797.lua b/tests/parity_gym_tm_bag_full_bug797.lua new file mode 100644 index 00000000..e0101d13 --- /dev/null +++ b/tests/parity_gym_tm_bag_full_bug797.lua @@ -0,0 +1,154 @@ +-- Parity test: gym leader TM award respects the bag cap (#797). +-- +-- scripts/PewterGym.asm, PewterGymScriptReceiveTM34: after +-- SetEvent EVENT_BEAT_BROCK it runs `lb bc, TM_BIDE, 1` / `call GiveItem` +-- / `jr nc, .BagFull`. On success it prints TEXT_PEWTERGYM_RECEIVED_TM34 +-- (and the TM34 explanation); on carry-clear it prints +-- TEXT_PEWTERGYM_TM34_NO_ROOM instead. Both paths fall through to +-- .gymVictory, so the badge lands either way and the TM is simply lost. +-- The same `jr nc, .BagFull` shape is in CeruleanGym.asm, VermilionGym.asm, +-- CeladonGym.asm, FuchsiaGym.asm, SaffronGym.asm, CinnabarGym.asm and +-- ViridianGym.asm. +-- +-- The port drives this through OverworldState:checkVictoryRewards over +-- data/scripts/victories.lua (gym leaders are not def_trainers entries, so +-- src/script/Commands.lua give_item -- which has always handled a full bag +-- -- is never on this path). checkVictoryRewards used to write the TM +-- straight into save.inventory, bypassing Bag.add's BAG_ITEM_CAPACITY +-- check and producing a 21-of-20 bag while still printing "received TM". +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.PALLET_TOWN) then Data:load() end +local S = require("tests.harness").suite("parity gym TM bag full #797") +local check, eq = S.check, S.eq + +local Bag = require("src.inventory.Bag") +local victories = require("data.scripts.victories") + +-- === (1) data shape: every gym reward carries both branches === +-- A future gym edit must not silently drop the alternate line, so the +-- table check is cheap insurance over the eight leader entries. +do + local n = 0 + for key, entry in pairs(victories) do + if entry.badge then + n = n + 1 + check(type(entry.itemDialogue) == "table" and #entry.itemDialogue > 0, + key .. " has an itemDialogue tail (the GiveItem-succeeded text)") + check(type(entry.bagFull) == "string", + key .. " names a bagFull text (.BagFull branch)") + local body = entry.bagFull and (Data.text or {})[entry.bagFull] + check(type(body) == "string" and body ~= "", + key .. " bagFull label resolves to extracted text") + -- the received-TM tail must not still be baked into `dialogue`, + -- or the full-bag path would print it anyway + for _, label in ipairs(entry.dialogue or {}) do + for _, tail in ipairs(entry.itemDialogue or {}) do + check(label ~= tail, + key .. " dialogue no longer repeats " .. tostring(tail)) + end + end + end + end + eq(n, 8, "all eight gym leader rewards checked") +end + +-- === (2) behavior: Brock with a full bag vs an empty one === + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local OW = require("src.world.OverworldController") + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() + +-- concatenates the pages of the TextBox checkVictoryRewards pushed +local function stackedDialogue() + local top = Game.stack:top() + if not (top and top.pages) then return "" end + local parts = {} + for _, page in ipairs(top.pages) do + parts[#parts + 1] = table.concat(page, "\n") + end + return table.concat(parts, "\n") +end + +local function freshSave() + while Game.stack:top() do Game.stack:pop() end + Game.save = SaveData.newGame() + Game.save.flags = {} + Game.save.inventory = {} + Game.save.bagOrder = nil + Game.save.defeatedTrainers = {} +end + +-- --- full bag: the TM is refused, the NoRoom line replaces the TM text --- +freshSave() +local cap = Bag.capacity(Data) +-- Bag.slots only counts non-badge ids, so distinct filler ids fill it +for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end +eq(Bag.slots(Game.save), cap, "bag starts at BAG_ITEM_CAPACITY") + +Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") +local ow = Game.stack:top() +ow:checkVictoryRewards("OPP_BROCK", 1) +local fullText = stackedDialogue() + +check(Game.save.inventory.TM_BIDE == nil, + "full bag: TM_BIDE is refused (GiveItem carry clear)") +eq(Bag.slots(Game.save), cap, + "full bag: no 21st slot appears (the reporter's symptom)") +check(Game.save.inventory.BOULDERBADGE == 1, + "full bag: .gymVictory still awards BOULDERBADGE") +check(Game.save.flags.EVENT_BEAT_BROCK, + "full bag: EVENT_BEAT_BROCK is still set") +check(fullText:find("room for this", 1, true) ~= nil, + "full bag: dialogue prints _PewterGymTM34NoRoomText") +check(fullText:find("BIDE", 1, true) == nil, + "full bag: the TM34 explanation is skipped") +check(fullText:find("FLASH", 1, true) ~= nil, + "full bag: the BoulderBadge speech still runs") + +-- --- empty bag: the success tail still appends and the TM lands --- +freshSave() +Game.stack:push(OW, "PEWTER_GYM", 4, 13, "up") +ow = Game.stack:top() +ow:checkVictoryRewards("OPP_BROCK", 1) +local okText = stackedDialogue() + +check(Game.save.inventory.TM_BIDE == 1, + "empty bag: TM_BIDE lands in the bag") +local order = Bag.order(Game.save) +check(order[1] == "TM_BIDE", + "empty bag: Bag.add kept the wBagItems order (bagOrder) honest") +check(okText:find("BIDE", 1, true) ~= nil, + "empty bag: itemDialogue (TM34 explanation) still appends") +check(okText:find("room for this", 1, true) == nil, + "empty bag: the NoRoom line is not printed") + +-- --- one more leader, to prove the split is not Pewter-only --- +freshSave() +for i = 1, cap do Game.save.inventory["FILLER" .. i] = 1 end +Game.stack:push(OW, "CERULEAN_GYM", 4, 10, "up") +ow = Game.stack:top() +ow:checkVictoryRewards("OPP_MISTY", 1) +local mistyText = stackedDialogue() +check(Game.save.inventory.TM_BUBBLEBEAM == nil, + "Misty full bag: TM_BUBBLEBEAM is refused") +check(Game.save.inventory.CASCADEBADGE == 1, + "Misty full bag: CASCADEBADGE still awarded") +eq(Bag.slots(Game.save), cap, "Misty full bag: still at capacity") +check(mistyText:find((Data.text or {})._CeruleanGymMistyTM11NoRoomText + :match("^[^\n]+") or "\1", 1, true) ~= nil, + "Misty full bag: dialogue prints _CeruleanGymMistyTM11NoRoomText") + +while Game.stack:top() do Game.stack:pop() end + +S.finish() diff --git a/tests/parity_hof.lua b/tests/parity_hof.lua index 0f2b6d41..0f427eea 100644 --- a/tests/parity_hof.lua +++ b/tests/parity_hof.lua @@ -116,16 +116,20 @@ local HallOfFame = require("src.ui.HallOfFame") check(getmetatable(stack2:top()) == HallOfFame, "induction showcase pushed") -- Gen1 layout (issue #102): pic rests at hlcoord (12,5); mon phase starts --- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone) +-- with the LEVEL/TYPE info box (not a top "HALL OF FAME" banner alone). +-- HoFShowMonOrPlayer sweeps the BACK pic across the screen first (hSCX +-- $c0 -> $a0) and only then scrolls the front pic in (#847), so the +-- induction opens on the back pass. local hofUi = stack2:top() -eq(hofUi.phase, "mons", "induction opens on the mon showcase phase") -eq(hofUi.scrollX < 12 * 8, true, "front pic starts off-screen left of (12,5)") --- drive past the scroll so the info box is armed +eq(hofUi.phase, "back", "induction opens on the back pic sweep (#847)") +eq(hofUi.scrollX, 160, "back pic enters at the right edge (hSCX = $c0)") +-- drive the back sweep and the front scroll so the info box is armed local scrollGuard = 0 -while hofUi.scrollX < 12 * 8 and scrollGuard < 200 do +while (hofUi.phase == "back" or hofUi.scrollX < 12 * 8) and scrollGuard < 400 do scrollGuard = scrollGuard + 1 hofUi:update(1 / 60) end +eq(hofUi.phase, "mons", "the front pic phase follows the back sweep (#847)") eq(hofUi.scrollX, 12 * 8, "front pic settles at hlcoord (12,5)") eq(hofUi.showHofBanner, false, "bottom HALL OF FAME banner waits for the 80-frame hold") check(hofUi.timer == 80 or hofUi.timer < 80, diff --git a/tests/parity_surf_clears_bike_bug846.lua b/tests/parity_surf_clears_bike_bug846.lua new file mode 100644 index 00000000..8b8e1ea0 --- /dev/null +++ b/tests/parity_surf_clears_bike_bug846.lua @@ -0,0 +1,187 @@ +-- Parity test: getting on SURF ends the bike (#846). +-- +-- pokered keeps walking / biking / surfing in ONE state byte, +-- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm) +-- copies the old state aside, refuses when it is already 2, and on a +-- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change +-- player state to surfing` followed by PlayDefaultMusic -- the bike state +-- is overwritten, so no bike can survive a surf: not its 8-frame step +-- cadence, not its theme. The port splits that byte into two independent +-- flags (Game.save.onBike and player.surfing) and nothing used to clear +-- the first when the second went up, so a player who surfed off the bike +-- paddled at bike speed with Music_BikeRiding still playing. +-- +-- The mirror direction is explicit in the same asm file: ItemUseBicycle +-- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? / +-- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water. +-- +-- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.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.PALLET_TOWN) then Data:load() end + +local S = require("tests.harness").suite("parity surf clears bike") +local check, eq = S.check, S.eq + +require("src.render.Font").load(Data) +local Game = require("src.core.Game") +local Input = require("src.core.Input") +local ItemEffects = require("src.inventory.ItemEffects") +local Music = require("src.core.Music") +local Pokemon = require("src.pokemon.Pokemon") +local Renderer = require("src.render.Renderer") +local SaveData = require("src.core.SaveData") +local StateStack = require("src.core.StateStack") +local OW = require("src.world.OverworldController") + +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 + +-- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox +-- upvalue for a stub and never puts it back, and run_tests.lua runs every +-- parity suite from one file: point it back at the real module so trySurf +-- pushes a real box here (same guard as parity_field_move_layering.lua). +local function setUpvalue(fn, name, val) + local i = 1 + while true do + local n = debug.getupvalue(fn, i) + if not n then return false end + if n == name then debug.setupvalue(fn, i, val); return true end + i = i + 1 + end +end +setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox")) + +local function frame(btns) + Input.pressed = {} + for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end + StateStack:update(1 / 60) + for _, b in ipairs(btns or {}) do Input.state[b] = false end +end + +local function popAll() while Game.stack:top() do Game.stack:pop() end end + +local function pushOW(mapId, x, y, facing) + popAll() + Game.stack:push(OW, mapId, x, y, facing) + return Game.stack:top() +end + +local function mkMon(species, ...) + local m = Pokemon.new(Data, species, 20) + m.moves = {} + for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end + return m +end + +-- Music.play no-ops on the headless audio stub, so the song the bike/surf +-- override actually resolves to is only observable by intercepting it +-- (the Music.playMap stub pattern in parity_seam_walk_anim.lua) +local playedSong +local realPlay = Music.play +Music.play = function(_, song) playedSong = song end + +local bikeSong = Music.special(Data, "bike") +local surfSong = Music.special(Data, "surf") +check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong, + "the bike and surf themes are two distinct songs") + +-- ===================================================================== +-- control: on land, onBike really does buy the halved step cadence, so +-- the assertion after the mount below is not vacuous +-- ===================================================================== +local ow = pushOW("PALLET_TOWN", 5, 6, "up") +local p = ow.player +eq(p.stepFrames, 16, "a walking step is 16 frames") +eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)") +Game.save.onBike = true +p.turnTimer = 0 +p.stepFramesCur = nil +eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell") +eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land") + +-- ===================================================================== +-- the mount: bike state is gone the moment the got-on text closes, and +-- the first step onto the water is a WALK-length step, not a bike one +-- ===================================================================== +ow = pushOW("PALLET_TOWN", 4, 13, "down") +p = ow.player +p.surfing = false +Game.save.onBike = true +Game.save.party = { mkMon("SQUIRTLE", "SURF") } +-- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF") +-- refuses and trySurf never prints anything +Game.save.inventory.SOULBADGE = true +check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here") +check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)") + +-- what is playing when the mount starts: the bike override over the +-- outdoor Pallet theme +Music.playMap(Data, "PALLET_TOWN", true, false) +eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet") + +p.stepFramesCur = nil +ow:trySurf(4, 14, nil) +local box = Game.stack:top() +check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText") +local guard = 0 +while Game.stack:top() == box and guard < 400 do + guard = guard + 1 + frame({ "a" }) +end +check(Game.stack:top() ~= box, "the got-on text closes") + +eq(p.surfing, true, "the mount raises the surf state") +eq(Game.save.onBike, false, + "ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)") +eq(playedSong, surfSong, + "PlayDefaultMusic after the mount picks the surf theme, not the bike theme") + +-- the blink carries the mount forward onto the water; let that scripted +-- step land (it is queued through scriptMove, which drives the entity +-- directly and never touches Player:tryMove) +guard = 0 +while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do + guard = guard + 1 + frame({}) +end +eq(Game.stack:top(), ow, "the mount ends back on the map") +eq(p.cellY, 14, "the mount steps forward onto the water") + +-- the symptom in #846: the first paddled step the player takes. It runs +-- through Player:tryMove, which reads Game.save.onBike for its step +-- length -- a stale bike flag paddles at 8 frames per cell. +check(ow.map:isWaterCell(4, 15), "the next cell south is water too") +p.turnTimer = 0 +p.stepFramesCur = nil +eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)") +eq(p.stepFramesCur, p.stepFrames, + "the paddled step uses the walk cadence, the exact symptom in #846") +check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water") + +-- ===================================================================== +-- the mirror hole: the bag cannot put the bike back on under a surfer +-- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns +-- by another route +-- ===================================================================== +local save = SaveData.newGame() +local surfingOw = { player = { surfing = true } } +local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw) +eq(result, "failed", "the BICYCLE is refused while surfing") +check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path") +check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil, + "the surfing BICYCLE refusal uses the OAK 'not the time' text") + +local landOw = { player = { surfing = false } } +eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle", + "the BICYCLE still mounts normally on land") + +Music.play = realPlay +popAll() +S.finish() diff --git a/tests/rom_importer_last_version_test.lua b/tests/rom_importer_last_version_test.lua new file mode 100644 index 00000000..07e3543d --- /dev/null +++ b/tests/rom_importer_last_version_test.lua @@ -0,0 +1,89 @@ +-- #835: the launcher must open on the game that was played last, instead of +-- always opening on Red. Two halves, both asserted here: RomImporter:play +-- writes the chosen version to options.lua, and RomImporter:_applyLastVersionTab +-- reads it back when the constructor has finished filling self.ready. +-- Self-contained: `luajit tests/rom_importer_last_version_test.lua`; also +-- dofile'd by tests/run_tests.lua. +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer last version") +local eq = S.eq + +love.mouse.isCursorSupported = function() return false end + +local SaveData = require("src.core.SaveData") +local LaunchOptions = require("src.core.LaunchOptions") +local RomImporter = require("src.import.RomImporter") + +-- The options round trip must not touch the developer's real save directory. +-- SaveData.persistFs consults SaveData.portableFs() before love.filesystem +-- (src/core/SaveData.lua:203-208), so overriding that one hook reroutes both +-- loadOptions and saveOptions onto this in-memory volume. saveOptions reads +-- the file back after writing (#828), so read/write have to be truthful. +-- The override is process-global and tests/run_tests.lua dofiles every suite +-- into one process, so it MUST be put back before this file returns: leaving +-- it in place reroutes every later suite's save I/O into `disk` (parity_hof +-- reads its own save back and fails 6 assertions if this leaks). +local realPortableFs = SaveData.portableFs +local disk = {} +SaveData.portableFs = function() + return { + getInfo = function(name) return disk[name] and { type = "file" } or nil end, + read = function(name) return disk[name] or nil, "no file: " .. name end, + write = function(name, data) disk[name] = data return true end, + remove = function(name) disk[name] = nil end, + } +end + +local function newImporter(fields) + local ri = setmetatable(fields, RomImporter) + return ri +end + +-- ---- write side: play() records the version it hands off to boot + +local booted = nil +local ri = newImporter({ + android = true, -- skips the cursor restore; see #114 suite + workState = nil, + tab = "red", + ready = { yellow = true }, + onComplete = function(version) booted = version end, +}) +ri:play("yellow") +eq(booted, "yellow", "play boots the chosen version") +eq(SaveData.loadOptions().lastVersion, "yellow", "play remembers the version played") + +-- ---- read side: a fresh launcher opens on that column + +local ri2 = newImporter({ tab = "red", ready = { red = true, yellow = true } }) +ri2:_applyLastVersionTab() +eq(ri2.tab, "yellow", "launcher opens on the last played version") + +-- A remembered version whose cache is gone or stale must not open a column +-- with no Play button in it. +local ri3 = newImporter({ tab = "red", ready = { red = true, yellow = false } }) +ri3:_applyLastVersionTab() +eq(ri3.tab, "red", "an unready remembered version leaves the tab alone") + +-- An explicit --game shortcut (main.lua sets LaunchOptions.pendingTab) wins +-- over the remembered version. +LaunchOptions.pendingTab = "blue" +local ri4 = newImporter({ tab = "blue", ready = { red = true, yellow = true } }) +ri4:_applyLastVersionTab() +eq(ri4.tab, "blue", "an explicit --game tab beats the remembered version") +LaunchOptions.pendingTab = nil -- module is a singleton: do not leak this + +-- A junk value in options.lua (hand-edited file, a build that knew other +-- versions) must not select a tab that does not exist. +local opts = SaveData.loadOptions() +opts.lastVersion = "gold" +SaveData.saveOptions(opts) +local ri5 = newImporter({ tab = "red", ready = { red = true, yellow = true } }) +ri5:_applyLastVersionTab() +eq(ri5.tab, "red", "an unknown remembered version leaves the tab alone") + +SaveData.portableFs = realPortableFs + +S.finish() diff --git a/tests/run_tests.lua b/tests/run_tests.lua index b09a9182..a5d5517d 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -3400,6 +3400,9 @@ runSuites({ "tests/input_hold_test.lua" }) -- ---------------------------------------------- launcher cursor (#114) runSuites({ "tests/rom_importer_cursor_test.lua" }) +-- ---------------------------------------------- launcher last played tab (#835) +runSuites({ "tests/rom_importer_last_version_test.lua" }) + -- ---------------------------------------------- Android second ROM pick (#167) runSuites({ "tests/rom_importer_android_pick_test.lua" }) From 24c5114745a04b3f2e9e1607231af1d21e5c494b Mon Sep 17 00:00:00 2001 From: ratherDashing <1879051+ratherDashing@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:39:48 -0400 Subject: [PATCH 05/12] Ship a Linux arm64 (aarch64) AppImage scripts/build.sh's `linux` target only ever produces x86_64: it unpacks LOVE's official love-11.5-x86_64.AppImage and re-fuses game.love into it. There is no aarch64 equivalent to unpack -- LOVE 11.5 publishes win32, win64, macOS, Android, iOS and exactly one x86_64 AppImage -- so arm64 desktop Linux (Raspberry Pi 4/5, Armbian, arm64 VMs on Apple Silicon) had no artifact at all. Compile LOVE 11.5 from the official linux-src tarball instead, inside a Debian bullseye arm64 container, and assemble the AppImage from scratch. Both pinned inputs (the LOVE source tarball and the AppImage type-2 runtime, on a dated tag rather than `continuous`) are SHA-256 verified on the host, so the container runs with no network access. Bullseye is the compile environment, not a claim about where the artifact runs: glibc is backward but not forward compatible, so linking against the oldest supported glibc is the only thing that makes one artifact work everywhere. The binaries come out needing only glibc 2.29 / GLIBCXX_3.4.21, covering Raspberry Pi OS bullseye through trixie and Ubuntu 20.04 onward. The dependency walker copies in LOVE's own libraries and leaves the driver-coupled, loader-coupled and font-stack libraries to the host. That last category is not cosmetic: Debian's libtheoradec is linked against libcairo, so a host cairo gets loaded into the process, and because the loader resolves one SONAME once per process it then binds to whatever libfreetype we bundled -- bullseye's 2.10.4 has no FT_Get_Transform, which cairo 1.18 needs, and the game died at startup with a symbol lookup error. Excluding the whole font stack makes the process self-consistent. CI gets three path-gated jobs: an offline selftest on ubuntu-latest (pins, the host-arch guard, the exclude list, the AppRun fusion contract), a real build on ubuntu-24.04-arm that asserts the layout, that every bundled object resolves under AppRun's LD_LIBRARY_PATH, and that the glibc floor is still <= 2.31, and a release job that reuses the shared game.love payload. None of it needs secrets or self-hosted hardware, so it runs on fork PRs. Verified end to end on a Raspberry Pi 5 (Debian trixie, Wayland): the launcher boots from the AppImage and renders correctly. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 97 +++++++ .github/workflows/release.yml | 49 +++- README.md | 18 ++ docs/linux-arm64-build.md | 175 +++++++++++++ scripts/build_linux_arm64.sh | 163 ++++++++++++ scripts/linux-arm64/Dockerfile | 29 +++ scripts/linux-arm64/build_appimage.sh | 236 ++++++++++++++++++ scripts/linux-arm64/common.sh | 108 ++++++++ .../linux-arm64/selftest_build_linux_arm64.sh | 128 ++++++++++ 9 files changed, 1002 insertions(+), 1 deletion(-) create mode 100644 docs/linux-arm64-build.md create mode 100755 scripts/build_linux_arm64.sh create mode 100644 scripts/linux-arm64/Dockerfile create mode 100755 scripts/linux-arm64/build_appimage.sh create mode 100755 scripts/linux-arm64/common.sh create mode 100755 scripts/linux-arm64/selftest_build_linux_arm64.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcb5ae92..033a9d71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,103 @@ jobs: if-no-files-found: error retention-days: 7 + linux-arm64-changes: + name: detect Linux arm64 changes + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.paths.outputs.changed }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - id: paths + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.sha }} + run: | + if [ -z "$BASE_SHA" ] || [ "$BASE_SHA" = "0000000000000000000000000000000000000000" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git diff --name-only "$BASE_SHA" "$HEAD_SHA" | grep -Eq '^(scripts/build_linux_arm64\.sh$|scripts/linux-arm64/|scripts/pack_love\.sh$|docs/linux-arm64-build\.md$|\.github/workflows/(ci|release)\.yml$)'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + linux-arm64-selftest: + name: Linux arm64 offline selftest + needs: linux-arm64-changes + if: needs.linux-arm64-changes.outputs.changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + # Deliberately on x86_64: everything this gate checks (pins, the + # host-arch guard, the dependency exclude list, the AppRun fusion + # contract) is answerable without a container or an aarch64 machine, + # so the slow native job below only ever starts on a sane tree. + - name: Linux arm64 offline selftest + run: bash scripts/linux-arm64/selftest_build_linux_arm64.sh + + linux-arm64-build: + name: Linux arm64 AppImage build + needs: [linux-arm64-changes, linux-arm64-selftest] + if: | + always() + && needs.linux-arm64-changes.outputs.changed == 'true' + && needs.linux-arm64-selftest.result == 'success' + # No fork restriction, unlike switch-build: this needs no secrets and no + # self-hosted hardware, just GitHub's free arm64 runner for public repos, + # so contributors get the same coverage on their own PRs. + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - name: Build the aarch64 AppImage + run: | + set -euo pipefail + scripts/build_linux_arm64.sh --version 0.0.0 + - name: Verify the AppImage is self-contained and bullseye-compatible + run: | + set -euo pipefail + image="dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage" + + # --appimage-extract needs no FUSE, so this works on a runner + # without /dev/fuse and still exercises the real payload. + "$image" --appimage-extract >/dev/null + for required in AppRun bin/love game.love lib/liblove-11.5.so; do + [ -e "squashfs-root/$required" ] \ + || { echo "::error::AppImage is missing $required"; exit 1; } + done + + # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is + # applied; an unresolved soname here is a user-visible launch crash. + missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ + ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ + | grep 'not found' || true)" + [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } + + # The whole point of compiling on bullseye. If a future change moves + # the builder to a newer base, the glibc floor silently rises and + # every user on an older distro gets "GLIBC_2.xx not found" -- catch + # it here instead of in a release. + floor="$(objdump -T squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ + | grep -o 'GLIBC_[0-9.]*' | sort -V | tail -1)" + echo "highest required glibc symbol version: $floor" + [ -n "$floor" ] \ + || { echo "::error::found no versioned glibc symbols -- objdump read nothing"; exit 1; } + highest="$(printf '%s\n' "$floor" "GLIBC_2.31" | sort -V | tail -1)" + [ "$highest" = "GLIBC_2.31" ] \ + || { echo "::error::AppImage requires $floor, above the bullseye 2.31 floor"; exit 1; } + - name: Upload the AppImage + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-arm64 + path: | + dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage + dist/linux-arm64/gen1recomp-0.0.0-linux-arm64.AppImage.sha256 + if-no-files-found: error + retention-days: 7 + headless: name: headless suites (no ROM) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c6e532e..c111670d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -140,6 +140,36 @@ jobs: if-no-files-found: error retention-days: 1 + linux-arm64: + name: build Linux arm64 AppImage + needs: [version, love-payload] + # GitHub's free arm64 runner for public repos. It has to be arm64: the + # AppImage compiles LÖVE natively inside a Debian bullseye arm64 + # container, and the qemu-emulated alternative takes hours. + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - name: Download shared payload + uses: actions/download-artifact@v8 + with: + name: gen1recomp-release-love + path: .bazinga/work + - name: Build Linux arm64 AppImage + run: | + set -euo pipefail + scripts/build_linux_arm64.sh \ + --version "${{ needs.version.outputs.version }}" \ + --game-love .bazinga/work/game.love + - name: Upload Linux arm64 release + uses: actions/upload-artifact@v7 + with: + name: gen1recomp-linux-arm64-release + path: | + dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage + dist/linux-arm64/gen1recomp-${{ needs.version.outputs.version }}-linux-arm64.AppImage.sha256 + if-no-files-found: error + retention-days: 1 + xbox-uwp: name: build Xbox UWP release needs: [version, love-payload] @@ -222,7 +252,7 @@ jobs: } release: - needs: [version, xbox-uwp] + needs: [version, xbox-uwp, linux-arm64] runs-on: ${{ fromJSON(github.repository == 'bryanthaboi/gen1recomp' && '["self-hosted", "macOS"]' || '"macos-latest"') }} steps: @@ -367,6 +397,13 @@ jobs: name: gen1recomp-xbox-uwp-release path: dist/xbox-uwp + - name: Download Linux arm64 release + if: github.repository == 'bryanthaboi/gen1recomp' + uses: actions/download-artifact@v8 + with: + name: gen1recomp-linux-arm64-release + path: dist/linux-arm64 + - name: Stage release assets if: github.repository == 'bryanthaboi/gen1recomp' id: assets @@ -379,6 +416,15 @@ jobs: cp "dist/mac/gen1recomp-macos.zip" "$outdir/gen1recomp-${v}-macos.zip" cp "dist/win/gen1recomp-win64.zip" "$outdir/gen1recomp-${v}-windows.zip" cp "dist/linux/gen1recomp-linux.zip" "$outdir/gen1recomp-${v}-linux.zip" + + # arm64 desktop Linux (Raspberry Pi, Armbian, arm64 VMs). Built on + # its own runner because LÖVE publishes no aarch64 binary and the + # AppImage has to be compiled natively; ships as a runnable + # AppImage rather than a zip so `chmod +x && ./it` just works. + arm64_appimage="dist/linux-arm64/gen1recomp-${v}-linux-arm64.AppImage" + [ -f "$arm64_appimage" ] || { echo "::error::$arm64_appimage not found (expected from the linux-arm64 job)"; exit 1; } + cp "$arm64_appimage" "$outdir/gen1recomp-${v}-linux-arm64.AppImage" + chmod +x "$outdir/gen1recomp-${v}-linux-arm64.AppImage" apk="$(find dist/android/debug -name '*.apk' | head -1)" [ -n "$apk" ] || { echo "::error::no Android APK found under dist/android/debug"; exit 1; } cp "$apk" "$outdir/gen1recomp-${v}-android.apk" @@ -512,6 +558,7 @@ jobs: "dist/release/gen1recomp-${v}-macos.zip" "dist/release/gen1recomp-${v}-windows.zip" "dist/release/gen1recomp-${v}-linux.zip" + "dist/release/gen1recomp-${v}-linux-arm64.AppImage" "dist/release/gen1recomp-${v}-android.apk" "dist/release/gen1recomp-${v}-ios.ipa" "dist/release/gen1recomp-${v}-switch.zip" diff --git a/README.md b/README.md index c1d4d08e..45afadb6 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,24 @@ even on a different computer, as long as the same folder comes along. already written to either location is touched automatically, so copy files over yourself if you want to carry existing progress across the switch. +## Linux on arm64 (Raspberry Pi) + +Alongside the x86_64 `gen1recomp-*-linux.zip`, every release ships +`gen1recomp-*-linux-arm64.AppImage` for 64-bit ARM desktop Linux — Raspberry +Pi 4/5, Armbian and other SBC distros, and arm64 VMs on Apple Silicon: + +```sh +chmod +x gen1recomp-*-linux-arm64.AppImage +./gen1recomp-*-linux-arm64.AppImage +``` + +LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the +engine from source inside a Debian bullseye arm64 container; the result needs +only glibc 2.29+, which covers Raspberry Pi OS bullseye through trixie and +Ubuntu 20.04 onward. Build instructions, the host requirements, and why the +font stack is deliberately left unbundled are in +[docs/linux-arm64-build.md](docs/linux-arm64-build.md). + ## iOS Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore diff --git a/docs/linux-arm64-build.md b/docs/linux-arm64-build.md new file mode 100644 index 00000000..c40a8bef --- /dev/null +++ b/docs/linux-arm64-build.md @@ -0,0 +1,175 @@ +# Linux arm64 (aarch64) AppImage + +Releases ship `gen1recomp--linux-arm64.AppImage` alongside the +existing x86_64 `gen1recomp--linux.zip`. It targets 64-bit ARM +desktop Linux: Raspberry Pi 4/5 running Raspberry Pi OS, Armbian and other +SBC distros, arm64 VMs on Apple Silicon, Ampere/Graviton desktops, and the +aarch64 handhelds that run a full distro. + +> The Anbernic RG34XXSP has its own PortMaster-style pack +> (`gen1recomp-*-rg34xxsp-stockos64-mod.zip`, see +> [anbernic-rg34xxsp.md](anbernic-rg34xxsp.md)). That one bundles PortMaster's +> LÖVE runtime and expects the device's own SDL; this AppImage is the generic +> desktop-Linux artifact and shares nothing with it but the `game.love`. + +## For players + +```sh +chmod +x gen1recomp-*-linux-arm64.AppImage +./gen1recomp-*-linux-arm64.AppImage +``` + +Then use **Import ROM** in the launcher to point it at your own legal Red / +Blue / Yellow cartridge dump, exactly as on every other platform. + +If your system has no FUSE (`dlopen(): error loading libfuse.so.2`), either +install it (`sudo apt install libfuse2`) or run without it: + +```sh +./gen1recomp-*-linux-arm64.AppImage --appimage-extract-and-run +``` + +### What the host has to provide + +The AppImage bundles LÖVE, SDL2, OpenAL and the audio/video decoders. It +deliberately does **not** bundle the graphics drivers, the audio server +client libraries, or the font stack — those have to come from your system, +because bundled copies would either bypass your GPU driver or disagree with +libraries your desktop already has loaded (see +[Why the font stack is not bundled](#why-the-font-stack-is-not-bundled)). + +In practice any arm64 system with a working desktop already satisfies this. +The requirements are glibc 2.29 or newer, plus Mesa/GL, X11 or Wayland, +ALSA or PulseAudio, and freetype/fontconfig — i.e. `libgl1`, `libfreetype6`, +`libfontconfig1`, `libpng16-16`, `libx11-6`. + +## For builders + +```sh +scripts/build_linux_arm64.sh --version 0.1.0 +``` + +Output: + +``` +dist/linux-arm64/gen1recomp--linux-arm64.AppImage +dist/linux-arm64/gen1recomp--linux-arm64.AppImage.sha256 +``` + +Useful flags: `--game-love PATH` reuses an already-packed payload (CI does +this so every platform ships identical bytes), `--rebuild-image` forces the +builder container to rebuild, `--clean-cache` throws away the pinned +downloads and the compiled LÖVE prefix. + +### Requirements + +An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the +reference machine (a full build takes about 3.5 minutes on one; rebuilds +reuse the cached LÖVE prefix and take seconds). Apple Silicon with Docker +Desktop and GitHub's `ubuntu-24.04-arm` runner both work too. + +The script refuses to run on x86_64 rather than falling back to qemu-user +emulation: that path takes hours and has produced miscompiled LuaJIT. + +### Why this is not just another `scripts/build.sh` target + +`scripts/build.sh linux` downloads LÖVE's official `love-11.5-x86_64.AppImage`, +unpacks its squashfs, drops `game.love` in, and glues it back together. That +trick is not available here — **LÖVE publishes no aarch64 binary at all.** The +11.5 release has win32, win64, macOS, Android, iOS and one x86_64 AppImage, +and that is the entire list. + +So this build compiles LÖVE 11.5 from the official `linux-src` tarball and +assembles the AppImage from scratch. Both pinned inputs (the LÖVE source +tarball and the AppImage type-2 runtime) are SHA-256 verified on the host +before the container ever sees them, and the container itself runs with no +network access. + +### Why the build happens in a Debian bullseye container + +glibc is backward compatible but not forward compatible: a binary linked +against glibc 2.41 will not start on a system with 2.31, and there is no way +to fix that after the fact. Compiling on the oldest base we support is +therefore the only thing that makes one artifact work everywhere. + +Bullseye (glibc 2.31) is that base. The resulting binaries actually come out +needing only **glibc 2.29** and **GLIBCXX_3.4.21**, so the AppImage covers +everything from Ubuntu 20.04 and Raspberry Pi OS bullseye through current +trixie. + +This is a statement about the *compile environment*, not about where the +artifact runs — building on your own newer distro would silently raise that +floor and strand every user on an older one, with no symptom until they +download it. CI enforces the floor: `linux-arm64-build` fails if the highest +required glibc symbol version climbs above 2.31. + +### Why the font stack is not bundled + +The dependency walker copies in what LÖVE needs and leaves everything else to +the host. Three categories are excluded, and the third one is subtle enough +to be worth writing down, because it is a real crash that shipped in an early +version of this build: + +1. **Driver and session coupled** — GL/EGL/gbm/drm, X11/xcb/Wayland, D-Bus, + PulseAudio, ALSA, systemd/udev. A bundled `libGL` would bypass Mesa's V3D + driver on the Pi; a bundled `libpulse` would fight the running sound server. +2. **Loader coupled** — glibc's own pieces cannot be mixed with the host's + `ld.so`, and `libstdc++`/`libgcc_s` must be at least as new as the compiler + that built us (bullseye's gcc 10 is older than any supported host's, so the + host copy always satisfies us). +3. **Shared with the host font stack** — freetype, fontconfig, libpng, brotli, + zlib. + +That third one exists because Debian's `libtheoradec.so.1` is, oddly, linked +against `libcairo.so.2`. LÖVE needs theora for `love.video`, so the host's +cairo gets pulled into our process. The dynamic loader resolves one SONAME +exactly once per process, so a host cairo then binds to whatever +`libfreetype.so.6` *we* bundled: + +``` +love -> liblove -> libtheoradec -> libcairo (host, new) + `-> FT_Get_Transform -> libfreetype (ours, bullseye 2.10.4) +``` + +`FT_Get_Transform` arrived in FreeType 2.11, so cairo 1.18 on a trixie host +fails to relocate and the game dies at startup with a symbol lookup error. +Bundling a *newer* freetype only moves the arms race one release along. +Excluding the whole font/compression stack instead makes the process +self-consistent: cairo, fontconfig and freetype all come from one host and +agree with each other, while `liblove` — compiled against 2.10.4 — only ever +asks for symbols every supported host already has. + +### CI + +Three jobs, path-gated on `scripts/build_linux_arm64.sh`, +`scripts/linux-arm64/`, `scripts/pack_love.sh` and this document: + +- **`linux-arm64-selftest`** (`ubuntu-latest`, x86_64) — offline gate. Checks + the pins are real digests on a dated tag rather than the moving + `continuous` one, that the Dockerfile still builds on bullseye, that the + exclude list still classifies known sonames correctly, that AppRun still + launches `game.love` with `--fused`, and that the host-arch guard actually + fires. Needs no container and no arm64 machine. +- **`linux-arm64-build`** (`ubuntu-24.04-arm`) — the real build, then extracts + the artifact and asserts the layout, that every bundled object resolves + under AppRun's `LD_LIBRARY_PATH`, and that the glibc floor is still ≤ 2.31. + Uploads the AppImage for 7 days. +- **release** — `linux-arm64` runs on `ubuntu-24.04-arm`, reuses the shared + `game.love` from the `love-payload` job, and the AppImage is staged and + published like every other release asset. + +Unlike the Switch job, none of this needs secrets or self-hosted hardware, so +it runs on fork PRs too. + +### Updating the pins + +Both pins live in `scripts/linux-arm64/common.sh`: + +- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping the LÖVE version invalidates + the cached prefix automatically (it is keyed by version). Check that + bullseye still has `-dev` packages new enough for the new release; + `build_appimage.sh` asserts every optional module actually linked, because + LÖVE's `configure` exits 0 and silently drops a module when one is missing. +- `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag + from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases). + The selftest fails the build if this ever points at `continuous`. diff --git a/scripts/build_linux_arm64.sh b/scripts/build_linux_arm64.sh new file mode 100755 index 00000000..e8e94a83 --- /dev/null +++ b/scripts/build_linux_arm64.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Builds the aarch64 (arm64) Linux AppImage. +# +# scripts/build.sh's `linux` target only produces x86_64: it unpacks LÖVE's +# official x86_64 AppImage and re-fuses it, and no aarch64 equivalent is +# published. This script compiles LÖVE 11.5 from the official linux-src +# tarball inside a Debian bullseye arm64 container and fuses game.love into a +# type-2 AppImage, so one artifact covers Raspberry Pi OS, Armbian, Ubuntu +# arm64 and the aarch64 handhelds. +# +# Usage: +# scripts/build_linux_arm64.sh [--version X.Y.Z] [--game-love PATH] +# [--rebuild-image] [--clean-cache] +# +# Output: +# dist/linux-arm64/gen1recomp--linux-arm64.AppImage +# dist/linux-arm64/gen1recomp--linux-arm64.AppImage.sha256 +# +# Requirements: docker or podman on an aarch64 host (a Raspberry Pi 5, an +# ubuntu-24.04-arm runner or Apple Silicon Docker all work). Nothing is +# cross-compiled and no qemu emulation is involved. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +. "$ROOT/scripts/linux-arm64/common.sh" + +HERE="$ROOT/.bazinga" +CACHE="$HERE/cache/linux-arm64" +WORK="$HERE/work/linux-arm64" +DIST="$ROOT/dist/linux-arm64" + +VERSION="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo dev)" +GAME_LOVE="" +REBUILD_IMAGE=0 + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="${2:?--version needs a value}"; shift ;; + --game-love) GAME_LOVE="${2:?--game-love needs a path}"; shift ;; + --rebuild-image) REBUILD_IMAGE=1 ;; + --clean-cache) rm -rf "$CACHE" ;; + -h|--help) + sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) fail "unknown argument: $1" ;; + esac + shift +done + +# --------------------------------------------------------------- host checks +# aarch64 only. The container is arch-native; running it under qemu-user on an +# x86_64 host "works" but takes hours and has produced miscompiled LuaJIT +# before, so refuse rather than hand back a build nobody can trust. +host_arch="$(uname -m)" +case "$host_arch" in + aarch64|arm64) ;; + *) fail "this build must run on an aarch64 host (found: $host_arch). + Use a Raspberry Pi 5 / arm64 VM / Apple Silicon, or the ubuntu-24.04-arm CI runner." ;; +esac + +RUNTIME="$(container_runtime)" || fail_need_container +say "container runtime: $RUNTIME" + +mkdir -p "$CACHE" "$WORK" "$DIST" + +# --------------------------------------------------------------- game.love +# Shared packer, same include/exclude set and the same verification gates as +# every other platform, so this artifact can never drift from the desktop one. +if [ -n "$GAME_LOVE" ]; then + [ -f "$GAME_LOVE" ] || fail "--game-love: no such file: $GAME_LOVE" + say "using prebuilt payload: $GAME_LOVE" +else + GAME_LOVE="$WORK/game.love" + "$ROOT/scripts/pack_love.sh" \ + --output "$GAME_LOVE" \ + --listing "$WORK/love-listing.txt" \ + --version "$VERSION" +fi + +# --------------------------------------------------------------- icon +# One source of truth for every platform's launcher icon (scripts/build.sh +# resizes the same file with sips on macOS). Pillow is already a project +# dependency via tools/build_data.py; without it, ship the 1024px original +# rather than failing the build over an icon. +IN_DIR="$WORK/in" +rm -rf "$IN_DIR"; mkdir -p "$IN_DIR" +ICON_SRC="$ROOT/assets/logo/gen1recomp_cover.png" +[ -f "$ICON_SRC" ] || fail "missing icon source: $ICON_SRC" +if ! python3 - "$ICON_SRC" "$IN_DIR/icon.png" <<'PY' 2>/dev/null +import sys +from PIL import Image +with Image.open(sys.argv[1]) as image: + image.convert("RGBA").resize((512, 512), Image.LANCZOS).save(sys.argv[2]) +PY +then + warn "Pillow not available, shipping the unresized icon" + cp "$ICON_SRC" "$IN_DIR/icon.png" +fi +cp "$GAME_LOVE" "$IN_DIR/game.love" + +# --------------------------------------------------------------- downloads +# Fetched on the host and checksum-pinned here so the container never needs +# network access and every input is verified in exactly one place. +download_pinned "$LOVE_SRC_URL" "$CACHE/$LOVE_SRC_TARBALL" "$LOVE_SRC_SHA256" +download_pinned "$APPIMAGE_RUNTIME_URL" "$CACHE/$APPIMAGE_RUNTIME_NAME" \ + "$APPIMAGE_RUNTIME_SHA256" + +# --------------------------------------------------------------- builder image +if [ "$REBUILD_IMAGE" = 1 ] || ! "$RUNTIME" image inspect "$BUILDER_IMAGE" >/dev/null 2>&1; then + say "building $BUILDER_IMAGE ($BUILDER_BASE_IMAGE)" + "$RUNTIME" build -t "$BUILDER_IMAGE" \ + -f "$ROOT/scripts/linux-arm64/Dockerfile" "$ROOT/scripts/linux-arm64" \ + || fail "failed to build the $BUILDER_BASE_IMAGE builder image" +fi + +# --------------------------------------------------------------- build +OUT_DIR="$WORK/out" +rm -rf "$OUT_DIR"; mkdir -p "$OUT_DIR" + +# --user keeps the AppImage owned by the invoking user instead of root; podman +# maps root in the container to the host user already, so only docker needs it. +user_args=() +if [ "$RUNTIME" = "docker" ]; then + user_args=(--user "$(id -u):$(id -g)") +fi + +say "compiling and packaging inside $BUILDER_BASE_IMAGE" +"$RUNTIME" run --rm ${user_args[@]+"${user_args[@]}"} \ + -e LOVE_VERSION="$LOVE_VERSION" \ + -e APP_NAME="$APP_NAME" \ + -e VERSION="$VERSION" \ + -v "$CACHE:/cache" \ + -v "$IN_DIR:/in:ro" \ + -v "$OUT_DIR:/out" \ + -v "$ROOT/scripts/linux-arm64:/scripts:ro" \ + "$BUILDER_IMAGE" bash /scripts/build_appimage.sh + +# --------------------------------------------------------------- publish +built="$OUT_DIR/$APP_NAME-$VERSION-linux-arm64.AppImage" +[ -f "$built" ] || fail "container produced no AppImage at $built" + +# The runtime is a static-pie ELF and the payload starts where its section +# headers end; a truncated cat would still be "a file", so prove both halves +# survived before shipping. +head -c 4 "$built" | od -An -tx1 | tr -d ' \n' | grep -q '^7f454c46$' \ + || fail "built AppImage is not an ELF" +e_shoff=$(od -An -j40 -N8 -tu8 "$built" | tr -d ' ') +e_shentsize=$(od -An -j58 -N2 -tu2 "$built" | tr -d ' ') +e_shnum=$(od -An -j60 -N2 -tu2 "$built" | tr -d ' ') +sfs_offset=$((e_shoff + e_shentsize * e_shnum)) +[ "$(dd if="$built" bs=1 skip="$sfs_offset" count=4 2>/dev/null)" = "hsqs" ] \ + || fail "no squashfs payload at offset $sfs_offset (runtime/payload fusion failed)" + +out="$DIST/$(basename "$built")" +rm -f "$out" "$out.sha256" +mv "$built" "$out" +chmod +x "$out" +printf '%s %s\n' "$(sha256_file "$out")" "$(basename "$out")" > "$out.sha256" + +say "Linux arm64 build: $out ($(du -h "$out" | cut -f1))" +say "sha256: $(cut -d' ' -f1 "$out.sha256")" diff --git a/scripts/linux-arm64/Dockerfile b/scripts/linux-arm64/Dockerfile new file mode 100644 index 00000000..65990884 --- /dev/null +++ b/scripts/linux-arm64/Dockerfile @@ -0,0 +1,29 @@ +# Build environment for the aarch64 Linux AppImage. +# +# Debian bullseye on purpose: it ships glibc 2.31, the oldest runtime we +# promise to support. Everything linked here therefore runs on bullseye and +# every later distro (glibc is backward compatible, not forward), which is +# what makes the resulting AppImage portable across Raspberry Pi OS, Armbian, +# Ubuntu 20.04+, and the aarch64 handheld distros. +# +# This image is arch-native: build it on an aarch64 host (Raspberry Pi 5, +# ubuntu-24.04-arm runner, Apple Silicon Docker) — no qemu emulation. +FROM debian:bullseye + +ENV DEBIAN_FRONTEND=noninteractive + +# build-essential/autoconf: LÖVE 11.5's linux-src tarball is autotools. +# squashfs-tools: packs the AppDir into the AppImage payload. +# The lib*-dev set is LÖVE's full optional-module surface — a missing one +# does not fail configure, it silently drops a module (love.sound decoders, +# love.font, love.video), so they are pinned here deliberately. +RUN apt-get update -qq \ + && apt-get install -y --no-install-recommends \ + build-essential pkg-config autoconf automake libtool \ + ca-certificates curl file xz-utils zip unzip squashfs-tools \ + libsdl2-dev libopenal-dev libogg-dev libvorbis-dev libtheora-dev \ + libmodplug-dev libmpg123-dev libfreetype6-dev libluajit-5.1-dev \ + zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /work diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh new file mode 100755 index 00000000..420d586c --- /dev/null +++ b/scripts/linux-arm64/build_appimage.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# Compiles LÖVE for aarch64 and fuses game.love into a self-contained +# AppImage. Runs INSIDE the Debian bullseye container from Dockerfile -- +# scripts/build_linux_arm64.sh is the entry point on the host. +# +# Mounts the host provides: +# /cache pinned downloads + the compiled LÖVE prefix (persists between runs) +# /in read-only inputs: game.love, icon.png +# /out the finished AppImage lands here +# +# Environment: +# LOVE_VERSION, APP_NAME, VERSION passed through from the host script +# JOBS make -j (defaults to nproc) + +set -euo pipefail + +LOVE_VERSION="${LOVE_VERSION:?}" +APP_NAME="${APP_NAME:?}" +VERSION="${VERSION:?}" +JOBS="${JOBS:-$(nproc)}" + +CACHE="/cache" +IN="/in" +OUT="/out" +WORK="/tmp/build" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +mkdir -p "$WORK" + +# ------------------------------------------------------------ compile LÖVE +# The prefix is cached because this is the only slow step (~3 min on a Pi 5, +# and it is identical for every game version). Keyed by LÖVE version so a +# LOVE_VERSION bump cannot silently reuse the old build. +PREFIX="$CACHE/love-$LOVE_VERSION-prefix" +if [ -x "$PREFIX/bin/love" ] && [ -f "$PREFIX/lib/liblove-$LOVE_VERSION.so" ]; then + say "reusing cached LÖVE $LOVE_VERSION aarch64 build" +else + say "compiling LÖVE $LOVE_VERSION for aarch64 (jobs: $JOBS)" + rm -rf "$PREFIX" "$WORK/love-src" + mkdir -p "$WORK/love-src" + tar -xzf "$CACHE/love-$LOVE_VERSION-linux-src.tar.gz" \ + -C "$WORK/love-src" --strip-components=1 + ( + cd "$WORK/love-src" + # No --disable-* flags on purpose: configure silently drops a love module + # when its -dev package is absent, so the Dockerfile pins the full set and + # the assertions below prove each one actually linked. + ./configure --prefix="$PREFIX" --disable-static >/dev/null + make -j"$JOBS" >/dev/null + make install >/dev/null + # Keep LÖVE's license inside the cached prefix: the unpacked source tree + # is thrown away, so a later cache-hit run would otherwise have nothing + # to ship and the AppImage would go out without its engine license. + cp license.txt "$PREFIX/license.txt" + ) +fi + +love_bin="$PREFIX/bin/love" +love_lib="$PREFIX/lib/liblove-$LOVE_VERSION.so" +[ -x "$love_bin" ] || fail "LÖVE build produced no bin/love" +[ -f "$love_lib" ] || fail "LÖVE build produced no lib/liblove-$LOVE_VERSION.so" +file "$love_bin" | grep -q 'ARM aarch64' \ + || fail "built love is not an aarch64 ELF (got: $(file -b "$love_bin"))" + +# A configure run that lost an optional dependency still exits 0 and still +# builds -- the loss only shows up as a missing love module at runtime, i.e. +# in a shipped artifact. Assert the decoder/font/video libs really linked. +for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 \ + libmodplug.so.1 libmpg123.so.0 libvorbisfile.so.3 \ + libtheoradec.so.1 libluajit-5.1.so.2; do + objdump -p "$love_lib" | grep -q "NEEDED.*$soname" \ + || fail "liblove is not linked against $soname (a -dev package went missing)" +done + +# --------------------------------------------------------------- AppDir +# Layout mirrors LÖVE's own x86_64 AppImage exactly (bin/ lib/ share/ at the +# AppDir root, not usr/-prefixed), so the AppRun contract below -- and the +# FUSE_PATH fusion scripts/build.sh performs on the x86_64 image -- stay the +# same idea on both architectures. +APPDIR="$WORK/AppDir" +rm -rf "$APPDIR" +mkdir -p "$APPDIR/bin" "$APPDIR/lib" "$APPDIR/share" + +cp "$love_bin" "$APPDIR/bin/love" +chmod +x "$APPDIR/bin/love" + +# ------------------------------------------------------ bundle dependencies +# Walk the DT_NEEDED graph from love + liblove, copying in everything that is +# not host-provided. Recursion stops at excluded libraries, so the driver and +# session subtrees behind SDL2 are never pulled in. +# +# Three reasons a library MUST come from the host, and every entry below is +# one of them: +# +# 1. Driver/session coupled. A bundled libGL would bypass Mesa's V3D driver +# on the Pi; a bundled libpulse/libdbus would fight the user's running +# session. GL/EGL/gbm/drm, X11/xcb/wayland/xkbcommon, dbus, pulse, alsa, +# systemd/udev. +# +# 2. Loader coupled. glibc's pieces cannot be mixed with the host's ld.so at +# all, and libstdc++/libgcc_s must be at least as new as the compiler -- +# bullseye's gcc 10 is older than any supported host's, so the host copy +# always satisfies us. +# +# 3. Shared with the host's font stack -- the subtle one, and the reason +# this list is longer than LÖVE's own AppImage manifest. Bullseye's +# libtheoradec is (bizarrely, a Debian packaging artifact) linked against +# libcairo, so the HOST's cairo gets loaded into our process. Because the +# dynamic loader resolves one SONAME once per process, that host cairo +# then binds to whatever libfreetype.so.6 we bundled -- and a bullseye +# freetype 2.10.4 has no FT_Get_Transform, which cairo 1.18 needs: +# +# love -> liblove -> libtheoradec -> libcairo (host, new) +# `-> FT_Get_Transform -> libfreetype (ours, old) BOOM +# +# Bundling a newer freetype only moves the arms race. Excluding the whole +# font/compression stack instead makes it self-consistent: cairo, +# fontconfig and freetype all come from one host and agree with each +# other, while liblove -- compiled against 2.10.4 -- only ever asks for +# symbols every supported host already has. +EXCLUDE_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libresolv\.so\.2|libutil\.so\.1|libanl\.so\.1|libnsl\.so\.[0-9]+|libstdc\+\+\.so\.6|libgcc_s\.so\.1|lib(GL|GLX|GLdispatch|OpenGL|EGL|GLESv[12]|glapi|gbm|drm)\..*|libX[a-z0-9]*\..*|libxcb.*|libwayland-.*|libxkbcommon.*|libdbus-1\..*|libpulse.*|libasound\..*|libsndfile\..*|libFLAC\..*|libopus\..*|libsystemd\..*|libudev\..*|libselinux\..*|libcap\..*|libgcrypt\..*|libgpg-error\..*|liblzma\..*|libzstd\..*|liblz4\..*|libffi\..*|libexpat\..*|libbsd\..*|libmd\..*|libuuid\..*|libg(lib|object|module|thread)-2\..*|libfontconfig\..*|libfreetype\..*|libpng[0-9]*\..*|libbrotli.*|libz\.so\..*|libwrap\..*|libasyncns\..*|libtirpc\..*|lib(gssapi_krb5|krb5|k5crypto|com_err|krb5support|keyutils)\..*|libpcre.*)$' + +# soname -> absolute path, harvested from the full ldd closure of both roots. +declare -A RESOLVED=() +while read -r soname _arrow path _addr; do + [ -n "${path:-}" ] || continue + [ -e "$path" ] || continue + RESOLVED["$soname"]="$path" +done < <(ldd "$love_bin" "$love_lib" | awk '/=>/ {print $1, $2, $3, $4}') + +declare -A BUNDLED=() +bundle_needed() { # $1 = ELF whose DT_NEEDED entries to walk + local soname target + while read -r soname; do + [ -n "$soname" ] || continue + if [[ "$soname" =~ $EXCLUDE_RE ]]; then continue; fi + if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi + target="${RESOLVED[$soname]:-}" + [ -n "$target" ] || fail "cannot resolve $soname (needed by $(basename "$1"))" + # Copy dereferenced and under the soname: the AppDir must not depend on + # the builder's libSDL2-2.0.so.0 -> libSDL2-2.0.so.0.14.0 symlink chain. + cp -L "$target" "$APPDIR/lib/$soname" + chmod 0644 "$APPDIR/lib/$soname" + BUNDLED["$soname"]=1 + bundle_needed "$APPDIR/lib/$soname" + done < <(objdump -p "$1" | awk '/NEEDED/ {print $2}') +} + +say "bundling shared libraries" +cp "$love_lib" "$APPDIR/lib/liblove-$LOVE_VERSION.so" +chmod 0644 "$APPDIR/lib/liblove-$LOVE_VERSION.so" +BUNDLED["liblove-$LOVE_VERSION.so"]=1 +bundle_needed "$APPDIR/bin/love" +bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so" +say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')" + +# LÖVE loads jit.* (jit.status, the profiler) through LUA_PATH; without these +# the modules are simply absent, so ship them the way upstream's image does. +jit_share="$(ls -d /usr/share/luajit-* 2>/dev/null | head -1)" +[ -n "$jit_share" ] || fail "luajit jit/*.lua modules not found under /usr/share" +LUAJIT_SHARE_DIR="$(basename "$jit_share")" +mkdir -p "$APPDIR/share/$LUAJIT_SHARE_DIR" "$APPDIR/share/lua/5.1" "$APPDIR/lib/lua/5.1" +cp -R "$jit_share/jit" "$APPDIR/share/$LUAJIT_SHARE_DIR/" + +# --------------------------------------------------------------- branding +cp "$IN/game.love" "$APPDIR/game.love" +# The .desktop's Icon= resolves against the AppDir root by basename, and +# .DirIcon is what appimaged and file-manager thumbnailers read. +cp "$IN/icon.png" "$APPDIR/$APP_NAME.png" +cp "$IN/icon.png" "$APPDIR/.DirIcon" + +cat > "$APPDIR/$APP_NAME.desktop" < "$APPDIR/AppRun" <. gzip at 128K blocks matches what +# LÖVE's official image uses and what every type-2 runtime can read; zstd would +# be smaller but is not universally supported by older runtimes users may have +# registered through appimaged. +say "packing squashfs" +sfs="$WORK/payload.squashfs" +rm -f "$sfs" +mksquashfs "$APPDIR" "$sfs" \ + -comp gzip -b 131072 -noappend -all-root -no-xattrs -quiet >/dev/null + +out="$OUT/$APP_NAME-$VERSION-linux-arm64.AppImage" +rm -f "$out" +cat "$CACHE/runtime-aarch64" "$sfs" > "$out" +chmod +x "$out" + +say "AppImage: $(basename "$out") ($(du -h "$out" | cut -f1))" diff --git a/scripts/linux-arm64/common.sh b/scripts/linux-arm64/common.sh new file mode 100755 index 00000000..210c67c2 --- /dev/null +++ b/scripts/linux-arm64/common.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Shared helpers and pins for the aarch64 Linux AppImage build. +# Source from other scripts: . "$(dirname "$0")/common.sh" + +# shellcheck disable=SC2034 +if [ -z "${ROOT:-}" ]; then + ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi +export ROOT + +# ---------------------------------------------------------------- pins +# LÖVE ships no aarch64 binary of any kind -- the 11.5 release has win32/win64, +# macOS, Android, iOS and an x86_64 AppImage, and that is the whole list. So +# this port compiles the official linux-src tarball instead of unpacking a +# prebuilt image the way scripts/build.sh does for x86_64. +LOVE_VERSION="11.5" +LOVE_SRC_TARBALL="love-$LOVE_VERSION-linux-src.tar.gz" +LOVE_SRC_URL="https://github.com/love2d/love/releases/download/$LOVE_VERSION/$LOVE_SRC_TARBALL" +LOVE_SRC_SHA256="066e0843f71aa9fd28b8eaf27d41abb74bfaef7556153ac2e3cf08eafc874c39" + +# AppImage type-2 runtime: the ~900 KB static-pie ELF that gets prepended to +# the squashfs payload. Pinned to a dated tag, never "continuous", so a +# rebuild months from now produces the same bytes. +APPIMAGE_RUNTIME_TAG="20251108" +APPIMAGE_RUNTIME_NAME="runtime-aarch64" +APPIMAGE_RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME" +APPIMAGE_RUNTIME_SHA256="00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444" + +# Debian bullseye (glibc 2.31) is the compile environment, NOT a statement +# about where the artifact runs. glibc is backward compatible but not forward +# compatible, so linking against the oldest glibc we support is what lets one +# AppImage cover Raspberry Pi OS bullseye/bookworm/trixie, Ubuntu 20.04+ and +# the aarch64 handheld distros. Building on a newer base would silently +# restrict the artifact to that base and newer. +BUILDER_BASE_IMAGE="debian:bullseye" +BUILDER_IMAGE="${GEN1_LINUX_ARM64_IMAGE:-gen1recomp-linux-arm64-builder}" + +APP_NAME="gen1recomp" + +say() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33mwarn:\033[0m %s\n' "$*" >&2; } +fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +# Print SHA-256 hex digest of PATH. Prefers sha256sum, falls back to shasum +# (same order-agnostic pair scripts/switch/common.sh uses). +sha256_file() { + local path="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + else + fail "need sha256sum or shasum (install coreutils)" + fi +} + +# download_pinned URL DEST EXPECTED_SHA256 +# +# A cache hit is only trusted if it still hashes to the pin: a download +# truncated by a network drop would otherwise be reused forever, which is the +# same trap scripts/build.sh guards for the win64 zip and the x86_64 AppImage. +download_pinned() { + local url="$1" dest="$2" want="$3" got="" + if [ -f "$dest" ]; then + got="$(sha256_file "$dest")" + if [ "$got" = "$want" ]; then + return 0 + fi + warn "cached $(basename "$dest") has the wrong digest, re-downloading" + rm -f "$dest" + fi + say "downloading $(basename "$dest")" + curl -fL --progress-bar "$url" -o "$dest.tmp" || fail "download failed: $url" + got="$(sha256_file "$dest.tmp")" + [ "$got" = "$want" ] || fail "$(printf '%s\n expected %s\n got %s' \ + "checksum mismatch for $(basename "$dest")" "$want" "$got")" + mv "$dest.tmp" "$dest" +} + +# Echo the container runtime to use: docker, else podman. +container_runtime() { + if [ -n "${GEN1_CONTAINER_RUNTIME:-}" ]; then + printf '%s' "$GEN1_CONTAINER_RUNTIME" + return 0 + fi + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + printf 'docker' + elif command -v podman >/dev/null 2>&1; then + printf 'podman' + else + return 1 + fi +} + +fail_need_container() { + fail "$(cat <<'EOF' +the aarch64 AppImage is compiled inside a Debian bullseye container and needs +docker or podman on an aarch64 host. + + Raspberry Pi OS / Debian / Ubuntu: sudo apt install docker.io && sudo usermod -aG docker "$USER" + Fedora / Asahi: sudo dnf install podman + macOS (Apple Silicon): brew install --cask docker + +Override the runtime with GEN1_CONTAINER_RUNTIME=podman. +See docs/linux-arm64-build.md. +EOF +)" +} diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh new file mode 100755 index 00000000..1a25b177 --- /dev/null +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# Offline checks for the aarch64 Linux AppImage build. +# +# Runs anywhere -- no container, no network, no aarch64 host -- so PR CI can +# gate the parts of this build that do not need three minutes of compiling. +# The real build is exercised separately by the linux-arm64-build job. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=common.sh +. "$SCRIPT_DIR/common.sh" + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "required command not found: $1" +} +require_command unzip +require_command zip + +say "checking shell entry points" +bash -n "$ROOT/scripts/build_linux_arm64.sh" "$SCRIPT_DIR"/*.sh +help="$(bash "$ROOT/scripts/build_linux_arm64.sh" --help)" +printf '%s' "$help" | grep -q -- '--version X.Y.Z' \ + || fail "build help does not document --version" +printf '%s' "$help" | grep -q 'linux-arm64\.AppImage' \ + || fail "build help does not name the artifact it produces" + +say "checking the host-architecture guard" +# The guard is what stops someone from kicking off a qemu-emulated build that +# takes hours and miscompiles LuaJIT. Prove it fires rather than trusting it. +# The guard is what stops someone from kicking off a qemu-emulated build that +# takes hours and has miscompiled LuaJIT before. Prove it fires by shadowing +# uname, rather than trusting the branch is reachable. +fake_bin="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-fake-uname.XXXXXX")" +printf '#!/bin/sh\necho x86_64\n' > "$fake_bin/uname" +chmod +x "$fake_bin/uname" +guard_out="$(PATH="$fake_bin:$PATH" \ + bash "$ROOT/scripts/build_linux_arm64.sh" --version 0.0.0 2>&1 || true)" +rm -rf "$fake_bin" +printf '%s' "$guard_out" | grep -q 'aarch64 host' \ + || fail "build script does not refuse to run on a non-aarch64 host" + +say "checking pinned inputs" +# Pins must be real digests, and the AppImage runtime must come from a dated +# tag: "continuous" is a moving target and would make rebuilds unreproducible. +for pin_name in LOVE_SRC_SHA256 APPIMAGE_RUNTIME_SHA256; do + pin_value="${!pin_name}" + printf '%s' "$pin_value" | grep -Eq '^[0-9a-f]{64}$' \ + || fail "$pin_name is not a sha256 digest: $pin_value" +done +if printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q '/continuous/'; then + fail "the AppImage runtime is pinned to the moving 'continuous' tag" +fi +printf '%s' "$APPIMAGE_RUNTIME_URL" | grep -q "/$APPIMAGE_RUNTIME_TAG/$APPIMAGE_RUNTIME_NAME\$" \ + || fail "APPIMAGE_RUNTIME_URL does not match the pinned tag/asset" +printf '%s' "$LOVE_SRC_URL" | grep -q "/$LOVE_VERSION/$LOVE_SRC_TARBALL\$" \ + || fail "LOVE_SRC_URL does not match LOVE_VERSION/LOVE_SRC_TARBALL" + +say "checking the builder base image" +# Building on anything newer than bullseye silently raises the glibc floor and +# strands every user on an older distro, with no symptom until they run it. +grep -q '^FROM debian:bullseye$' "$SCRIPT_DIR/Dockerfile" \ + || fail "Dockerfile no longer builds on debian:bullseye (that raises the glibc floor)" +[ "$BUILDER_BASE_IMAGE" = "debian:bullseye" ] \ + || fail "BUILDER_BASE_IMAGE disagrees with the Dockerfile" + +say "checking the dependency exclude list" +# Extract the live regex from the build script and classify known sonames +# through it, so a future edit cannot quietly start bundling glibc or stop +# bundling the engine's own dependencies. +EXCLUDE_RE="$( + # shellcheck disable=SC1090 + grep -m1 "^EXCLUDE_RE=" "$SCRIPT_DIR/build_appimage.sh" | sed "s/^EXCLUDE_RE='//; s/'\$//" +)" +[ -n "$EXCLUDE_RE" ] || fail "could not read EXCLUDE_RE out of build_appimage.sh" + +must_exclude=(libc.so.6 ld-linux-aarch64.so.1 libstdc++.so.6 libgcc_s.so.1 + libGL.so.1 libEGL.so.1 libgbm.so.1 libdrm.so.2 libX11.so.6 + libwayland-client.so.0 libpulse.so.0 libasound.so.2 + libfreetype.so.6 libfontconfig.so.1 libpng16.so.16 libz.so.1) +must_bundle=(libSDL2-2.0.so.0 libopenal.so.1 libluajit-5.1.so.2 libmodplug.so.1 + libmpg123.so.0 libogg.so.0 libvorbis.so.0 libvorbisfile.so.3 + libtheoradec.so.1 liblove-11.5.so) + +for soname in "${must_exclude[@]}"; do + [[ "$soname" =~ $EXCLUDE_RE ]] \ + || fail "$soname must be host-provided but the exclude list would bundle it" +done +for soname in "${must_bundle[@]}"; do + if [[ "$soname" =~ $EXCLUDE_RE ]]; then + fail "$soname is an engine dependency but the exclude list drops it" + fi +done + +say "checking AppRun and the fusion contract" +# The AppImage must boot straight into the game. If AppRun ever loses --fused, +# users get vanilla LÖVE's "no game" screen instead, and nothing else catches +# that before someone downloads a release. +grep -qF -- '--fused "\$APPDIR/game.love"' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "AppRun no longer launches game.love with --fused" +grep -qF 'LD_LIBRARY_PATH="\$APPDIR/lib/' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "AppRun no longer puts the bundled lib directory on LD_LIBRARY_PATH" +grep -qF 'comp gzip -b 131072' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "squashfs payload is no longer gzip/128K (older type-2 runtimes cannot read it)" + +say "checking the linked-module assertions" +# configure exits 0 when an optional -dev package is missing and just drops the +# module, so these assertions are the only thing standing between a missing +# build dependency and a release that cannot play sound. +for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 libmodplug.so.1 \ + libmpg123.so.0 libvorbisfile.so.3 libtheoradec.so.1; do + grep -qF "$soname" "$SCRIPT_DIR/build_appimage.sh" \ + || fail "build_appimage.sh no longer asserts liblove links $soname" +done + +say "checking the shared game.love payload" +temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-linux-arm64-selftest.XXXXXX")" +trap 'rm -rf "$temp_dir"' EXIT +"$ROOT/scripts/pack_love.sh" \ + --output "$temp_dir/game.love" \ + --listing "$temp_dir/love-listing.txt" \ + --version 1.2.3 \ + --dry-run >/dev/null +unzip -p "$temp_dir/game.love" src/core/Version.lua \ + | grep -Eq 'engine[[:space:]]*=[[:space:]]*"1\.2\.3"' \ + || fail "shared payload version was not stamped" + +say "Linux arm64 self-test passed" From 863f371e68cee5cf6dcd04abf16fe14bbb1a7978 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 5 Aug 2026 14:38:10 -0400 Subject: [PATCH 06/12] CLOSES #806, CLOSES #809, CLOSES #853, CLOSES #854, CLOSES #860, CLOSES #862, CLOSES #865, CLOSES #866 --- data/scripts/flavor/pewter_city.lua | 8 +- data/scripts/flavor/viridian_city.lua | 8 +- data/scripts/story3.lua | 57 +++- data/scripts/story4.lua | 55 ++- data/scripts/story6.lua | 8 +- data/scripts/yellow_jessie_james.lua | 57 +++- docs/new-features.md | 25 ++ src/battle/BattleState.lua | 20 +- src/battle/Status.lua | 24 +- src/core/SaveData.lua | 54 ++- src/core/TouchControls.lua | 65 +++- src/import/LauncherSettings.lua | 12 + src/import/LauncherView.lua | 40 ++- src/import/RomImporter.lua | 7 + src/script/Commands.lua | 23 ++ src/ui/DexEntryMenu.lua | 26 +- src/ui/OptionsMenu.lua | 29 +- src/world/OverworldController.lua | 60 +++- tests/drivers/boulder_trainer_bug809_test.lua | 222 ++++++++++++ tests/drivers/dojo_balls_bug853_test.lua | 209 ++++++++++++ tests/drivers/fighting_dojo_bug197_test.lua | 14 +- .../drivers/game_corner_grunt_bug862_test.lua | 321 ++++++++++++++++++ tests/drivers/jessie_james_bug866_test.lua | 177 ++++++++++ tests/engine/disable_same_turn_bug860.lua | 211 ++++++++++++ .../engine/options_write_readback_bug828.lua | 88 +++++ 25 files changed, 1746 insertions(+), 74 deletions(-) create mode 100644 tests/drivers/boulder_trainer_bug809_test.lua create mode 100644 tests/drivers/dojo_balls_bug853_test.lua create mode 100644 tests/drivers/game_corner_grunt_bug862_test.lua create mode 100644 tests/drivers/jessie_james_bug866_test.lua create mode 100644 tests/engine/disable_same_turn_bug860.lua diff --git a/data/scripts/flavor/pewter_city.lua b/data/scripts/flavor/pewter_city.lua index b5047092..eb525b7d 100644 --- a/data/scripts/flavor/pewter_city.lua +++ b/data/scripts/flavor/pewter_city.lua @@ -16,9 +16,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end M.PEWTER_CITY = { diff --git a/data/scripts/flavor/viridian_city.lua b/data/scripts/flavor/viridian_city.lua index b9e476da..f78626f8 100644 --- a/data/scripts/flavor/viridian_city.lua +++ b/data/scripts/flavor/viridian_city.lua @@ -23,9 +23,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end M.VIRIDIAN_CITY = { diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 56567a50..8cca471b 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -517,6 +517,14 @@ M.GAME_CORNER = { done() return end + -- GameCornerRocketText hands the battle its own loss line through + -- SaveEndBattleTextPointers (.BattleEndText -> + -- _GameCornerRocketBattleEndText, "Dang!"), and PrintEndBattleText + -- prints it ON the battle screen between TrainerDefeatedText and + -- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory). + -- He is a text_asm trainer with no def_trainers header, so there is no + -- header.won for engageTrainer to find and the line has to be handed + -- over here or it never shows at all (#862). ow:engageTrainer(npc, function() if not ow:trainerDefeated(npc) then done() @@ -527,19 +535,44 @@ M.GAME_CORNER = { game.data.text._GameCornerRocketAfterBattleText or "Our hideout might\nbe discovered! I\nbetter tell BOSS!", function() - -- #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) + -- #198/#862: GameCornerRocketBattleScript (scripts/GameCorner.asm) + -- picks the exit walk from where the player is standing, because + -- the grunt on (9,5) has to get past him: wYCoord == 6 (talked to + -- from the south) or wXCoord == 8 (from the west) leaves the row + -- clear and takes GameCornerMovement_Rocket_WalkDirect, five steps + -- RIGHT; otherwise the player is east of him on (10,5) and + -- GameCornerMovement_Rocket_WalkAroundPlayer steps DOWN, right, UP + -- and right again to go AROUND him. pokeyellow's copy of the + -- around-path takes one extra RIGHT on the lower row before coming + -- back up (it also has to clear Pikachu); both versions end on + -- (15,5). He never steps UP: (9,4) is the poster wall, which is + -- where the old single UP step sent him. + local px = ow.player and ow.player.cellX + local py = ow.player and ow.player.cellY + local path + if py == 6 or px == 8 then + path = { { "right", 5 } } + elseif require("src.core.GameVersion").isYellow() then + path = { { "down", 1 }, { "right", 3 }, { "up", 1 }, { "right", 3 } } + else + path = { { "down", 1 }, { "right", 2 }, { "up", 1 }, { "right", 4 } } + end + -- GameCornerRocketExitScript only HideObjects him once + -- BIT_SCRIPTED_NPC_MOVEMENT clears, i.e. after the last step. + -- scriptMove locks player input (#scriptMoves>0) and ignores + -- collision, so the despawn + unfreeze (done) ride the final step. + local function step(i) + if i > #path then + hideRocket() + done() + return + end + ow:scriptMove(npc, path[i][1], path[i][2], + function() step(i + 1) end) + end + step(1) end)) - end) + end, game.data.text._GameCornerRocketBattleEndText or "Dang!") end, -- GameCornerClerk1Text (scripts/GameCorner.asm): the offer, a -- YesNoChoice, then ¥1000 for 50 coins. Yellow drops the "1" from the diff --git a/data/scripts/story4.lua b/data/scripts/story4.lua index 874a2b9c..110a3cae 100644 --- a/data/scripts/story4.lua +++ b/data/scripts/story4.lua @@ -13,9 +13,18 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- The question stays on screen under the YES/NO menu. The dojo prize +-- balls are the clearest case: FightingDojoHitmonleePokeBallText +-- (scripts/FightingDojo.asm) is `call PrintText` on a text_end string -- +-- no prompt, so no WaitForTextScrollButtonPress -- immediately followed +-- by `call YesNoChoice`, and InitYesNoTextBoxParameters +-- (engine/menus/text_box.asm) puts the menu above the dialogue box +-- rather than replacing it. Ride TextBox's opts.choice, the same as +-- Commands.ask, instead of popping the box with an A press and leaving a +-- bare ChoiceBox over the overworld (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end -- fill the extracted text placeholders ({NUM:...}, {RAM:...}, {PLAYER}) @@ -155,19 +164,35 @@ local function dojoBall(species, ownBall, otherBall, askKey) push(game, "You'll have to\nbeat the master\nfirst!", done) return end - ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) - if not yes then done() return end - flags["EVENT_GOT_" .. species] = true - flags.EVENT_DEFEATED_FIGHTING_DOJO = true - 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) - push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) - end) + local function offer() + ask(game, t[askKey] or ("You want\n" .. species .. "?"), function(yes) + if not yes then done() return end + flags["EVENT_GOT_" .. species] = true + flags.EVENT_DEFEATED_FIGHTING_DOJO = true + 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) + push(game, ("%s got\n%s!"):format(game.save.player.name, species), done) + end) + end + -- FightingDojoHitmonleePokeBallText / ...HitmonchanPokeBallText run + -- `ld a, HITMONLEE / call DisplayPokedex` BEFORE .Text and YesNoChoice, + -- so the ball opens the prize's dex page first and the offer follows + -- it. _DisplayPokedex (engine/events/display_pokedex.asm) sets only + -- the SEEN bit, so the page stays the name-and-sprite preview until + -- the mon is owned, the same shape as the Fuchsia exhibit signs in + -- data/scripts/flavor/fuchsia_city.lua (#853). + local dex = game.save.pokedex + if dex then + dex.seen = dex.seen or {} + dex.seen[species] = true + end + require("src.ui.Screens").push(game, "DexEntryMenu", + { species = species, onClose = offer }) end end diff --git a/data/scripts/story6.lua b/data/scripts/story6.lua index e28c1230..21de982e 100644 --- a/data/scripts/story6.lua +++ b/data/scripts/story6.lua @@ -12,9 +12,13 @@ local function push(game, s, done) game.stack:push(TextBox.new(game, s, done)) end +-- PrintText on a text_end string returns with the box still drawn and +-- YesNoChoice then draws the menu above it (InitYesNoTextBoxParameters, +-- engine/menus/text_box.asm); no A press clears the question first. Ride +-- TextBox's opts.choice, the same as Commands.ask (#854). local function ask(game, s, cb) - local ChoiceBox = require("src.ui.ChoiceBox") - push(game, s, function() game.stack:push(ChoiceBox.new(game, cb)) end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, s, nil, { choice = cb })) end -- ------------------------------------------------------------------- diff --git a/data/scripts/yellow_jessie_james.lua b/data/scripts/yellow_jessie_james.lua index c1e4f226..7379012e 100644 --- a/data/scripts/yellow_jessie_james.lua +++ b/data/scripts/yellow_jessie_james.lua @@ -62,10 +62,15 @@ M.MT_MOON_B2F = { { "walk_npc", 6, { "left", "left", "left", "left", "left" } }, { "face_object", 6, "left" }, { "show_text", "_MtMoonJessieJamesText2" }, + -- MtMoonB2FScript12 arms _MtMoonJessieJamesText3 with + -- SaveEndBattleTextPointers before it sets wCurOpponent, so + -- TrainerBattleVictory prints it on the battle screen as "ROCKET: A + -- brat beat us?" between TrainerDefeatedText and MoneyForWinningText. + -- Its one-word first line only reads right behind that tag (#866). + { "save_end_battle_text", "_MtMoonJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 42 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_MtMoonJessieJamesText3" }, { "show_text", "_MtMoonJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -85,7 +90,8 @@ M.MT_MOON_B2F = { -- motto plays from off-screen FIRST, then the duo pops in at (25,10) / -- (24,10) and whichever of them shares the player's column ($18=24 or -- $19=25, EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT) walks the three --- tiles down to loom over the player while the other steps one. A loss +-- tiles down to loom over the player while the other walks four and ends +-- up beside him. A loss -- re-hides them (RocketHideoutB4FResetScripts via EVENT_6A0), so the -- trigger re-arms clean. -- ------------------------------------------------------------------- @@ -106,7 +112,7 @@ M.ROCKET_HIDEOUT_B4F = { if f.EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES then return false end -- ON_LEFT: player under James's column (25); movement data pairs -- RocketHideoutB4FJessieJamesMovementData_45605/45606 swap so the - -- column-mate walks 3, the other 1. + -- column-mate walks 3, the other 4. local onLeft = (x == 25) ow.runner:run({ { "stop_music" }, @@ -116,16 +122,30 @@ M.ROCKET_HIDEOUT_B4F = { { "emote", "player", "shock", 30 }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JAMES" }, { "show_object", "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_JESSIE" }, - -- James (object 2) then Jessie (object 3), Script4..Script9 order - { "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, + -- James (object 2) then Jessie (object 3), Script4..Script9 order. + -- RocketHideoutB4FJessieJamesMovementData_45605 is a lone $4 that FALLS + -- THROUGH into _45606 ($4 $4 $4 $ff), so MoveSprite_ (home/pathfinding.asm) + -- reads _45605 as FOUR steps and _45606 as three; $4 is DOWN in Yellow's + -- Func_5288 lookup (engine/overworld/movement.asm), which walks with no + -- collision test. From (25,10)/(24,10) against a player on y=14 the + -- column-mate stops three down, right above him, and the other walks the + -- full four to stand alongside -- which is what the facings below assume. + -- Reading _45605 as a single step stranded whoever was off-column three + -- tiles away, so James never reached the player (#865). + { "walk_npc", 2, onLeft and { "down", "down", "down" } + or { "down", "down", "down", "down" } }, { "face_object", 2, onLeft and "down" or "left" }, - { "walk_npc", 3, onLeft and { "down" } or { "down", "down", "down" } }, + { "walk_npc", 3, onLeft and { "down", "down", "down", "down" } + or { "down", "down", "down" } }, { "face_object", 3, onLeft and "right" or "down" }, { "show_text", "_RocketHideoutJessieJamesText2" }, + -- RocketHideoutB4FScript10 saves _RocketHideoutJessieJamesText3 as the + -- end-battle text, so it prints as "ROCKET: Such a dreadful twerp!" on + -- the battle screen ahead of MoneyForWinningText (#866). + { "save_end_battle_text", "_RocketHideoutJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 43 }, { "check_battle_result", "win" }, { "jump_if_false", "lost" }, - { "show_text", "_RocketHideoutJessieJamesText3" }, { "show_text", "_RocketHideoutJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -175,16 +195,27 @@ M.POKEMON_TOWER_7F = { { "show_text", "_PokemonTowerJessieJamesText1" }, { "face_player_dir", "up" }, { "emote", "player", "shock", 30 }, - -- Jessie (object 1) then James (object 2), Script1..Script6 order - { "walk_npc", 1, onLeft and { "down" } or { "down", "down", "down" } }, + -- Jessie (object 1) then James (object 2), Script1..Script6 order. + -- Same fall-through blob as the hideout: PokemonTower7FMovementData_60d7a + -- is a lone $4 running into _60d7b ($4 $4 $4 $FF), so _60d7a is FOUR + -- steps and _60d7b is three. From (10,8)/(11,8) against a player on + -- y=12 the column-mate halts one tile above him and the other closes the + -- full four to his side; the single-step reading is why James only + -- "moved a bit" here (#865). + { "walk_npc", 1, onLeft and { "down", "down", "down", "down" } + or { "down", "down", "down" } }, { "face_object", 1, onLeft and "right" or "down" }, - { "walk_npc", 2, onLeft and { "down", "down", "down" } or { "down" } }, + { "walk_npc", 2, onLeft and { "down", "down", "down" } + or { "down", "down", "down", "down" } }, { "face_object", 2, onLeft and "down" or "left" }, { "show_text", "_PokemonTowerJessieJamesText2" }, + -- PokemonTower7FScript7 saves _PokemonTowerJessieJamesText3 as the + -- end-battle text: "ROCKET: You will regret this!" on the battle screen, + -- before the prize money (#866). + { "save_end_battle_text", "_PokemonTowerJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 44 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_PokemonTowerJessieJamesText3" }, { "show_text", "_PokemonTowerJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, @@ -254,10 +285,12 @@ M.SILPH_CO_11F = { { "walk_npc", 6, jessieDirs }, { "face_object", 6, jessieFace }, { "show_text", "_SilphCoJessieJamesText2" }, + -- SilphCo11FScript11 saves _SilphCoJessieJamesText3 (SilphCo11FText_624c2) + -- as the end-battle text: "ROCKET: Like always..." before the money (#866). + { "save_end_battle_text", "_SilphCoJessieJamesText3" }, { "start_battle", "trainer", "OPP_ROCKET", 45 }, { "check_battle_result", "win" }, { "jump_if_false", "end" }, - { "show_text", "_SilphCoJessieJamesText3" }, { "show_text", "_SilphCoJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, diff --git a/docs/new-features.md b/docs/new-features.md index cea7bdb3..77552928 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -332,6 +332,26 @@ one used sideways. An `options.lua` from before this split keeps its single layout in both orientations until one of them is edited. In-game, Options → **TOUCH PAD** toggles the same on/off flag without leaving a play session. +## Haptic feedback (mobile) + +Options → **VIBRATION** (also in the launcher's gear menu) buzzes the device +the instant an on-screen control takes a button (#806). A glass pad has no +edges under a thumb, so the pulse is what tells you the press landed: +sliding the d-pad from one direction to the next buzzes again, a second +finger landing on a button that is already held does not, and releasing +never does. + +Four levels: **OFF**, **LIGHT** (the default), **MEDIUM**, **HEAVY**. +"Intensity" is really a pulse length -- the platform call takes a duration +and nothing else -- so LIGHT is a 12 ms tick, MEDIUM 25 ms, HEAVY 45 ms. +Stepping the row fires one sample pulse at the level you land on, so the +three can be compared without leaving the menu. On iOS the system +vibration has one fixed length, so all three levels feel the same there and +the row is effectively on/off. The setting lives in `options.lua` and the +row only appears where the on-screen pad can (Android/iOS, or desktop with +`POKEPORT_TOUCH=1`, where it does nothing since desktop LOVE has no +vibrator). + ## Screen orientation lock (Android) Options → **ORIENTATION** (also in the launcher's gear menu) locks the @@ -585,6 +605,11 @@ settings gear and pulses when an update is waiting, instead of sitting in a banner at the bottom of the page that you had to scroll to notice. Checking for updates from there shows a loader like everything else. +**A quit button.** An X sits to the right of the settings gear and closes +the app cleanly, the same shutdown path as the window's close button. Mostly +for platforms where reaching the window chrome is awkward (Android, Steam +Deck, fullscreen desktops). + **The look.** Black background, white outlines, no gradients or glows, and buttons that are solid colour-coded keys: green commits, blue navigates, red destroys, yellow wants attention. The three game tabs keep their red, blue diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index c0aaf737..da79b2b7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -3188,6 +3188,16 @@ function BattleState:executeAction(user, target, action) user.boundTurns = target.trappingTurns and math.max(1, target.trappingTurns) or nil + -- wPlayerSelectedMove / wEnemySelectedMove as the status gauntlet + -- reads it: the locked specials keep continuing the move they + -- started, so they carry a move id too. Resolved once here and + -- handed to every statusInterrupt below, which is where + -- .TriedToUseDisabledMoveCheck lives (#860). + local selectedId = action.id + or (action.special == "trapping" and user.trapMove) + or (action.special == "bide" and "BIDE") + or nil + -- trainer class AI actions (engine/battle/trainer_ai.asm) if action.special == "aiItem" then self.aiUses = (self.aiUses or 1) - 1 @@ -3245,17 +3255,17 @@ function BattleState:executeAction(user, target, action) return end if action.special == "trapping" then - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:continueTrapping(user, target) return end if action.special == "bide" then - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:continueBide(user, target) return end - if self:statusInterrupt(user, target) then return end + if self:statusInterrupt(user, target, selectedId) then return end self:performMove(user, target, action, false) end run() @@ -3349,8 +3359,8 @@ end -- Runs Status.beforeMove plus the shared interruption bookkeeping; -- returns true when the user's action is interrupted. -function BattleState:statusInterrupt(user, target) - local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self) +function BattleState:statusInterrupt(user, target, selectedId) + local canMove, msgs, selfHit = Status.beforeMove(user, self.rng, self, selectedId) for _, m in ipairs(msgs) do self:sayStatusMsg(user, m) end if selfHit then -- confusion self-hit (core.asm:3428-3434): clears everything in diff --git a/src/battle/Status.lua b/src/battle/Status.lua index a42062ae..90dd8d74 100644 --- a/src/battle/Status.lua +++ b/src/battle/Status.lua @@ -168,7 +168,7 @@ end -- The active status record's beforeMove runs at its priority slot: above -- VOLATILE_PRIORITY before the held/disable/confusion block (sleep, -- freeze), at or below after it (paralysis) -- the original's order. -function Status.beforeMove(battler, rng, battle) +function Status.beforeMove(battler, rng, battle, selectedMoveId) local mon = battler.mon -- Haze curing this mon's sleep/freeze forfeits its pending move for -- the turn, silently (haze.asm writes $ff/CANNOT_MOVE to the selected @@ -225,6 +225,28 @@ function Status.beforeMove(battler, rng, battle) end end end + -- .TriedToUseDisabledMoveCheck (engine/battle/core.asm, and the enemy + -- copy .checkIfTriedToUseDisabledMove): the disabled-move test runs at + -- EXECUTION time, comparing wPlayerDisabledMoveNumber against the + -- already SELECTED move, so a Disable that lands earlier in the same + -- turn still blocks the slower mon's move (#860). It sits after the + -- confusion block and before the paralysis roll, so a confusion self-hit + -- still pre-empts it and the paralysis roll is never spent on a turn the + -- disable eats. PrintMoveIsDisabledText clears CHARGING_UP before + -- printing, so a disabled charge move drops its stored turn instead of + -- releasing later. + if selectedMoveId and battler.disabledSlot then + local disabled = (battler.curMoves or {})[battler.disabledSlot] + if disabled and disabled.id == selectedMoveId then + battler.charging, battler.chargeReady = nil, nil + local moves = battle and battle.data and battle.data.moves + local shown = moves and moves[selectedMoveId] and moves[selectedMoveId].name + or tostring(selectedMoveId) + table.insert(msgs, romText(battle and battle.data, "_MoveIsDisabledText", + "%s's\n%s is\ndisabled!", name(battler), shown)) + return false, msgs + end + end if handler then local canMove, selfHit = runStatus() if not canMove or selfHit then return canMove, msgs, selfHit end diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index e05ac52b..d9c0569d 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -30,6 +30,15 @@ local SaveData = {} -- deliberately shared across versions (it holds global preferences and the -- mod enable-state, not per-playthrough data). local OPTIONS_FILENAME = "options.lua" +-- #828: options.lua is rewritten whole on every write (see saveOptions), and +-- unlike the progress files it had no staged copy, so a write interrupted +-- between the truncate and the flush -- the process replaced by +-- HostShell.restart on the way back to the launcher, an Android +-- external-storage volume that never flushed -- left a truncated or empty +-- file that loadOptions could only answer with defaults: every setting +-- "reset" at once. Same .bak/.tmp witness names the save files use. +local OPTIONS_BACKUP_FILENAME = OPTIONS_FILENAME .. ".bak" +local OPTIONS_TMP_FILENAME = OPTIONS_FILENAME .. ".tmp" -- Main / backup / staged-witness names for a version (defaults to the active -- one). The backup is a rolling copy and .tmp is the staged-write witness; @@ -305,6 +314,13 @@ function SaveData.defaultOptions() -- layout (#633). Pre-#633 files stored one top-level positions table; -- TouchControls.normalizeConfig folds it into both orientations on load. touchControls = { enabled = true }, + -- Haptic feedback level for on-screen pad presses (#806): + -- off | light | medium | heavy, mapped to a love.system.vibrate + -- duration in src/core/TouchControls.lua. LIGHT by default, like the + -- overlay itself defaulting on, so an options.lua predating this key + -- gets the tick without going looking for the row. Inert wherever the + -- overlay never appears (desktop) or LOVE has no vibrator. + haptics = "light", } end @@ -369,7 +385,21 @@ function SaveData.saveOptions(opts, fs) opts.modOptions = merged end local encoded = SaveSerializer.encode(opts) - local ok, err = fs.write(OPTIONS_FILENAME, encoded) + -- Stage the new bytes and roll the last good file aside BEFORE the main + -- write truncates it, the same tmp/bak dance SaveData.save uses for + -- progress: whatever ends the process mid-write, one of the three copies + -- is complete and loadOptions promotes it instead of falling back to + -- defaults (#828). + local ok, err = fs.write(OPTIONS_TMP_FILENAME, encoded) + if not ok then + Logger.error("options save failed: %s", tostring(err)) + return nil + end + local prev = fs.getInfo(OPTIONS_FILENAME) and fs.read(OPTIONS_FILENAME) + if type(prev) == "string" and prev ~= "" and prev ~= encoded then + fs.write(OPTIONS_BACKUP_FILENAME, prev) + end + ok, err = fs.write(OPTIONS_FILENAME, encoded) if not ok then Logger.error("options save failed: %s", tostring(err)) return nil @@ -387,6 +417,8 @@ function SaveData.saveOptions(opts, fs) #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") return nil end + -- the staged witness has served its purpose; the main file is verified + remove(fs, OPTIONS_TMP_FILENAME) return opts end @@ -397,6 +429,26 @@ function SaveData.loadOptions(fs) if fs.getInfo(OPTIONS_FILENAME) then Logger.error("options load failed: %s", tostring(err)) end + -- #828: answering defaults here is what "closing the game reset all my + -- settings" looked like -- one interrupted whole-file rewrite and every + -- preference, the mod enable-state and the slot registry were gone. + -- Promote the staged copy, then the rolled-aside backup, exactly as + -- SaveData.load does for progress, and heal the main file from whichever + -- one parsed. + local recovered = readTable(fs, OPTIONS_TMP_FILENAME) + local from = "tmp" + if not recovered then + recovered = readTable(fs, OPTIONS_BACKUP_FILENAME) + from = "bak" + end + if recovered then + Logger.warn("options.lua %s; recovered from %s copy", + fs.getInfo(OPTIONS_FILENAME) and "corrupt" or "missing", from) + if fs.write then + fs.write(OPTIONS_FILENAME, SaveSerializer.encode(recovered)) + end + return SaveData.mergeOptions(recovered) + end return SaveData.defaultOptions() end return SaveData.mergeOptions(data) diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index efdfee22..a2e3b22b 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -85,6 +85,55 @@ local function clampScale(v) return v end +-- Haptic feedback (#806): a short vibration the instant a control takes a GB +-- button, the way every mobile emulator front-end does it -- the pad has no +-- edges under a thumb, so the buzz is the only confirmation a press landed. +-- Persisted as options.haptics (src/core/SaveData.lua defaultOptions), NOT +-- under options.touchControls: TouchControls:config() is the launcher +-- editor's save snapshot and only emits enabled + layouts, so a nested key +-- would be dropped on every editor save. +-- love.system.vibrate takes a duration and nothing else, so "intensity" is a +-- duration preset: Android runs the platform vibrator for exactly that long, +-- while iOS ignores the duration and fires the fixed system vibration, so +-- there the three levels all read as simply on. +TouchControls.HAPTICS = { "off", "light", "medium", "heavy" } +TouchControls.HAPTIC_DEFAULT = "light" + +local HAPTIC_SECONDS = { off = 0, light = 0.012, medium = 0.025, heavy = 0.045 } +local HAPTIC_LABELS = { + off = "OFF", light = "LIGHT", medium = "MEDIUM", heavy = "HEAVY", +} + +function TouchControls.normalizeHaptics(level) + if HAPTIC_SECONDS[level] then return level end + return TouchControls.HAPTIC_DEFAULT +end + +function TouchControls.hapticLabel(level) + return HAPTIC_LABELS[TouchControls.normalizeHaptics(level)] +end + +function TouchControls.cycleHaptics(level, dir) + local cur, idx = TouchControls.normalizeHaptics(level), 1 + for i, m in ipairs(TouchControls.HAPTICS) do + if m == cur then idx = i break end + end + local n = #TouchControls.HAPTICS + return TouchControls.HAPTICS[(idx - 1 + (dir or 1)) % n + 1] +end + +-- One pulse at the given level. Feature-guarded rather than platform-gated: +-- love.system.vibrate is a no-op on desktop and absent from the headless love +-- stubs, so the press path below stays identical everywhere and the tests +-- never reach a vibrator. +function TouchControls.buzz(level) + local secs = HAPTIC_SECONDS[TouchControls.normalizeHaptics(level)] + if not secs or secs <= 0 then return false end + if not (love and love.system and love.system.vibrate) then return false end + pcall(love.system.vibrate, secs) + return true +end + -- Copy a persisted positions table, dropping unknown / non-numeric entries. -- Always a fresh table: two orientations seeded from the same pre-#633 -- layout must not alias, or dragging one would still move the other. @@ -174,6 +223,10 @@ end function TouchControls:init() self.active = wantsOverlay() self.enabled = true + -- vibration level for presses (#806); applyOptions overwrites it from + -- options.haptics, this is the value a harness that never applies options + -- runs with + self.haptics = TouchControls.HAPTIC_DEFAULT -- per-orientation buckets (#633); self.positions / self.scale mirror the -- one currently on screen so layout(), the editor and the tests keep a -- single lookup @@ -210,6 +263,9 @@ end function TouchControls:applyOptions(opts) local cfg = TouchControls.normalizeConfig(opts and opts.touchControls) self.enabled = cfg.enabled + -- haptics is a plain top-level option, not part of the layout config the + -- launcher editor round-trips through config() (#806) + self.haptics = TouchControls.normalizeHaptics(opts and opts.haptics) self.layouts = cfg.layouts self.layoutW, self.layoutH = nil, nil self.layoutOx, self.layoutOy = nil, nil @@ -401,7 +457,14 @@ end local function pressBtn(self, btn) local n = (self.held[btn] or 0) + 1 self.held[btn] = n - if n == 1 then Input:overlayPressed(btn) end + -- Buzz only on the 0 -> 1 edge, the same edge that presses the GB button: + -- a second finger landing on a button that is already held, and a d-pad + -- finger resting inside one direction, must not retrigger it. Sliding the + -- d-pad to a new direction does, which is the point (#806). + if n == 1 then + Input:overlayPressed(btn) + TouchControls.buzz(self.haptics) + end end local function releaseBtn(self, btn) diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 6de23c77..45292922 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -242,6 +242,18 @@ local function coreRows(opts) opts.touchControls = tc return true end) + -- VIBRATION sits with it (#806): same gate, same subsystem. Stepping + -- the row buzzes once at the level being selected. + local okTC, TC = pcall(require, "src.core.TouchControls") + if okTC then + add(Strings("VIBRATION"), + function() return Strings(TC.hapticLabel(opts.haptics)) end, + function(dir) + opts.haptics = TC.cycleHaptics(opts.haptics, dir) + TC.buzz(opts.haptics) + return true + end) + end end end diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 7be70f26..0ae0d5b0 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -311,8 +311,20 @@ local function setPage(imp, key, v) imp._pages[key] = v end +-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has +-- no guaranteed glyph, and the launcher ships no icon asset for it. +local function drawCross(x, y, size, color) + love.graphics.push("all") + love.graphics.setColor(color) + love.graphics.setLineWidth(math.max(2, size * 0.16)) + love.graphics.setLineJoin("bevel") + love.graphics.line(x, y, x + size, y + size) + love.graphics.line(x + size, y, x, y + size) + love.graphics.pop() +end + -- ------------------------------------------------------------- header --- Rail, logo row (settings on the right), tab bar. +-- Rail, logo row (settings and quit on the right), tab bar. -- Returns the y at which content may start. Its vertical arithmetic is -- mirrored by headerHeight() at the bottom of this file (the short-window -- scroll decision needs the height before anything draws) -- keep in sync. @@ -340,7 +352,14 @@ local function buildHeader(imp, m) local rx = m.x + m.w - m.pad local by = y + (rowH - gear) / 2 - -- Settings gear, top-right corner. + -- The right cluster is laid out right to left -- Quit outermost, the gear + -- inboard of it -- but the two are REGISTERED gear first, because the first + -- focusable of the first frame adopts the keyboard ring and that must not be + -- the button that exits the app. + local quitX = rx - gear + rx = quitX - math.floor(6 * m.s) + + -- Settings gear. imp._gearIcon = imp._gearIcon or love.graphics.newImage("assets/launcher/gear.png") rx = rx - gear @@ -365,6 +384,23 @@ local function buildHeader(imp, m) end end + -- Quit, top-right corner. + do + local x = quitX + Kit._audit("control", x, by, gear, gear, "quit") + local focused = Kit.focusable("quit", x, by, gear, gear) + local hot = focused or Kit.hover(x, by, gear, gear) + Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1) + Theme.stroke(x, by, gear, gear, PAL.line, + hot and Theme.A.focus or Theme.A.hairline, 1) + local pad = math.floor(gear * 0.32) + drawCross(x + pad, by + pad, gear - 2 * pad, + hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 }) + if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then + queueAction(imp, "quit", function() imp:_quitApp() end) + end + end + -- The self-update control lives in the FOOTER next to the BCG mark (small, -- out of the wordmark's way -- it used to overlap the logo on a phone). It -- still GLOWS through Kit.button when there is something to act on. diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 20b2071e..eb46ad29 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2459,6 +2459,13 @@ function RomImporter:_openSettings() if ok and model then self._settings = model end end +-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's +-- love.quit hook still runs: that is where the worker threads are shut down +-- (#339) and where a launcher close is told apart from a running game's (#785). +function RomImporter:_quitApp() + if love.event and love.event.quit then love.event.quit() end +end + function RomImporter:_closeSettings() if self._settings then self._settings.save() end self._settings = nil diff --git a/src/script/Commands.lua b/src/script/Commands.lua index 20792244..9148583e 100644 --- a/src/script/Commands.lua +++ b/src/script/Commands.lua @@ -260,6 +260,26 @@ function Commands.take_item(ctx, itemId, count) if inv[itemId] == 0 then inv[itemId] = nil end end +-- save_end_battle_text : SaveEndBattleTextPointers +-- (home/trainers.asm, called from e.g. RocketHideoutB4FScript10 just before +-- wCurOpponent is set). The armed line is the trainer's OWN loss line and +-- belongs on the battle screen: PrintEndBattleText (home/trainers.asm) runs +-- from TrainerBattleVictory (engine/battle/core.asm) after +-- TrainerDefeatedText and the pic scroll but BEFORE MoneyForWinningText, and +-- TrainerEndBattleText prints _TrainerNameText first so the line opens with +-- the "CLASS: " tag. Scripts that printed it with a plain show_text after +-- start_battle got it a box too late -- after the payout -- and untagged +-- (#866). Arms exactly one battle; start_battle consumes it. +function Commands.save_end_battle_text(ctx, textId) + local text = ctx.game.data.text[textId] + if not text and ctx.overworld then + text = ctx.game.data:resolveText(ctx.overworld.map.def.label, textId) + end + -- BattleState takes finished text, so expand {PLAYER}/{RIVAL} here the + -- way OverworldState:engageTrainer does for the sight/talk path + ctx.endBattleText = TextBox.substitute(ctx.game, text or textId) +end + -- start_battle "wild" species level | start_battle "trainer" OPP_CLASS partyIndex function Commands.start_battle(ctx, kind, a, b) local BattleState = require("src.battle.BattleState") @@ -270,6 +290,9 @@ function Commands.start_battle(ctx, kind, a, b) else battle = BattleState.newTrainer(ctx.game, a, b) end + -- one SaveEndBattleTextPointers arms one battle; leaving it set would leak + -- the line into the next scripted fight + battle.endBattleText, ctx.endBattleText = ctx.endBattleText, nil battle.onFinish = function(result) ctx.lastBattleResult = result ctx.lastCheck = result == "win" diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 11e0d808..152ae7ce 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -2,10 +2,13 @@ -- dex description (data/pokemon/dex_entries.asm + dex_text.asm). -- -- `species` may be a species id string, or a table --- `{ species = id, forceOwned = true }`. forceOwned mirrors pret's --- StarterDex (engine/events/starter_dex.asm), which temporarily sets the --- owned bit so Oak's lab ball previews show height/weight/description --- without permanently marking the mon owned. +-- `{ species = id, forceOwned = true, onClose = fn }`. forceOwned mirrors +-- pret's StarterDex (engine/events/starter_dex.asm), which temporarily sets +-- the owned bit so Oak's lab ball previews show height/weight/description +-- without permanently marking the mon owned. onClose fires once the page +-- is dismissed, for callers that push this screen from a plain callback +-- instead of a script runner (the push_screen command yields on the runner +-- instead, src/script/Commands.lua). local Font = require("src.render.Font") local Strings = require("src.core.Strings") @@ -26,14 +29,16 @@ end local function resolveArgs(speciesOrOpts) if type(speciesOrOpts) == "table" then return speciesOrOpts.species or speciesOrOpts[1], - speciesOrOpts.forceOwned and true or false + speciesOrOpts.forceOwned and true or false, + speciesOrOpts.onClose end - return speciesOrOpts, false + return speciesOrOpts, false, nil end function DexEntryMenu.new(game, speciesOrOpts) - local species, forceOwned = resolveArgs(speciesOrOpts) - local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) + local species, forceOwned, onClose = resolveArgs(speciesOrOpts) + local self = setmetatable({ game = game, forceOwned = forceOwned, + onClose = onClose }, DexEntryMenu) self.def = game.data.pokemon[species] local path, trueColor = require("src.pokemon.Sprites").path( game.data, species, "front", { kind = "dex" }) @@ -52,6 +57,11 @@ function DexEntryMenu:update(dt) local input = self.game.input if input:wasPressed("a") or input:wasPressed("b") then self.game.stack:pop() + -- onClose resumes a callback-style caller after the page closes: the + -- dojo prize balls print their offer only once DisplayPokedex returns + -- (data/scripts/story4.lua, #853). Script rows do not need it, they + -- yield on push_screen's waitingCheck instead. + if self.onClose then self.onClose() end end end diff --git a/src/ui/OptionsMenu.lua b/src/ui/OptionsMenu.lua index 56379f7f..1187dc6c 100644 --- a/src/ui/OptionsMenu.lua +++ b/src/ui/OptionsMenu.lua @@ -437,6 +437,25 @@ local function buildRows(game) require("src.core.TouchControls"):applyOptions(o) return true end }, + -- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT / + -- MEDIUM / HEAVY, where the intensity is a vibration duration -- + -- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below, + -- since the only thing that buzzes is a virtual button press. + { id = "haptics", label = Strings("VIBRATION"), + value = function(g) + local TC = require("src.core.TouchControls") + return Strings(TC.hapticLabel(g.save.options.haptics)) + end, + step = function(g, dir) + local o = g.save.options + local TC = require("src.core.TouchControls") + o.haptics = TC.cycleHaptics(o.haptics, dir) + TC:applyOptions(o) + -- sample the level being selected: stepping the row is the only way + -- to compare LIGHT against HEAVY without leaving the menu + TC.buzz(o.haptics) + return true + end }, } -- issue #136: hide GBC FX on Android/iOS -- the present shader soft-bricks if not GBCFX.isSupported() then @@ -454,8 +473,10 @@ local function buildRows(game) end rows = filtered end - -- TOUCH PAD only where the overlay can appear (mobile, or desktop with - -- POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off everywhere. + -- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or + -- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off + -- everywhere. VIBRATION rides the same gate: nothing else in the port + -- vibrates, and love.system.vibrate is a no-op on desktop anyway. do local env = os.getenv("POKEPORT_TOUCH") local osName = love.system and love.system.getOS and love.system.getOS() @@ -464,7 +485,9 @@ local function buildRows(game) if not show then local filtered = {} for _, row in ipairs(rows) do - if row.id ~= "touchControls" then filtered[#filtered + 1] = row end + if row.id ~= "touchControls" and row.id ~= "haptics" then + filtered[#filtered + 1] = row + end end rows = filtered end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ebca790e..ff56ae22 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -1259,8 +1259,14 @@ end function OverworldState:checkBoulderPush(dir) local p = self.player local fx, fy = Collision.target(p.cellX, p.cellY, dir) - local npc = self:npcAtCell(fx, fy) - if not npc or not Map.isPushable(npc.def) or npc.moving then + -- IsSpriteInFrontOfPlayer (home/overworld.asm) hands TryPushingBoulder + -- the LOWEST sprite index standing on the faced cell, so in the original a + -- second sprite parked on the boulder's cell hides the boulder from the + -- push path for the rest of the map visit. Pick the pushable sprite out of + -- the cell instead: a scripted walk-up that lands a trainer on the boulder + -- must not brick it permanently (#809). + local npc = self:pushableAtCell(fx, fy) + if not npc or npc.moving then self.boulderTried = nil -- pokered resets when no boulder is in front return false end @@ -1720,6 +1726,22 @@ function OverworldState:npcAtCell(cx, cy) return nil end +-- The Strength boulder on a cell, ignoring anything else standing there. +-- npcAtCell returns whichever object the map listed first, which is only +-- well defined while at most one sprite occupies a cell; scripted walks +-- (TrainerWalkUpToPlayer) can break that, and the push path must still find +-- the boulder underneath (#809). +function OverworldState:pushableAtCell(cx, cy) + for _, npc in ipairs(self.npcs) do + if ((npc.cellX == cx and npc.cellY == cy) or + (npc.targetX == cx and npc.targetY == cy)) + and Map.isPushable(npc.def) then + return npc + end + end + return nil +end + -- what the A press resolved to, for world.interacted's listeners local function interacted(self, fx, fy, kind, target) Runtime.emit("world.interacted", { mapId = self.map.id, x = fx, y = fy, @@ -2962,7 +2984,7 @@ local function meetTrainerTheme(cls) end -- Run the pre-battle text -> battle -> won text -> flags sequence. -function OverworldState:engageTrainer(npc, onDone) +function OverworldState:engageTrainer(npc, onDone, endBattleText) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -2972,7 +2994,15 @@ function OverworldState:engageTrainer(npc, onDone) battleText = select(1, Game.data:resolveText(self.map.def.label, d.text)) or Strings("I like shorts!\nThey're comfy and\neasy to wear!") end - local wonText = header and header.won and Game.data.text[header.won] + -- `endBattleText` is a caller-supplied stand-in for header.won: the + -- text_asm trainers that hand their loss line to the battle through + -- SaveEndBattleTextPointers (scripts/GameCorner.asm GameCornerRocketText + -- passes _GameCornerRocketBattleEndText, "Dang!") have no def_trainers + -- header for the extractor to read, so their script passes the finished + -- line here and it still lands where PrintEndBattleText puts it -- between + -- TrainerDefeatedText and MoneyForWinningText, on the battle screen (#862). + local wonText = endBattleText + or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") Game.stack:push(TextBox.new(Game, battleText, function() @@ -3241,8 +3271,26 @@ function OverworldState:startTrainerApproach(npc, dist) self.emote = { npc = npc, frames = 60, onDone = function() - if dist > 1 then - self:scriptMove(npc, npc.facing, dist - 1, fight) + -- TrainerWalkUpToPlayer (engine/overworld/trainer_sight.asm) writes + -- dist-1 NPC_MOVEMENT_* bytes and hands them to MoveSprite, and every + -- scripted step skips collision entirely (CanWalkOntoTile, + -- engine/overworld/movement.asm: "always allow walking if the + -- movement is scripted"), so the original marches the trainer straight + -- through a Strength boulder sitting on the sight line. Stop one cell + -- short of the boulder instead: two sprites on one cell is a state the + -- push path cannot represent, and the walk-up is the one scripted move + -- the player can steer a boulder into (#809). + local steps = dist - 1 + local cx, cy = npc.cellX, npc.cellY + for i = 1, steps do + cx, cy = Collision.target(cx, cy, npc.facing) + if self:pushableAtCell(cx, cy) then + steps = i - 1 + break + end + end + if steps > 0 then + self:scriptMove(npc, npc.facing, steps, fight) else fight() end diff --git a/tests/drivers/boulder_trainer_bug809_test.lua b/tests/drivers/boulder_trainer_bug809_test.lua new file mode 100644 index 00000000..52229aee --- /dev/null +++ b/tests/drivers/boulder_trainer_bug809_test.lua @@ -0,0 +1,222 @@ +-- A trainer's walk-up must stop short of a Strength boulder, and the boulder +-- must still be pushable afterwards (#809). TrainerWalkUpToPlayer (pokered +-- engine/overworld/trainer_sight.asm) writes dist-1 movement bytes that skip +-- collision, so the trainer used to park ON the boulder, and after that +-- IsSpriteInFrontOfPlayer (home/overworld.asm) handed TryPushingBoulder the +-- trainer instead of the rock. POKEPORT_DRIVER=tests/drivers/boulder_trainer_bug809_test.lua POKEPORT_IDENTITY=bug809 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . + +-- No POKEPORT_SPEED: the sighting, the "!" bubble and the walk-up all run at +-- the normal 60 Hz logic clock so the stop-short frame is the one a player +-- would see. The setup pushes are slow for the same reason; the run takes +-- about half a minute of real time before it hands the pad over. +return function(game) + local U = dofile("tests/drivers/util.lua") + + -- pokered data/maps/objects/VictoryRoad3F.asm: + -- object_event 13, 3, SPRITE_COOLTRAINER_F, STAY, RIGHT, ..., OPP_COOLTRAINER_F, 3 + -- object_event 22, 3, SPRITE_BOULDER, STAY, BOULDER_MOVEMENT_BYTE_2, ... + -- Her header range is 4 (data/generated/trainer_headers.lua VictoryRoad3F[4]), + -- so she spots the player anywhere on row 3 within four cells to her east and + -- then walks dist-1 cells toward him. Row 3 is walled at x=19, so BOULDER1 + -- cannot simply be shoved west into her sight line: it has to go down column + -- 22 to row 6, west along row 6, and back up column 17 onto row 3. + local MAP = "VICTORY_ROAD_3F" + local MAP_LABEL = "VictoryRoad3F" + local BOULDER = "VICTORYROAD3F_BOULDER1" + local TRAINER = "VICTORYROAD3F_COOLTRAINER_F2" + local START = { x = 22, y = 2, facing = "down" } + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local pass = true + local function check(label, ok) + if not ok then pass = false end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + -- Hold `btn` until `cond` goes true or the budget runs out, then release and + -- let any half-finished step land. Boulder pushes need a held direction: + -- handleInput only reaches checkBoulderPush while the player already faces + -- that way, and TryPushingBoulder arms on one poll and moves on the next + -- (BIT_TRIED_PUSH_BOULDER), so a single tap can never shift a rock. + local function holdUntil(btn, cond, budget) + local first = true + for _ = 1, budget or 600 do + if cond() then break end + if first then table.insert(game.input.pressQueue, btn); first = false end + game.input.state[btn] = true + coroutine.yield() + end + game.input.state[btn] = false + for _ = 1, 40 do + if not game.overworld.player.moving and #game.overworld.scriptMoves == 0 then + break + end + coroutine.yield() + end + U.wait(4) -- an input-free poll re-arms turning in place (wCheckFor180DegreeTurn) + return cond() + end + + U.teleport(game, MAP, START.x, START.y, START.facing) + U.wait(10) + local ow = game.overworld + local rock = findNpc(ow, BOULDER) + local trainer = findNpc(ow, TRAINER) + + check("BOULDER1 loaded on " .. MAP, rock ~= nil) + check("COOLTRAINER_F2 loaded on " .. MAP, trainer ~= nil) + if not (rock and trainer) then + U.log("map objects missing; nothing to drive") + while true do coroutine.yield() end + end + check("BOULDER1 starts at the asm cell (22,3)", + rock.cellX == 22 and rock.cellY == 3) + check("COOLTRAINER_F2 starts at the asm cell (13,3) facing right", + trainer.cellX == 13 and trainer.cellY == 3 and trainer.facing == "right") + check("checkBoulderPush resolves through pushableAtCell", + type(ow.pushableAtCell) == "function") + + local header = game.data:trainerHeader(MAP_LABEL, trainer.def.index) + local range = header and header.range or 0 + check("her sight range is 4 cells", range == 4) + + -- The whole route, so a map or tileset edit shows up here instead of as a + -- driver that quietly wanders off. If a cell is not walkable the boulder + -- cannot be pushed onto it (CheckForCollisionWhenPushingBoulder reuses the + -- player's passability check) and the run is not worth continuing. + local ROUTE = { + { 22, 4 }, { 22, 5 }, { 22, 6 }, { 23, 5 }, { 23, 6 }, + { 21, 6 }, { 20, 6 }, { 19, 6 }, { 18, 6 }, { 17, 6 }, + { 18, 7 }, { 17, 7 }, { 17, 5 }, { 17, 4 }, { 17, 3 }, + { 18, 4 }, { 18, 3 }, { 16, 3 }, { 16, 4 }, { 16, 2 }, + } + local routeOk = true + for _, c in ipairs(ROUTE) do + if not ow.map:isWalkableCell(c[1], c[2]) then + routeOk = false + U.log("route cell not walkable:", c[1], c[2]) + end + end + check("the push route is walkable end to end", routeOk) + + -- STRENGTH is live for the map visit. BIT_STRENGTH_ACTIVE is what + -- TryPushingBoulder gates on -- it never re-reads badges or party moves -- + -- so setting the field-move state is the whole grant (see the comment in + -- OverworldState:checkBoulderPush). + ow.strengthActive = true + + -- Victory Road rolls a wild encounter on every completed step, not just in + -- grass (wild_encounters.asm counts caves as indoor), and this run walks + -- twenty-odd cells with an empty party. Drop the map's table: a wild + -- battle mid-route interrupts the push with a screen transition and has + -- nothing to do with what is being checked. + game.data.encounters[MAP] = nil + + if not pass then + U.log("setup checks already failed; not driving the push") + while true do coroutine.yield() end + end + + local function boulderAt(x, y) + return function() return rock.cellX == x and rock.cellY == y end + end + local function playerAt(x, y) + local p = ow.player + return function() return p.cellX == x and p.cellY == y end + end + + -- down column 22 to row 6 + holdUntil("down", boulderAt(22, 6), 400) + check("boulder pushed down column 22 to (22,6)", rock.cellX == 22 and rock.cellY == 6) + -- around to its east side + holdUntil("right", playerAt(23, 5), 120) + holdUntil("down", playerAt(23, 6), 120) + -- west along row 6 to the column that reaches row 3 + holdUntil("left", boulderAt(17, 6), 700) + check("boulder pushed west along row 6 to (17,6)", rock.cellX == 17 and rock.cellY == 6) + -- around to its south side + holdUntil("down", playerAt(18, 7), 120) + holdUntil("left", playerAt(17, 7), 120) + -- up column 17 onto her row + holdUntil("up", boulderAt(17, 3), 400) + check("boulder pushed up column 17 onto row 3 at (17,3)", + rock.cellX == 17 and rock.cellY == 3) + if not pass then + U.log("the boulder never reached her row; the race below cannot happen") + while true do coroutine.yield() end + end + + -- Step onto row 3 one cell out of range (18 - 13 = 5 > 4) so the sighting + -- happens on the push itself and not a moment earlier. + holdUntil("right", playerAt(18, 4), 120) + holdUntil("up", playerAt(18, 3), 120) + check("player waiting at (18,3), one cell outside her range", + ow.player.cellX == 18 and ow.player.cellY == 3 and not ow.engaging) + + -- The engage lands on a battle we are not going to fight: stand in for it, + -- record where the walk-up stopped, and mark her beaten the way winning + -- would. Everything the walk-up does has already happened by this point. + local stopped + local realEngage = ow.engageTrainer + ow.engageTrainer = function(self, npc, onDone) + stopped = { npc = npc, x = npc.cellX, y = npc.cellY } + game.save.defeatedTrainers[npc.id] = true + if onDone then onDone() end + end + + -- One push west: the boulder lands on (16,3) and the player follows onto + -- (17,3), four cells from her, which is the frame she spots him on. + holdUntil("left", function() return stopped ~= nil end, 400) + + check("she spotted the player and finished her walk-up", stopped ~= nil) + check("the boulder moved one cell west to (16,3)", + rock.cellX == 16 and rock.cellY == 3) + if stopped then + U.log("she stopped at", stopped.x, stopped.y, "boulder at", rock.cellX, rock.cellY) + check("she is not standing on the boulder cell", + not (stopped.x == rock.cellX and stopped.y == rock.cellY)) + check("she stopped one cell short of it, at (15,3)", + stopped.x == 15 and stopped.y == 3) + check("the push path still finds the boulder under that cell", + ow:pushableAtCell(rock.cellX, rock.cellY) == rock) + check("nothing else shares the boulder's cell", + ow:npcAtCell(rock.cellX, rock.cellY) == rock) + end + U.shot(game, SHOT_DIR .. "/bug809_walkup_stop.png") + + -- ...and the rock still moves. Push it north, the one free direction left: + -- west is her, east is the player, south is where he came from. + holdUntil("down", playerAt(17, 4), 120) + holdUntil("left", playerAt(16, 4), 120) + holdUntil("up", boulderAt(16, 2), 400) + if not check("the boulder is still pushable after the engage", + rock.cellX == 16 and rock.cellY == 2) then + U.log("boulder ended at", rock.cellX, rock.cellY, "player at", + ow.player.cellX, ow.player.cellY) + end + holdUntil("down", playerAt(16, 4), 120) + U.shot(game, SHOT_DIR .. "/bug809_still_pushable.png") + + ow.engageTrainer = realEngage + U.log(pass and "ALL CHECKS PASSED" or "SOME CHECKS FAILED") + + U.log("On screen: the COOLTRAINER stands at (15,3) with a one-cell gap") + U.log("between her and the rock, which now sits at (16,2), one row up from") + U.log("where she stopped. The near miss to watch for is her sprite ending") + U.log("the walk-up on top of the rock, or standing clear of it but leaving") + U.log("it inert: walk into the rock from any side and it should still shift") + U.log("a cell. Her battle was stubbed out and she is flagged as beaten;") + U.log("re-run the driver to watch the race again from the start.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/dojo_balls_bug853_test.lua b/tests/drivers/dojo_balls_bug853_test.lua new file mode 100644 index 00000000..211e1256 --- /dev/null +++ b/tests/drivers/dojo_balls_bug853_test.lua @@ -0,0 +1,209 @@ +-- Driver: Fighting Dojo prize balls, #853 (dex page first) and #854 (the +-- question stays on screen under YES/NO). pokered scripts/FightingDojo.asm +-- runs `ld a, HITMONLEE / call DisplayPokedex` before .Text, and .Text is a +-- text_end string printed with PrintText immediately followed by YesNoChoice. +-- No POKEPORT_SPEED here: the dex page and the YES/NO pop are what is judged. +-- SHOT_DIR=/tmp/shots POKEPORT_DRIVER=tests/drivers/dojo_balls_bug853_test.lua POKEPORT_IDENTITY=bug853 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 TextBox = require("src.render.TextBox") + local ChoiceBox = require("src.ui.ChoiceBox") + local DexEntryMenu = require("src.ui.DexEntryMenu") + local MapScripts = require("src.script.MapScripts") + local Screens = require("src.ui.Screens") + local OW = require("src.world.OverworldController") + local Pokemon = require("src.pokemon.Pokemon") + + -- pokered data/maps/objects/FightingDojo.asm: the two SPRITE_POKE_BALL + -- objects sit at (4, 1) HITMONLEE and (5, 1) HITMONCHAN, on the north wall + -- under the posters. The only approach is from the mat below them. + local MAP = "FIGHTING_DOJO" + local LEE = { name = "FIGHTINGDOJO_HITMONLEE_POKE_BALL", x = 4, y = 1 } + local CHAN = { name = "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", x = 5, y = 1 } + local START = { x = 4, y = 4 } -- walk up from here to (4, 2), facing LEE + + local failures = {} + local function check(cond, msg) + if cond then U.log("PASS", msg) else + failures[#failures + 1] = msg + U.log("FAIL", msg) + end + return cond + end + + local function topIs(mt) return getmetatable(game.stack:top()) == mt end + local function under() + return game.stack.states[#game.stack.states - 1] + end + + local function npcByName(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + end + + local function pageText() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return "" end + local page = top.pages and top.pages[top.pageIndex] + return page and table.concat(page, "\n") or "" + end + + local function waitFor(cond, cap) + for _ = 1, (cap or 200) do + if cond() then return true end + U.wait(2) + end + return cond() + end + + local function mashUntil(cond, cap) + for _ = 1, (cap or 100) do + if cond() then return true end + U.tap(game, "a") + U.wait(2) + end + return cond() + end + + -- fresh dojo with the master already beaten and neither prize taken + local function seed(x, y, facing) + while game.stack:top() do game.stack:pop() end + game.save.flags = { + EVENT_BEAT_KARATE_MASTER = true, + 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 = { FIGHTING_DOJO_obj_1 = true } + game.save.objectToggles = {} + game.save.player.name = game.save.player.name or "RED" + -- one mon so give_pokemon has a party to append to, and room for a prize + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) } + game.stack:push(OW, MAP, x, y, facing or "up") + U.wait(10) + return game.stack:top() + end + + local ow = seed(START.x, START.y, "up") + + ------------------------------------------------------------------ + -- machine-checkable half: seed, objects, script rows, text, screen id + ------------------------------------------------------------------ + check(game.save.flags.EVENT_BEAT_KARATE_MASTER == true, + "EVENT_BEAT_KARATE_MASTER is set (the balls answer at all)") + check(not game.save.flags.EVENT_GOT_HITMONLEE + and not game.save.flags.EVENT_GOT_HITMONCHAN, + "neither prize taken yet (no 'greedy' refusal path)") + + local leeBall, chanBall = npcByName(ow, LEE.name), npcByName(ow, CHAN.name) + check(leeBall ~= nil, "HITMONLEE ball object loaded") + check(chanBall ~= nil, "HITMONCHAN ball object loaded") + check(leeBall and leeBall.cellX == LEE.x and leeBall.cellY == LEE.y, + ("HITMONLEE ball sits at the asm cell (%d, %d)"):format(LEE.x, LEE.y)) + check(chanBall and chanBall.cellX == CHAN.x and chanBall.cellY == CHAN.y, + ("HITMONCHAN ball sits at the asm cell (%d, %d)"):format(CHAN.x, CHAN.y)) + + check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL")) + == "function", + "TEXT_FIGHTINGDOJO_HITMONLEE_POKE_BALL has a hand-ported talk script") + check(type(MapScripts.talkScript(MAP, "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL")) + == "function", + "TEXT_FIGHTINGDOJO_HITMONCHAN_POKE_BALL has a hand-ported talk script") + + -- the ask() string is the extracted descriptor, not the "You want X?" stub + local leeText = game.data.text._FightingDojoHitmonleePokeBallText + local chanText = game.data.text._FightingDojoHitmonchanPokeBallText + check(type(leeText) == "string" and leeText ~= "", + "_FightingDojoHitmonleePokeBallText resolves") + check(type(chanText) == "string" and chanText ~= "", + "_FightingDojoHitmonchanPokeBallText resolves") + if type(leeText) == "string" then + U.log("lee prompt reads:", (leeText:gsub("\n", " / "))) + end + local dexOk = pcall(Screens.get, game, "DexEntryMenu") + check(dexOk, "DexEntryMenu resolves through the Screens registry") + + ------------------------------------------------------------------ + -- rehearsal on the HITMONCHAN ball, answered NO so nothing is consumed + ------------------------------------------------------------------ + if chanBall then + ow:talkTo(chanBall) + check(waitFor(function() return topIs(DexEntryMenu) end, 60), + "#853: the ball opens the HITMONCHAN dex page before any question") + U.shot(game, DIR .. "/dojo_balls_1_dex.png") + U.tap(game, "b") + check(waitFor(function() return topIs(TextBox) end, 60), + "#853: closing the dex page leads into the offer text") + mashUntil(function() return topIs(ChoiceBox) end, 60) + check(topIs(ChoiceBox), "#854: the YES/NO menu opens on the offer") + check(getmetatable(under()) == TextBox, + "#854: the question box is still on the stack under the YES/NO menu") + U.shot(game, DIR .. "/dojo_balls_2_choice.png") + U.tap(game, "b") -- B answers NO; the prize stays unclaimed + waitFor(function() return game.stack:top() == ow end, 120) + check(not game.save.flags.EVENT_GOT_HITMONCHAN, + "answering NO leaves the HITMONCHAN prize unclaimed") + check(#game.save.party == 1, "answering NO adds nothing to the party") + end + + ------------------------------------------------------------------ + -- hand-off: walk to the HITMONLEE ball and open it for real + ------------------------------------------------------------------ + ow = seed(START.x, START.y, "up") + for _ = 1, 12 do + if ow.player.cellY <= LEE.y + 1 then break end + U.hold(game, "up", 16) + U.wait(4) + end + + local function facingTheBall() + local cur = game.overworld + local ball = cur and npcByName(cur, LEE.name) + if not ball then return false end + local fx, fy = cur.player:facingCell() + return cur:npcAtCell(fx, fy) == ball + end + + if not facingTheBall() then + -- a map edit or a mod moved the ball: stand on any free walkable + -- neighbour instead. {dx, dy, facing} is the offset from the ball to + -- the stand cell plus the direction that looks back at it. + local sides = { + { 0, 1, "up" }, { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = LEE.x + s[1], LEE.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log("walk up stopped short; standing on", cx, cy, "facing", s[3]) + ow = seed(cx, cy, s[3]) + break + end + end + end + check(facingTheBall(), "player is standing against the HITMONLEE ball") + + U.tap(game, "a") + check(waitFor(function() return topIs(DexEntryMenu) end, 60), + "#853: pressing A opens the HITMONLEE dex page") + U.shot(game, DIR .. "/dojo_balls_3_handoff.png") + + if #failures == 0 then + U.log("all checks passed") + else + U.log(("%d check(s) failed:"):format(#failures), table.concat(failures, "; ")) + end + + U.log("On screen now: the HITMONLEE dex page the ball opened, name and") + U.log("sprite only, since the mon is seen but not owned yet. Press B: the") + U.log("offer types out, and the YES/NO menu should appear above it with the") + U.log("question still readable -- the old bug swapped the text away for a") + U.log("bare YES/NO over the overworld. Answer YES to take HITMONLEE; the") + U.log("HITMONCHAN ball beside it stays put and gives the greedy refusal.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/fighting_dojo_bug197_test.lua b/tests/drivers/fighting_dojo_bug197_test.lua index ce529e1d..8d59f849 100644 --- a/tests/drivers/fighting_dojo_bug197_test.lua +++ b/tests/drivers/fighting_dojo_bug197_test.lua @@ -3,7 +3,9 @@ -- BUG1 gate -- the master stops the player on the tile to his left -- 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 +-- BUG4 (verify) -- the ball opens the prize's dex preview first +-- (FightingDojo.asm DisplayPokedex, #853) and then +-- asks with the Gen1 descriptor text -- 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 @@ -20,6 +22,7 @@ return function(game) local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" local TextBox = require("src.render.TextBox") local ChoiceBox = require("src.ui.ChoiceBox") + local DexEntryMenu = require("src.ui.DexEntryMenu") local OW = require("src.world.OverworldController") local Pokemon = require("src.pokemon.Pokemon") local Commands = require("src.script.Commands") @@ -35,6 +38,7 @@ return function(game) local function topIsTextBox() return getmetatable(game.stack:top()) == TextBox end local function topIsChoice() return getmetatable(game.stack:top()) == ChoiceBox end + local function topIsDex() return getmetatable(game.stack:top()) == DexEntryMenu end local function currentPageText() local top = game.stack:top() @@ -172,8 +176,14 @@ return function(game) check(leeBall ~= nil and chanBall ~= nil, "BUG5: both prize balls on the mat") if leeBall then ow:talkTo(leeBall) + -- DisplayPokedex runs before .Text and YesNoChoice in FightingDojo.asm, + -- so the dex page is the first thing the ball opens (#853) + U.wait(3) + check(topIsDex(), "BUG4: the ball opens the HITMONLEE dex entry first") + U.shot(game, DIR .. "/dojo_4_dexentry.png") + mashUntil(function() return not topIsDex() end, 20) check(sawText("hard kicking") or sawText("HITMONLEE"), - "BUG4: ball asks the Gen1 descriptor prompt (no dex entry)") + "BUG4: the dex page is followed by the Gen1 descriptor prompt") U.shot(game, DIR .. "/dojo_4_prompt.png") ------------------------------------------------------------------ -- BUG5: choose YES -> only the chosen ball vanishes; the other stays diff --git a/tests/drivers/game_corner_grunt_bug862_test.lua b/tests/drivers/game_corner_grunt_bug862_test.lua new file mode 100644 index 00000000..3c00bcfa --- /dev/null +++ b/tests/drivers/game_corner_grunt_bug862_test.lua @@ -0,0 +1,321 @@ +-- Driver: #862 Celadon Game Corner poster grunt, loss line + exit walk. +-- GameCornerRocketText saves _GameCornerRocketBattleEndText ("Dang!") for +-- PrintEndBattleText, and GameCornerRocketBattleScript picks the exit walk +-- from the player's cell (pokered/scripts/GameCorner.asm:54-102): east of +-- him it is WalkAroundPlayer, DOWN/R/R/UP/R/R/R/R, never UP into the poster. +-- No POKEPORT_SPEED: the walk and the battle text are what is under test. +-- SHOT_DIR=/tmp/shots POKEPORT_IDENTITY=bug862 POKEPORT_TOUCH=0 \ +-- POKEPORT_DRIVER=tests/drivers/game_corner_grunt_bug862_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 BattleState = require("src.battle.BattleState") + + local pass, fail = 0, 0 + local function check(label, ok, detail) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label, detail or "") + return ok + end + + -- pokered/data/maps/objects/GameCorner.asm:36 -- the grunt is + -- object_event 9, 5, SPRITE_ROCKET, STAY, UP, facing the poster bg_event + -- at (9,4), which is wall. Standing east of him on (10,5) is the branch + -- that matters: wYCoord ~= 6 and wXCoord ~= 8, so the script takes + -- GameCornerMovement_Rocket_WalkAroundPlayer. + local MAP = "GAME_CORNER" + local NAME = "GAMECORNER_ROCKET" + local GX, GY = 9, 5 + local STAND = { x = 10, y = 5, facing = "left" } + local POSTER = { x = 9, y = 4 } + -- DOWN, RIGHT, RIGHT, UP, RIGHT x4 from (9,5), ending on (15,5) + local AROUND = { + { 9, 6 }, { 10, 6 }, { 11, 6 }, { 11, 5 }, + { 12, 5 }, { 13, 5 }, { 14, 5 }, { 15, 5 }, + } + + -- clean slate: he must not read as already defeated or already 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" + game.save.money = game.save.money or 3000 + + -- a tank that one-shots OPP_ROCKET #7, so the mash win below is quick and + -- the same every run whatever the type matchups are + 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 } + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + local ow = game.overworld + + local function findGrunt() + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == NAME then return n end + end + return nil + end + + local grunt = findGrunt() + check("GAMECORNER_ROCKET is on the floor", grunt ~= nil) + if grunt then + check("he stands on (9,5)", grunt.cellX == GX and grunt.cellY == GY, + ("at (%d,%d)"):format(grunt.cellX, grunt.cellY)) + end + + -- a map edit or a mod could take (10,5) away; anything east of him keeps + -- the WalkAroundPlayer branch, so fall back to a free walkable neighbour + -- and say which branch that lands on + local function facingGrunt() + local g = findGrunt() + if not g then return false end + local fx, fy = ow.player:facingCell() + return ow:npcAtCell(fx, fy) == g + end + if grunt and not facingGrunt() then + local sides = { + { 1, 0, "left" }, { 0, 1, "up" }, { -1, 0, "right" }, { 0, -1, "down" }, + } + for _, s in ipairs(sides) do + local cx, cy = grunt.cellX + s[1], grunt.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("(%d,%d) is blocked; standing on"):format(STAND.x, STAND.y), + cx, cy, "facing", s[3]) + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + grunt = findGrunt() + break + end + end + end + check("the player is face to face with him", facingGrunt()) + local px, py = ow.player.cellX, ow.player.cellY + local around = not (py == 6 or px == 8) + U.log(("talking from (%d,%d): the script should take %s"):format( + px, py, around and "WalkAroundPlayer (down, right, right, up, " + .. "right x4)" or "WalkDirect (right x5)")) + + -- the two strings the fix depends on, and the poster cell the pre-fix + -- single UP step walked him into + local t = game.data.text + check("_GameCornerRocketBattleEndText resolves", + type(t._GameCornerRocketBattleEndText) == "string" + and t._GameCornerRocketBattleEndText ~= "", + tostring(t._GameCornerRocketBattleEndText)) + check("_GameCornerRocketAfterBattleText resolves", + type(t._GameCornerRocketAfterBattleText) == "string" + and t._GameCornerRocketAfterBattleText ~= "") + check("(9,4) is the poster wall, not a cell he can stand on", + not ow.map:isWalkableCell(POSTER.x, POSTER.y)) + + -- engageTrainer has to accept the script-supplied loss line; a stale + -- two-parameter copy would silently drop it and print nothing + local info = debug.getinfo(ow.engageTrainer, "S") + local sigOk = false + if info and info.short_src then + local src = io.open((info.short_src:gsub("^@", "")), "r") + if src then + local n = 0 + for line in src:lines() do + n = n + 1 + if n == info.linedefined then + sigOk = line:find("endBattleText", 1, true) ~= nil + break + end + end + src:close() + end + end + check("engageTrainer takes an endBattleText argument", sigOk) + + U.shot(game, DIR .. "/bug862_0_before.png") + + -- Talk and mash to a win, recording every battle message in order and + -- pausing on the loss line long enough to photograph it. + local said, battle = {}, nil + local lastSaid, dangShot = nil, false + local function sample() + local top = game.stack:top() + if getmetatable(top) == BattleState then + battle = battle or top + local cur = top.current + local text = type(cur) == "table" and cur.text + if type(text) == "string" and text ~= lastSaid then + lastSaid = text + said[#said + 1] = text + U.log("battle says:", (text:gsub("\n", " "))) + end + 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 + + local function idle() + return game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and not ow.transitioning + end + + U.tap(game, "a") + local sawAfter = false + for f = 1, 4000 do + sample() + if pageText():find("hideout", 1, true) then sawAfter = true break end + local top = game.stack:top() + if lastSaid and lastSaid:find("Dang", 1, true) and not dangShot then + -- stop mashing for a moment: the loss line is on the battle screen. + -- The row is picked up the frame it starts typing, so let it finish + -- before the capture or the shot is one letter wide. + dangShot = true + U.wait(60) + U.shot(game, DIR .. "/bug862_1_dang.png") + elseif 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 a stalled battle") + top.onFinish("win") + if game.stack:top() == top then game.stack:pop() end + end + else + U.tap(game, "a") + end + U.wait(2) + sample() + end + check("reached the after-battle 'hideout' line", sawAfter) + check("the battle carried the script's loss line", + battle ~= nil and type(battle.endBattleText) == "string" + and battle.endBattleText:find("Dang", 1, true) ~= nil, + battle and tostring(battle.endBattleText) or "no battle seen") + + -- PrintEndBattleText sits between TrainerDefeatedText and + -- MoneyForWinningText (engine/battle/core.asm TrainerBattleVictory) + local iDefeat, iDang, iMoney + for i, line in ipairs(said) do + if not iDefeat and line:find("defeated", 1, true) then iDefeat = i end + if not iDang and line:find("Dang", 1, true) then iDang = i end + if not iMoney and line:find("winning", 1, true) then iMoney = i end + end + check("the loss line printed on the battle screen", iDang ~= nil) + check("it printed with the ROCKET: name tag", + iDang ~= nil and said[iDang]:find(":", 1, true) ~= nil, + iDang and said[iDang] or "") + check("order is defeated -> Dang! -> payout", + iDefeat ~= nil and iDang ~= nil and iMoney ~= nil + and iDefeat < iDang and iDang < iMoney, + ("defeated=%s dang=%s payout=%s"):format(tostring(iDefeat), + tostring(iDang), + tostring(iMoney))) + U.shot(game, DIR .. "/bug862_2_afterbattle.png") + + -- Dismiss the after-battle box and watch the exit walk cell by cell. + U.tap(game, "a") + local visited, order, lowShot = {}, {}, false + local function mark(cx, cy) + local key = cx .. "," .. cy + if not visited[key] then + visited[key] = true + order[#order + 1] = key + end + end + -- the last step's hide_object rides its own onDone, so the grunt leaves + -- ow.npcs on the frame he lands: count the cell he is walking INTO as + -- visited too, or the destination never shows up in the sample + local last = { GX, GY } + for _ = 1, 900 do + local g = findGrunt() + if g then + mark(g.cellX, g.cellY) + last = { g.cellX, g.cellY } + if g.targetX and g.targetY then + mark(g.targetX, g.targetY) + last = { g.targetX, g.targetY } + end + if g.cellY > GY and not lowShot then + lowShot = true + U.shot(game, DIR .. "/bug862_3_walk.png") + end + elseif idle() then + break + 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 .. "/bug862_4_gone.png") + + U.log("cells he stood on:", table.concat(order, " ")) + check("he never stood on the poster cell (9,4)", + not visited[POSTER.x .. "," .. POSTER.y]) + check("he never stepped north of his start row", (function() + for key in pairs(visited) do + local y = tonumber(key:match(",(%d+)$")) + if y and y < GY then return false end + end + return true + end)()) + if around then + check("he stepped down to (9,6) to get past the player", visited["9,6"]) + check("he came back up onto row 5 and finished on (15,5)", + last[1] == 15 and last[2] == 5, + ("last seen on (%d,%d)"):format(last[1], last[2])) + else + check("he walked straight along row 5 to (15,5)", + last[1] == 15 and last[2] == 5 and not visited["9,6"], + ("last seen on (%d,%d)"):format(last[1], last[2])) + end + local toggles = game.save.objectToggles.GAME_CORNER + check("he despawned only after the last step", findGrunt() == nil) + check("his objectToggle is hidden", + toggles ~= nil and toggles.GAMECORNER_ROCKET == false) + check("he is recorded as defeated", + game.save.defeatedTrainers["GAME_CORNER_obj_11"] == true) + U.log(("checks: %d passed, %d failed"):format(pass, fail)) + + -- Hand the pad over on a clean copy of the same setup so the whole beat + -- can be watched at speed. + game.save.defeatedTrainers = {} + game.save.objectToggles.GAME_CORNER = nil + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.log("You are east of the grunt again, facing him. Press A and win.") + U.log("Right looks like: he says his piece on the battle screen after") + U.log("\"RED defeated ROCKET!\" -- one box, \"ROCKET: Dang!\" -- and the") + U.log("¥ payout comes after it, not before. Then the hideout line, then") + U.log("he steps DOWN off row 5, right past you, back up and out east.") + U.log("The near miss to watch for: he steps UP into the poster, or the") + U.log("Dang! box turns up in the overworld after the battle has torn down.") + U.log("Talk to him from (9,6) below instead and he takes the straight") + U.log("five-step version east; both are correct, the branch is your cell.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/jessie_james_bug866_test.lua b/tests/drivers/jessie_james_bug866_test.lua new file mode 100644 index 00000000..ec415d3b --- /dev/null +++ b/tests/drivers/jessie_james_bug866_test.lua @@ -0,0 +1,177 @@ +-- Manual check of the Rocket Hideout B4F Jessie & James ambush: James walks +-- the full four tiles to the player's side (#865) and their loss line prints +-- on the battle screen before the prize money (#866). +-- pokeyellow scripts/RocketHideoutB4F.asm (MovementData_45605 falls through +-- into _45606) and data/maps/objects/RocketHideoutB4F.asm. No fast-forward: +-- POKEPORT_DRIVER=tests/drivers/jessie_james_bug866_test.lua POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Commands = require("src.script.Commands") + local GameVersion = require("src.core.GameVersion") + local mapScripts = require("data.scripts.init") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "ROCKET_HIDEOUT_B4F" + local BEAT = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES" + local JAMES, JESSIE = "ROCKETHIDEOUTB4F_JAMES", "ROCKETHIDEOUTB4F_JESSIE" + -- RocketHideoutB4FScript_455a5 fires on wYCoord $e with wXCoord $18 or $19. + -- x=24 leaves EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT clear, which is + -- the branch that hands the four-step blob to James (object 2, spawned at + -- 25,10) and the three-step one to Jessie (object 3, at 24,10). + local TRIGGER = { x = 24, y = 14 } + local EXPECT = { + [JAMES] = { x = 25, y = 14, facing = "left" }, + [JESSIE] = { x = 24, y = 13, facing = "down" }, + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running the Yellow cache (the duo exists nowhere else)", + GameVersion.isYellow()) + if not GameVersion.isYellow() then + U.log("re-run with POKEPORT_VERSION=yellow; nothing below will be true") + end + + local hooks = mapScripts.get(MAP) + check("yellow_jessie_james registered an onStep for " .. MAP, + type(hooks) == "table" and type(hooks.onStep) == "function") + check("their talk entries are registered too", + type(hooks) == "table" and type(hooks.talk) == "table" + and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JAMES ~= nil + and hooks.talk.TEXT_ROCKETHIDEOUTB4F_JESSIE ~= nil) + + -- the #866 fix is a new script verb; if a mod shadowed it or the registry + -- never picked it up, the row would silently no-op and the line would come + -- back after the money instead of before it + local verb = Commands.resolve(game.data, "save_end_battle_text") + check("save_end_battle_text resolves as a script verb", type(verb) == "function") + + local texts = {} + for i = 1, 4 do + local key = "_RocketHideoutJessieJamesText" .. i + texts[i] = game.data.text[key] + check(key .. " resolves to a string", + type(texts[i]) == "string" and texts[i] ~= "") + end + if type(texts[3]) == "string" then + U.log("the armed loss line reads:", (texts[3]:gsub("\n", " / "))) + end + + local objs = (game.data.maps[MAP] or {}).objects or {} + local defs = {} + for _, o in ipairs(objs) do + if o.name == JAMES or o.name == JESSIE then defs[o.name] = o end + end + check("James is object 2 of " .. MAP .. ", hidden at (25,10)", + defs[JAMES] ~= nil and defs[JAMES].index == 2 + and defs[JAMES].x == 25 and defs[JAMES].y == 10 + and defs[JAMES].hidden == true) + check("Jessie is object 3, hidden at (24,10)", + defs[JESSIE] ~= nil and defs[JESSIE].index == 3 + and defs[JESSIE].x == 24 and defs[JESSIE].y == 10 + and defs[JESSIE].hidden == true) + + local rocket = game.data.trainers.OPP_ROCKET + check("OPP_ROCKET party 43 (the duo's shared team) exists", + rocket ~= nil and rocket.parties ~= nil and rocket.parties[43] ~= nil) + + -- arm the site: the ambush is gated only on its beat flag, so no story + -- progress is needed to make it live + game.save.flags[BEAT] = nil + game.save.flags.EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = nil + check(BEAT .. " cleared, so the trigger is live", + game.save.flags[BEAT] == nil) + + -- a real party, because the human has to win the battle for the loss line + -- to print at all + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 60), + Pokemon.new(game.data, "NIDOKING", 58), + Pokemon.new(game.data, "STARMIE", 58), + } + game.save.player.name = "RED" + + -- walk in from the north; the two elevator warps sit on row 15, so the + -- approach cannot come from below + U.teleport(game, MAP, TRIGGER.x, TRIGGER.y - 1, "down") + local ow = game.overworld + if not ow.map:isWalkableCell(TRIGGER.x, TRIGGER.y - 1) then + -- a map edit moved the free cell: any walkable neighbour of the trigger + -- works, the script only reads the tile the player lands on + local sides = { { 0, -1, "down" }, { -1, 0, "right" }, { 1, 0, "left" } } + for _, s in ipairs(sides) do + local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2] + if ow.map:isWalkableCell(cx, cy) then + U.log("standing on", cx, cy, "facing", s[3], "instead") + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + U.hold(game, s[3] == "down" and "down" or (s[3] == "right" and "right" or "left"), 20) + break + end + end + else + U.hold(game, "down", 20) + end + U.wait(10) + check("player stepped onto the trigger tile (24,14)", + ow.player.cellX == TRIGGER.x and ow.player.cellY == TRIGGER.y) + check("the ambush script is running", ow.runner:isRunning()) + + U.log("The cutscene is yours now: press A to read, then fight and win.") + U.log("Right looks like both Rockets closing in -- Jessie stopping one tile") + U.log("above you, James coming all the way down to stand at your right -- and") + U.log("after you win, \"ROCKET: Such a dreadful twerp!\" appearing on the") + U.log("battle screen just before the money line. The near-miss to watch for") + U.log("is James halting three tiles up by the wall, or that line showing up") + U.log("in the overworld box after the payout with no ROCKET: tag on it.") + U.log("Two more checks print below as you get to them.") + + local function npcNamed(name) + for _, n in ipairs(game.overworld and game.overworld.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local approachDone, battleSeen = false, false + while true do + if not approachDone then + local j, s = npcNamed(JAMES), npcNamed(JESSIE) + local ow2 = game.overworld + if j and s and not j.moving and not s.moving and ow2 + and #(ow2.scriptMoves or {}) == 0 + and (j.cellY > 10 or s.cellY > 10) then + approachDone = true + check("James walked the full four tiles to (25,14) facing left", + j.cellX == EXPECT[JAMES].x and j.cellY == EXPECT[JAMES].y + and j.facing == EXPECT[JAMES].facing) + check("Jessie stopped three down at (24,13) facing the player", + s.cellX == EXPECT[JESSIE].x and s.cellY == EXPECT[JESSIE].y + and s.facing == EXPECT[JESSIE].facing) + U.log("James at", j.cellX, j.cellY, j.facing, + "Jessie at", s.cellX, s.cellY, s.facing) + U.shot(game, SHOT_DIR .. "/jj866_approach.png") + end + end + if not battleSeen then + local top = game.stack:top() + if getmetatable(top) == BattleState then + battleSeen = true + -- BattleState prints endBattleText between _TrainerDefeatedText and + -- _MoneyForWinningText, so an armed field IS the ordering fix + check("the battle carries the loss line as its end-battle text", + type(top.endBattleText) == "string" and top.endBattleText ~= "" + and top.endBattleText == texts[3]) + if type(top.endBattleText) == "string" then + U.log("armed:", (top.endBattleText:gsub("\n", " / "))) + end + end + end + coroutine.yield() + end +end diff --git a/tests/engine/disable_same_turn_bug860.lua b/tests/engine/disable_same_turn_bug860.lua new file mode 100644 index 00000000..80c4ce65 --- /dev/null +++ b/tests/engine/disable_same_turn_bug860.lua @@ -0,0 +1,211 @@ +-- Disable blocks the move the slower mon ALREADY selected, on the very +-- turn the Disable lands (#860). pokered runs the test at execution +-- time, not at selection time: CheckPlayerStatusConditions +-- .TriedToUseDisabledMoveCheck (engine/battle/core.asm:3437-3447) compares +-- wPlayerDisabledMoveNumber against wPlayerSelectedMove and jumps to +-- ExecutePlayerMoveDone when they match -- "prevents a disabled move that +-- was selected before being disabled from being used", in the asm's own +-- comment. The enemy copy is .checkIfTriedToUseDisabledMove +-- (core.asm:5752+). The port only refused a disabled move at menu time, +-- so the second mover still fired the move it had latched before the +-- Disable resolved. +-- +-- The check sits after the confusion block and before the paralysis roll, +-- so this suite also pins the neighbours: the counter tick that clears an +-- expired Disable still runs first, and a move that was never disabled is +-- untouched. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Data = T.fixtures.fresh() +local Font = require("src.render.Font") +Font.load(Data) +local BattleState = require("src.battle.BattleState") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local TypeChart = require("src.battle.TypeChart") +TypeChart.load(Data) + +-- the fixture dataset has no status move; this dataset is this file's own +-- copy (fixtures.fresh), so registering one here cannot leak into another +-- case. Accuracy 100 keeps DisableEffect's MoveHitTest out of the way. +Data.moves.FIX_DISABLE = { + id = "FIX_DISABLE", index = 90, name = "FIX DISABLE", + type = "NORMAL", power = 0, accuracy = 100, pp = 20, + effect = "DISABLE_EFFECT", +} + +-- Deterministic rolls: the minimum of every range, except DisableEffect's +-- own "1-8 turns disabled" roll (effects.asm:1343-1345), which is pinned +-- at 4. A rolled 1 would be spent by the disabled mon's own counter tick +-- in the same CheckStatusConditions pass -- vanilla behaviour, but it +-- clears the disable before .TriedToUseDisabledMoveCheck can see it, so it +-- is not the case this suite is about. rng(0, 255) -> 0 makes every +-- accuracy roll hit. +local function rolls(disableTurns) + return function(a, b) + if a == 1 and b == 8 then return disableTurns end + if a then return a end + return 0 + end +end + +-- playerFirst decides who lands the Disable; the other side is the one +-- whose already-selected move has to die. Both mons get FIX_TACKLE in +-- slot 1 (the slot DisableEffect picks with the min roll) and FIX_SCRATCH +-- in slot 2 as the never-disabled control. +local function newBattle(playerFirst, disableTurns) + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + local battle = BattleState.newWild(game, "FIXMON_C", 30) + battle.rng = rolls(disableTurns or 4) + + local function loadout(battler) + battler.mon.moves = { + { id = "FIX_TACKLE", pp = 35 }, + { id = "FIX_SCRATCH", pp = 35 }, + { id = "FIX_DISABLE", pp = 20 }, + } + battler.curMoves = battler.mon.moves + end + loadout(battle.player) + loadout(battle.enemy) + + -- no speed tie to resolve: the disabler outruns its target outright + battle.player.curStats.speed = playerFirst and 200 or 1 + battle.enemy.curStats.speed = playerFirst and 1 or 200 + return battle +end + +-- the move instances the sides actually own, so PP decrements land on +-- the party copy the way DecrementPP mutates wBattleMonPP +local function slot(battler, i) return battler.curMoves[i] end + +-- consume the queue the way updateQueue does, minus the presentation +local function drain(battle) + local rows = {} + for _ = 1, 400 do + local item = table.remove(battle.queue, 1) + if not item then return rows end + if item.text then rows[#rows + 1] = { text = item.text } end + if item.fn then + battle.nextInsert = 0 + item.fn() + end + end + error("the turn queue never drained") +end + +local function saidWith(rows, needle) + for i, row in ipairs(rows) do + if row.text and row.text:find(needle, 1, true) then return i end + end + return nil +end + +-- "X's / MOVE is / disabled!" (PrintMoveIsDisabledText) versus +-- DisableEffect's own "MOVE was / disabled!" -- the two lines differ only +-- in that verb, so match on it +local function blocked(rows) return saidWith(rows, "is\ndisabled!") end +local function landed(rows) return saidWith(rows, "was\ndisabled!") end +local function usedTackle(rows) return saidWith(rows, "used FIX TACKLE!") end + +-- --------------------------------------------------------------------- +-- the player Disables first; the foe's latched FIX TACKLE dies this turn +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemyAction = function() return slot(battle.enemy, 1) end + local hpBefore = battle.player.mon.hp + + battle:resolveTurn(slot(battle.player, 3)) + local rows = drain(battle) + + T.check(landed(rows) ~= nil, "the Disable lands") + T.eq(battle.enemy.disabledSlot, 1, "and latches onto the foe's slot 1") + T.check(blocked(rows) ~= nil, + "the foe's already-selected move reports as disabled") + T.check(landed(rows) and blocked(rows) and landed(rows) < blocked(rows), + "in that order: disabled first, then the blocked attempt") + T.check(usedTackle(rows) == nil, + "the disabled move is never announced, so it never executed") + T.eq(battle.player.mon.hp, hpBefore, "and it deals no damage") + T.eq(battle.enemy.disabledTurns, 3, + "the counter ticked once for this turn and the disable is still live") +end + +-- --------------------------------------------------------------------- +-- the same, mirrored: the foe Disables first and the player's latched +-- move dies (core.asm:5752 .checkIfTriedToUseDisabledMove) +-- --------------------------------------------------------------------- +do + local battle = newBattle(false) + battle.enemyAction = function() return slot(battle.enemy, 3) end + local hpBefore = battle.enemy.mon.hp + local ppBefore = slot(battle.player, 1).pp + + battle:resolveTurn(slot(battle.player, 1)) + local rows = drain(battle) + + T.check(landed(rows) ~= nil, "the foe's Disable lands") + T.eq(battle.player.disabledSlot, 1, "on the player's slot 1") + T.check(blocked(rows) ~= nil, "the player's latched move reports as disabled") + T.check(usedTackle(rows) == nil, "and is never announced") + T.eq(battle.enemy.mon.hp, hpBefore, "the foe takes no damage") + T.eq(slot(battle.player, 1).pp, ppBefore, + "and the move that never executed spends no PP (DecrementPP is inside " + .. "the move, past the status gauntlet)") +end + +-- --------------------------------------------------------------------- +-- no regression on the turns after: the disable keeps blocking that move +-- while its counter runs, and a different move still works +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemyAction = function() return slot(battle.enemy, 1) end + battle:resolveTurn(slot(battle.player, 3)) + drain(battle) + T.eq(battle.enemy.disabledTurns, 3, "the disable is live going into turn 2") + + -- turn 2: the foe picks the disabled move with no Disable in flight + local hpBefore = battle.player.mon.hp + battle:resolveTurn(slot(battle.player, 2)) + local rows = drain(battle) + T.check(blocked(rows) ~= nil, "turn 2 still blocks the disabled move") + T.check(usedTackle(rows) == nil, "still no execution") + T.eq(battle.player.mon.hp, hpBefore, "still no damage") + T.eq(battle.enemy.disabledTurns, 2, "and the counter keeps ticking down") + + -- turn 3: the foe picks its OTHER move, which was never disabled + battle.enemyAction = function() return slot(battle.enemy, 2) end + hpBefore = battle.player.mon.hp + rows = (function() battle:resolveTurn(slot(battle.player, 2)); return drain(battle) end)() + T.check(blocked(rows) == nil, "an undisabled move is not blocked") + T.check(saidWith(rows, "used FIX SCRATCH!") ~= nil, "it is announced") + T.check(battle.player.mon.hp < hpBefore, "and it deals damage") +end + +-- --------------------------------------------------------------------- +-- the counter tick still runs ahead of the check: a disable that expires +-- on this turn frees the move it was holding (.DisabledCheck precedes +-- .TriedToUseDisabledMoveCheck) +-- --------------------------------------------------------------------- +do + local battle = newBattle(true) + battle.enemy.disabledSlot, battle.enemy.disabledTurns = 1, 1 + battle.enemyAction = function() return slot(battle.enemy, 1) end + local hpBefore = battle.player.mon.hp + + battle:resolveTurn(slot(battle.player, 2)) + local rows = drain(battle) + + T.check(saidWith(rows, "disabled no more!") ~= nil, "the disable expires") + T.check(blocked(rows) == nil, "so the move is not blocked") + T.check(usedTackle(rows) ~= nil, "it executes") + T.check(battle.player.mon.hp < hpBefore, "and deals damage") +end + +T.finish("disable blocks the already-selected move (#860)") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index 3d68fac2..ccd8508c 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -110,4 +110,92 @@ local last = Logger.history[#Logger.history] check(last and last:find("options save failed", 1, true) ~= nil, "the last failure logged is the false-return one, not the readback one") +-- ---- an interrupted write no longer resets every setting +-- The launcher wrote WIDE and a later write dies partway through (the +-- process replaced by HostShell.restart on the way back to the launcher, an +-- external-storage flush that never happened), leaving a corrupt +-- options.lua. loadOptions must promote the staged/backup copy instead of +-- answering defaults, which is what "closing the game reset all my +-- settings" looked like. +local live = memfs("honest") +SaveData.saveOptions({ battleLayout = "wide" }, live) +SaveData.saveOptions({ battleLayout = "wide", textSpeed = 1 }, live) +check(live.files[OPTIONS .. ".bak"] ~= nil, + "the previous good options.lua is rolled aside before the rewrite") +check(live.files[OPTIONS .. ".tmp"] == nil, + "the staged witness is dropped once the main write is verified") +live.files[OPTIONS] = "return { battleLayout = " -- died mid-rewrite +local healed = SaveData.loadOptions(live) +eq(healed and healed.battleLayout, "wide", + "a corrupt options.lua is recovered from the rolled-aside copy") +check(live.files[OPTIONS] ~= "return { battleLayout = ", + "the main options file is healed from the copy that parsed") + +local gone = memfs("honest") +SaveData.saveOptions({ battleLayout = "wide" }, gone) +gone.files[OPTIONS] = nil +gone.files[OPTIONS .. ".bak"] = nil +gone.files[OPTIONS .. ".tmp"] = nil +eq(SaveData.loadOptions(gone).battleLayout, + SaveData.defaultOptions().battleLayout, + "with no copy left the defaults are still the answer") + +-- ---- the reported sequence end to end: launcher setting -> play -> quit +-- #828 as the reporter walks it (issue steps 2-7, and the "so its partly +-- fixed" comment): change BATTLE LAYOUT from OG to WIDE in the launcher, go +-- in game, close, reopen the launcher. Every options write is a whole-file +-- rewrite out of the caller's table (saveOptions above), so the only thing +-- keeping the launcher's key alive across a game-side write is WHEN the game +-- took its copy: SaveData.load re-attaches a fresh loadOptions() to the save +-- it just read (src/core/SaveData.lua:1108, and SaveData.newGame does the +-- same at :1458), which is after the launcher's last write because +-- RomImporter:play hands off only once the settings modal has saved +-- (src/import/LauncherSettings.lua open/save, src/import/RomImporter.lua +-- play). This pins that ordering: it is the invariant, not the merge, that +-- makes the launcher's change survive. +local hop = memfs("honest") +SaveData.saveOptions({ battleLayout = "og" }, hop) + +-- launcher: the gear menu's edited table, persisted on close +local launcherOpts = SaveData.loadOptions(hop) +launcherOpts.battleLayout = "wide" +launcherOpts.lastVersion = "blue" -- #835 rides the same file +SaveData.saveOptions(launcherOpts, hop) + +-- boot: the game's copy is taken here, never earlier +local gameOpts = SaveData.loadOptions(hop) +eq(gameOpts.battleLayout, "wide", + "the game boots on the value the launcher just wrote") + +-- play: an in-game OPTION menu change writes the whole table back +gameOpts.textSpeed = 1 +check(SaveData.saveOptions(gameOpts, hop) ~= nil, "the game-side write lands") + +local reopened = SaveData.loadOptions(hop) +eq(reopened.battleLayout, "wide", + "the launcher's BATTLE LAYOUT survives a game-side options write (#828)") +eq(reopened.textSpeed, 1, "and the in-game change is persisted alongside it") +eq(reopened.lastVersion, "blue", + "launcher-only keys the game never reads are carried through its write") + +-- The corollary, and the reason the copy has to come from loadOptions: a +-- caller that writes a partial literal instead of a loaded table drops every +-- key it does not mention, because mergeOptions only fills DEFAULTS in around +-- what it is handed (SaveData.mergeOptions). Nothing on the boot path does +-- this today; the assertion is the guard rail if someone shortcuts it. +SaveData.saveOptions({ battleLayout = "og" }, hop) +eq(SaveData.loadOptions(hop).lastVersion, nil, + "a partial write drops launcher-only keys, so the game must write the " + .. "table loadOptions handed it") + +-- Known gap, deliberately not asserted: a copy taken BEFORE the launcher's +-- write and flushed after it still wins, because saveOptions merges only +-- modOptions from disk and every other key is last-writer-wins. Measured, +-- not guessed (og beats a newer wide). No shipping path holds an options +-- table across a launcher write -- HostShell.restart replaces the process on +-- the way back to the launcher (#785, #575) and LauncherSettings.open notes +-- its own cached table is only true while its modal covers the launcher -- +-- so closing that gap needs a three-way merge (baseline vs caller vs disk), +-- not a straight "disk wins", which would throw away real in-game changes. + T.finish("options_write_readback_bug828") From f92364a0027bbf14c8a9a54bbb187a8ad48bc33f Mon Sep 17 00:00:00 2001 From: ratherDashing <1879051+ratherDashing@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:24:53 -0400 Subject: [PATCH 07/12] Build SDL2, OpenAL and the codecs from source for the arm64 AppImage CI on a headless ubuntu-24.04-arm runner caught what a desktop Pi could not: the AppImage only started on a machine that already had a full desktop stack installed. Three distinct causes, all from bundling Debian's builds of libraries that Debian builds for a co-versioned system, which is the opposite of an AppImage's situation. 1. Hard-linked backends. Debian's libSDL2 lists libpulse, libasound, libX11 and libwayland-client as DT_NEEDED rather than dlopening them, so the loader demanded all four at startup; the CI job failed with "libpulse.so.0 => not found". Debian's OpenAL does the same through libsndio, which itself hard-links libasound. Built from source with --enable-*-shared and ALSOFT_DLOPEN, both dlopen their backends, so the image now runs on a Wayland-only session, a KMSDRM handheld with no X server, or a box with ALSA and no PulseAudio. 2. A stray link. Debian's libtheoradec is linked against libcairo, which drags in X11, xcb, fontconfig and freetype for a video decoder. --disable-examples leaves it needing only libogg. 3. SONAME collision with the host. OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that plugin pulls the host's libsndfile into the process. libsndfile links libogg, libvorbis and libmpg123 -- the same three we bundle -- and since the loader resolves a SONAME once per process it bound to our bullseye copies. A bullseye libmpg123 has no mpg123_info2 (added in 1.32), so the plugin failed to relocate, ALSA config collapsed, and the game ran with no audio device at all. Building them current means our copies satisfy the host's libsndfile instead of starving it. The general rule, now stated as an assertion instead of a comment: never bundle a library the host's own stack may also load unless ours is at least as new as theirs. build_appimage.sh fails if any shipped object hard-requires anything beyond glibc, libstdc++ and the font stack, and CI re-checks it on the extracted artifact. Host requirements drop from "a working desktop" to glibc 2.29+, libstdc++, libfreetype6 and zlib. Bundled libraries drop from 13 to 10: libcairo, libpixman and libsndio are gone entirely. Verified on a Raspberry Pi 5 (trixie, Wayland): boots, imports, plays, and audio works -- SDL 2.30 now picks the native Wayland backend rather than falling back to XWayland as bullseye's 2.0.14 did. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 14 + README.md | 11 +- docs/linux-arm64-build.md | 123 +++++---- scripts/build_linux_arm64.sh | 18 ++ scripts/linux-arm64/Dockerfile | 28 +- scripts/linux-arm64/build_appimage.sh | 239 ++++++++++++++++-- scripts/linux-arm64/common.sh | 72 ++++++ .../linux-arm64/selftest_build_linux_arm64.sh | 45 +++- 8 files changed, 466 insertions(+), 84 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 033a9d71..94075d42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -330,11 +330,25 @@ jobs: # Every bundled object must resolve once AppRun's LD_LIBRARY_PATH is # applied; an unresolved soname here is a user-visible launch crash. + # + # This runs on a HEADLESS runner on purpose, and that is the point. + # The first version of this build bundled Debian's SDL2, which + # hard-links libpulse/libasound/libX11/libwayland, so it only ever + # started on a full desktop -- a bare runner is what exposed it. missing="$(LD_LIBRARY_PATH="$PWD/squashfs-root/lib" \ ldd squashfs-root/bin/love squashfs-root/lib/*.so* 2>/dev/null \ | grep 'not found' || true)" [ -z "$missing" ] || { echo "::error::unresolved deps:"; echo "$missing"; exit 1; } + # Nothing may hard-link a driver, session or audio-stack library: + # those must be reached through dlopen so the AppImage runs on a box + # with only ALSA, only Wayland, or only KMSDRM. + linked="$(for f in squashfs-root/bin/love squashfs-root/lib/*.so*; do + objdump -p "$f" 2>/dev/null | awk '/NEEDED/{print $2}' + done | sort -u | grep -E '^lib(pulse|asound|X11|wayland|GL|EGL|drm|gbm|xcb|cairo|sndio|dbus)' || true)" + [ -z "$linked" ] \ + || { echo "::error::these must be dlopened, not linked:"; echo "$linked"; exit 1; } + # The whole point of compiling on bullseye. If a future change moves # the builder to a newer base, the glibc floor silently rises and # every user on an older distro gets "GLIBC_2.xx not found" -- catch diff --git a/README.md b/README.md index 45afadb6..ce866511 100644 --- a/README.md +++ b/README.md @@ -217,11 +217,12 @@ chmod +x gen1recomp-*-linux-arm64.AppImage ``` LÖVE publishes no aarch64 binary of any kind, so this artifact compiles the -engine from source inside a Debian bullseye arm64 container; the result needs -only glibc 2.29+, which covers Raspberry Pi OS bullseye through trixie and -Ubuntu 20.04 onward. Build instructions, the host requirements, and why the -font stack is deliberately left unbundled are in -[docs/linux-arm64-build.md](docs/linux-arm64-build.md). +engine — and SDL2, OpenAL and the codecs — from source inside a Debian +bullseye arm64 container. It needs only glibc 2.29+, libstdc++, freetype and +zlib on the host; OpenGL, X11, Wayland, KMSDRM, ALSA and PulseAudio are all +dlopened, so the same image runs on a full desktop, a Wayland-only session or +a KMSDRM handheld with no X server. Build instructions and the reasoning are +in [docs/linux-arm64-build.md](docs/linux-arm64-build.md). ## iOS diff --git a/docs/linux-arm64-build.md b/docs/linux-arm64-build.md index c40a8bef..b02e0ced 100644 --- a/docs/linux-arm64-build.md +++ b/docs/linux-arm64-build.md @@ -31,17 +31,22 @@ install it (`sudo apt install libfuse2`) or run without it: ### What the host has to provide -The AppImage bundles LÖVE, SDL2, OpenAL and the audio/video decoders. It -deliberately does **not** bundle the graphics drivers, the audio server -client libraries, or the font stack — those have to come from your system, -because bundled copies would either bypass your GPU driver or disagree with -libraries your desktop already has loaded (see -[Why the font stack is not bundled](#why-the-font-stack-is-not-bundled)). +Very little, and this is enforced by an assertion in the build rather than by +good intentions. The only libraries the AppImage requires at startup are: -In practice any arm64 system with a working desktop already satisfies this. -The requirements are glibc 2.29 or newer, plus Mesa/GL, X11 or Wayland, -ALSA or PulseAudio, and freetype/fontconfig — i.e. `libgl1`, `libfreetype6`, -`libfontconfig1`, `libpng16-16`, `libx11-6`. +``` +glibc 2.29+ libstdc++ libfreetype6 zlib +``` + +Everything else — OpenGL/Mesa, X11, Wayland, KMSDRM, ALSA, PulseAudio — is +**dlopened**, so it is used when present and skipped when absent. That means +one image runs on a full desktop, on a Wayland-only session, on a +KMSDRM-only handheld with no X server, and on a box with ALSA but no +PulseAudio, without a different build for each. + +That property does not come for free from Debian's packages, and getting it +is most of what the build below is doing; see +[Why five libraries are built from source](#why-five-libraries-are-built-from-source). ## For builders @@ -64,8 +69,8 @@ downloads and the compiled LÖVE prefix. ### Requirements An **aarch64 host** with **docker or podman**. A Raspberry Pi 5 is the -reference machine (a full build takes about 3.5 minutes on one; rebuilds -reuse the cached LÖVE prefix and take seconds). Apple Silicon with Docker +reference machine (a cold build takes about 10 minutes on one — six libraries +plus the engine; rebuilds reuse the cached prefix and take seconds). Apple Silicon with Docker Desktop and GitHub's `ubuntu-24.04-arm` runner both work too. The script refuses to run on x86_64 rather than falling back to qemu-user @@ -80,10 +85,10 @@ trick is not available here — **LÖVE publishes no aarch64 binary at all.** Th and that is the entire list. So this build compiles LÖVE 11.5 from the official `linux-src` tarball and -assembles the AppImage from scratch. Both pinned inputs (the LÖVE source -tarball and the AppImage type-2 runtime) are SHA-256 verified on the host -before the container ever sees them, and the container itself runs with no -network access. +assembles the AppImage from scratch. Every pinned input — the LÖVE source, the +five libraries built alongside it, and the AppImage type-2 runtime — is +SHA-256 verified on the host before the container ever sees it, and the +container itself runs with no network access. ### Why the build happens in a Debian bullseye container @@ -103,42 +108,59 @@ floor and strand every user on an older one, with no symptom until they download it. CI enforces the floor: `linux-arm64-build` fails if the highest required glibc symbol version climbs above 2.31. -### Why the font stack is not bundled +### Why five libraries are built from source -The dependency walker copies in what LÖVE needs and leaves everything else to -the host. Three categories are excluded, and the third one is subtle enough -to be worth writing down, because it is a real crash that shipped in an early -version of this build: +SDL2, OpenAL, libtheora, libogg/libvorbis and libmpg123 are compiled rather +than installed from bullseye. In every case the reason is *correctness*, not +a newer version number — Debian builds these for a system where every +dependency is installed and co-versioned, which is the opposite of an +AppImage's situation. Each one broke the build in a different way, and all +three failure modes are now assertions that fail the build instead of +shipping. -1. **Driver and session coupled** — GL/EGL/gbm/drm, X11/xcb/Wayland, D-Bus, - PulseAudio, ALSA, systemd/udev. A bundled `libGL` would bypass Mesa's V3D - driver on the Pi; a bundled `libpulse` would fight the running sound server. -2. **Loader coupled** — glibc's own pieces cannot be mixed with the host's - `ld.so`, and `libstdc++`/`libgcc_s` must be at least as new as the compiler - that built us (bullseye's gcc 10 is older than any supported host's, so the - host copy always satisfies us). -3. **Shared with the host font stack** — freetype, fontconfig, libpng, brotli, - zlib. +**1. Hard-linked backends (SDL2, OpenAL).** Debian's `libSDL2` lists +`libpulse`, `libasound`, `libX11` and `libwayland-client` as `DT_NEEDED` — +resolved by the loader at startup, not dlopened. An AppImage bundling it +refuses to start unless the host has *all four*. It appeared to work in +testing only because a desktop Pi has all four; a headless CI runner is what +exposed it. Debian's OpenAL does the same via `libsndio`, which itself +hard-links `libasound`. Built from source with `--enable-*-shared` and +`ALSOFT_DLOPEN`, both dlopen their backends instead. -That third one exists because Debian's `libtheoradec.so.1` is, oddly, linked -against `libcairo.so.2`. LÖVE needs theora for `love.video`, so the host's -cairo gets pulled into our process. The dynamic loader resolves one SONAME -exactly once per process, so a host cairo then binds to whatever -`libfreetype.so.6` *we* bundled: +**2. A stray link (libtheora).** Debian's `libtheoradec.so.1` is linked +against `libcairo.so.2` — a packaging artifact, since a video decoder has no +business drawing vector graphics — and cairo drags in X11, xcb, fontconfig +and freetype. `--disable-examples` produces a `libtheoradec` needing only +`libogg`. + +**3. SONAME collision with the host (ogg, vorbis, mpg123).** The subtle one. +OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that +plugin pulls the *host's* `libsndfile` into our process. `libsndfile` links +`libogg`, `libvorbis` and `libmpg123` — the same three we bundle. The loader +resolves a SONAME exactly once per process, so the host's `libsndfile` binds +to *our* copies: ``` -love -> liblove -> libtheoradec -> libcairo (host, new) - `-> FT_Get_Transform -> libfreetype (ours, bullseye 2.10.4) +openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host, new) + `-> mpg123_info2 -> libmpg123 (ours, bullseye 1.26) ``` -`FT_Get_Transform` arrived in FreeType 2.11, so cairo 1.18 on a trixie host -fails to relocate and the game dies at startup with a symbol lookup error. -Bundling a *newer* freetype only moves the arms race one release along. -Excluding the whole font/compression stack instead makes the process -self-consistent: cairo, fontconfig and freetype all come from one host and -agree with each other, while `liblove` — compiled against 2.10.4 — only ever +`mpg123_info2` arrived in mpg123 1.32, so the plugin failed to relocate, ALSA +config collapsed, and the game ran with **no audio device at all**. Not +bundling these instead would make `libogg`/`libvorbis`/`libmpg123` mandatory +host packages; building them current means our copies *satisfy* the host's +`libsndfile` rather than starving it. + +The same collision is why the font stack — freetype, fontconfig, libpng, +brotli, zlib — is left to the host entirely. Bundling a bullseye freetype +2.10.4 meant a host `libcairo` could not find `FT_Get_Transform` (added in +2.11) and the game died at startup. Leaving the whole stack to the host keeps +it self-consistent, while `liblove` — compiled against 2.10.4 — only ever asks for symbols every supported host already has. +The general rule this all reduces to: **never bundle a library the host's own +stack may also load, unless yours is at least as new as theirs.** + ### CI Three jobs, path-gated on `scripts/build_linux_arm64.sh`, @@ -165,11 +187,16 @@ it runs on fork PRs too. Both pins live in `scripts/linux-arm64/common.sh`: -- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping the LÖVE version invalidates - the cached prefix automatically (it is keyed by version). Check that - bullseye still has `-dev` packages new enough for the new release; - `build_appimage.sh` asserts every optional module actually linked, because - LÖVE's `configure` exits 0 and silently drops a module when one is missing. +- `LOVE_VERSION` / `LOVE_SRC_SHA256` — bumping any version invalidates the + cached prefix automatically (its name is keyed by every source version at + once, so a partial rebuild cannot mix vintages). Check that bullseye still + has `-dev` packages new enough for the new release; `build_appimage.sh` + asserts every optional module actually linked, because LÖVE's `configure` + exits 0 and silently drops a module when one is missing. +- `SDL2_*`, `OPENAL_*`, `THEORA_*`, `OGG_*`, `VORBIS_*`, `MPG123_*` — the + source-built libraries. Bumping these is usually safe and occasionally + necessary: `libmpg123` in particular must stay at least as new as what a + target host's `libsndfile` expects, which is asserted for `mpg123_info2`. - `APPIMAGE_RUNTIME_TAG` / `APPIMAGE_RUNTIME_SHA256` — always a dated tag from [AppImage/type2-runtime](https://github.com/AppImage/type2-runtime/releases). The selftest fails the build if this ever points at `continuous`. diff --git a/scripts/build_linux_arm64.sh b/scripts/build_linux_arm64.sh index e8e94a83..4df2ff00 100755 --- a/scripts/build_linux_arm64.sh +++ b/scripts/build_linux_arm64.sh @@ -104,6 +104,12 @@ cp "$GAME_LOVE" "$IN_DIR/game.love" # Fetched on the host and checksum-pinned here so the container never needs # network access and every input is verified in exactly one place. download_pinned "$LOVE_SRC_URL" "$CACHE/$LOVE_SRC_TARBALL" "$LOVE_SRC_SHA256" +download_pinned "$SDL2_URL" "$CACHE/$SDL2_TARBALL" "$SDL2_SHA256" +download_pinned "$OPENAL_URL" "$CACHE/$OPENAL_TARBALL" "$OPENAL_SHA256" +download_pinned "$THEORA_URL" "$CACHE/$THEORA_TARBALL" "$THEORA_SHA256" +download_pinned "$OGG_URL" "$CACHE/$OGG_TARBALL" "$OGG_SHA256" +download_pinned "$VORBIS_URL" "$CACHE/$VORBIS_TARBALL" "$VORBIS_SHA256" +download_pinned "$MPG123_URL" "$CACHE/$MPG123_TARBALL" "$MPG123_SHA256" download_pinned "$APPIMAGE_RUNTIME_URL" "$CACHE/$APPIMAGE_RUNTIME_NAME" \ "$APPIMAGE_RUNTIME_SHA256" @@ -129,6 +135,18 @@ fi say "compiling and packaging inside $BUILDER_BASE_IMAGE" "$RUNTIME" run --rm ${user_args[@]+"${user_args[@]}"} \ -e LOVE_VERSION="$LOVE_VERSION" \ + -e SDL2_VERSION="$SDL2_VERSION" \ + -e SDL2_TARBALL="$SDL2_TARBALL" \ + -e OPENAL_VERSION="$OPENAL_VERSION" \ + -e OPENAL_TARBALL="$OPENAL_TARBALL" \ + -e THEORA_VERSION="$THEORA_VERSION" \ + -e THEORA_TARBALL="$THEORA_TARBALL" \ + -e OGG_VERSION="$OGG_VERSION" \ + -e OGG_TARBALL="$OGG_TARBALL" \ + -e VORBIS_VERSION="$VORBIS_VERSION" \ + -e VORBIS_TARBALL="$VORBIS_TARBALL" \ + -e MPG123_VERSION="$MPG123_VERSION" \ + -e MPG123_TARBALL="$MPG123_TARBALL" \ -e APP_NAME="$APP_NAME" \ -e VERSION="$VERSION" \ -v "$CACHE:/cache" \ diff --git a/scripts/linux-arm64/Dockerfile b/scripts/linux-arm64/Dockerfile index 65990884..ab12a3b7 100644 --- a/scripts/linux-arm64/Dockerfile +++ b/scripts/linux-arm64/Dockerfile @@ -14,16 +14,32 @@ ENV DEBIAN_FRONTEND=noninteractive # build-essential/autoconf: LÖVE 11.5's linux-src tarball is autotools. # squashfs-tools: packs the AppDir into the AppImage payload. -# The lib*-dev set is LÖVE's full optional-module surface — a missing one +# +# Note what is deliberately ABSENT: libsdl2-dev, libtheora-dev and +# libopenal-dev. All three are built from source instead (see common.sh for +# why), and having Debian's copies installed would let pkg-config hand LÖVE's +# configure the system ones and silently undo it. +# +# The remaining lib*-dev set is LÖVE's optional-module surface. A missing one # does not fail configure, it silently drops a module (love.sound decoders, -# love.font, love.video), so they are pinned here deliberately. +# love.font, love.video), so they are pinned here deliberately and asserted +# after the build. +# +# The X11/Wayland/audio -dev packages are here for SDL2's *build*, not for +# runtime linkage: SDL detects each backend at compile time and then dlopens +# it, so these headers decide which backends exist at all while adding no +# DT_NEEDED entry to the shipped library. RUN apt-get update -qq \ && apt-get install -y --no-install-recommends \ - build-essential pkg-config autoconf automake libtool \ - ca-certificates curl file xz-utils zip unzip squashfs-tools \ - libsdl2-dev libopenal-dev libogg-dev libvorbis-dev libtheora-dev \ + build-essential pkg-config autoconf automake libtool cmake \ + ca-certificates curl file xz-utils bzip2 zip unzip squashfs-tools \ + libogg-dev libvorbis-dev \ libmodplug-dev libmpg123-dev libfreetype6-dev libluajit-5.1-dev \ - zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev \ + zlib1g-dev libgl1-mesa-dev libgles2-mesa-dev libegl1-mesa-dev \ + libasound2-dev libpulse-dev libudev-dev libdbus-1-dev \ + libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxi-dev \ + libxinerama-dev libxss-dev libxkbcommon-dev \ + libwayland-dev wayland-protocols libdrm-dev libgbm-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /work diff --git a/scripts/linux-arm64/build_appimage.sh b/scripts/linux-arm64/build_appimage.sh index 420d586c..53fc8fdc 100755 --- a/scripts/linux-arm64/build_appimage.sh +++ b/scripts/linux-arm64/build_appimage.sh @@ -15,6 +15,18 @@ set -euo pipefail LOVE_VERSION="${LOVE_VERSION:?}" +SDL2_VERSION="${SDL2_VERSION:?}" +SDL2_TARBALL="${SDL2_TARBALL:?}" +OPENAL_VERSION="${OPENAL_VERSION:?}" +OPENAL_TARBALL="${OPENAL_TARBALL:?}" +THEORA_VERSION="${THEORA_VERSION:?}" +THEORA_TARBALL="${THEORA_TARBALL:?}" +OGG_VERSION="${OGG_VERSION:?}" +OGG_TARBALL="${OGG_TARBALL:?}" +VORBIS_VERSION="${VORBIS_VERSION:?}" +VORBIS_TARBALL="${VORBIS_TARBALL:?}" +MPG123_VERSION="${MPG123_VERSION:?}" +MPG123_TARBALL="${MPG123_TARBALL:?}" APP_NAME="${APP_NAME:?}" VERSION="${VERSION:?}" JOBS="${JOBS:-$(nproc)}" @@ -29,16 +41,171 @@ fail() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } mkdir -p "$WORK" +# --------------------------------------------------------------- prefix +# Everything we compile lands in one prefix, cached because the compiling is +# the only slow part (~5 min cold on a Pi 5) and is identical for every game +# version. The key includes every source version, so bumping any of them +# invalidates the cache instead of silently reusing a stale mix. +PREFIX="$CACHE/prefix-love$LOVE_VERSION-sdl$SDL2_VERSION-al$OPENAL_VERSION-theora$THEORA_VERSION-ogg$OGG_VERSION-vorbis$VORBIS_VERSION-mpg$MPG123_VERSION" +export PKG_CONFIG_PATH="$PREFIX/lib/pkgconfig" +# Our own libraries must win over the system ones during LÖVE's configure and +# link, or the whole point of building them is lost. +export LD_LIBRARY_PATH="$PREFIX/lib" + +# ------------------------------------------------------ compile audio codecs +# Ordered by dependency: vorbis needs ogg, and theora needs ogg too. All three +# are small, plain autotools builds -- well under a minute each. +build_autotools() { # $1 = label $2 = version $3 = tarball $4 = probe lib $5.. = configure args + local label="$1" version="$2" tarball="$3" probe="$4"; shift 4 + if [ -f "$PREFIX/lib/$probe" ]; then + say "reusing cached $label $version" + return 0 + fi + say "compiling $label $version" + local src="$WORK/$label-src" + rm -rf "$src"; mkdir -p "$src" + case "$tarball" in + *.tar.bz2) tar -xjf "$CACHE/$tarball" -C "$src" --strip-components=1 ;; + *) tar -xzf "$CACHE/$tarball" -C "$src" --strip-components=1 ;; + esac + ( + cd "$src" + # Several of these tarballs predate aarch64's entry in config.guess; the + # distro's copies know about it, so refresh them or configure bails out + # with "cannot guess build type". + for helper in config.guess config.sub; do + [ -f "$helper" ] && cp "/usr/share/misc/$helper" . 2>/dev/null + done + ./configure --prefix="$PREFIX" --disable-static "$@" >/dev/null + make -j"$JOBS" >/dev/null + make install >/dev/null + ) +} + +build_autotools ogg "$OGG_VERSION" "$OGG_TARBALL" libogg.so.0 +build_autotools vorbis "$VORBIS_VERSION" "$VORBIS_TARBALL" libvorbis.so.0 +# mpg123's ports/ tree and the command-line player are irrelevant here; only +# libmpg123 gets linked, and --disable-modules keeps the output-backend +# plugins (and their dlopen of ALSA/pulse) out of the shipped library. +build_autotools mpg123 "$MPG123_VERSION" "$MPG123_TARBALL" libmpg123.so.0 \ + --disable-modules --with-audio=dummy --disable-lfs-alias + +# The symbol that was missing when this was bullseye's copy. Assert it, so a +# version bump that quietly regresses below the host's expectations fails the +# build instead of silently killing audio again. +objdump -T "$PREFIX/lib/libmpg123.so.0" | grep -q 'mpg123_info2' \ + || fail "bundled libmpg123 lacks mpg123_info2; the host's libsndfile will fail to relocate" + +# ------------------------------------------------------------ compile SDL2 +# --enable-*-shared (the defaults, made explicit so a future SDL release +# cannot flip them under us) is the entire reason this is built from source: +# each backend is dlopened at runtime rather than becoming a DT_NEEDED entry, +# so the AppImage starts on a host with only ALSA, or only Wayland, or only +# KMSDRM, instead of demanding all of them at once the way Debian's build does. +if [ -f "$PREFIX/lib/libSDL2-2.0.so.0" ]; then + say "reusing cached SDL2 $SDL2_VERSION" +else + say "compiling SDL2 $SDL2_VERSION (jobs: $JOBS)" + rm -rf "$WORK/sdl-src"; mkdir -p "$WORK/sdl-src" + tar -xzf "$CACHE/$SDL2_TARBALL" -C "$WORK/sdl-src" --strip-components=1 + ( + cd "$WORK/sdl-src" + ./configure --prefix="$PREFIX" --disable-static \ + --enable-alsa --enable-alsa-shared \ + --enable-pulseaudio --enable-pulseaudio-shared \ + --enable-video-x11 --enable-x11-shared \ + --enable-video-wayland --enable-wayland-shared \ + --enable-video-kmsdrm --enable-kmsdrm-shared \ + --enable-libudev --disable-sndio --disable-jack --disable-esd \ + --disable-arts --disable-nas --disable-oss >/dev/null + make -j"$JOBS" >/dev/null + make install >/dev/null + ) +fi + +# Prove the dlopen intent actually took. If SDL ever hard-links an audio or +# video backend again, the AppImage silently regains a startup dependency on +# the host having that exact stack -- which is the bug this replaced. +sdl_lib="$PREFIX/lib/libSDL2-2.0.so.0" +[ -f "$sdl_lib" ] || fail "SDL2 build produced no libSDL2-2.0.so.0" +for forbidden in libpulse libasound libX11 libwayland libdrm libgbm libsndio; do + if objdump -p "$sdl_lib" | grep -q "NEEDED.*$forbidden"; then + fail "SDL2 hard-links $forbidden; it must dlopen its backends (--enable-*-shared)" + fi +done + +# ---------------------------------------------------- compile openal-soft +# ALSOFT_DLOPEN keeps the ALSA and PulseAudio backends behind dlopen, and +# sndio is switched off outright -- Debian enables it, which is what chained +# libopenal -> libsndio -> libasound into a mandatory startup dependency. +if [ -f "$PREFIX/lib/libopenal.so.1" ]; then + say "reusing cached openal-soft $OPENAL_VERSION" +else + say "compiling openal-soft $OPENAL_VERSION (jobs: $JOBS)" + rm -rf "$WORK/openal-src"; mkdir -p "$WORK/openal-src" + tar -xzf "$CACHE/$OPENAL_TARBALL" -C "$WORK/openal-src" --strip-components=1 + ( + cd "$WORK/openal-src" + cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$PREFIX" \ + -DALSOFT_DLOPEN=ON \ + -DALSOFT_BACKEND_SNDIO=OFF \ + -DALSOFT_BACKEND_OSS=OFF \ + -DALSOFT_BACKEND_JACK=OFF \ + -DALSOFT_EXAMPLES=OFF \ + -DALSOFT_UTILS=OFF \ + -DALSOFT_TESTS=OFF \ + -DLIBTYPE=SHARED >/dev/null + cmake --build build -j"$JOBS" >/dev/null + cmake --install build >/dev/null + ) +fi + +openal_lib="$PREFIX/lib/libopenal.so.1" +[ -f "$openal_lib" ] || fail "openal-soft build produced no libopenal.so.1" +for forbidden in libsndio libasound libpulse libjack; do + if objdump -p "$openal_lib" | grep -q "NEEDED.*$forbidden"; then + fail "openal hard-links $forbidden; backends must stay behind dlopen" + fi +done + +# --------------------------------------------------- compile libtheora +# --disable-examples is what drops Debian's libcairo link (and with it libX11, +# libxcb, libfontconfig and libfreetype as startup dependencies). The encoder +# is dead weight for a player, but libtheoradec is what LÖVE actually links. +if [ -f "$PREFIX/lib/libtheoradec.so.1" ]; then + say "reusing cached libtheora $THEORA_VERSION" +else + say "compiling libtheora $THEORA_VERSION" + rm -rf "$WORK/theora-src"; mkdir -p "$WORK/theora-src" + tar -xjf "$CACHE/$THEORA_TARBALL" -C "$WORK/theora-src" --strip-components=1 + ( + cd "$WORK/theora-src" + # theora 1.1.1 predates the aarch64 config.guess, so refresh the autotools + # helper scripts or configure rejects the host outright. + for helper in config.guess config.sub; do + cp "/usr/share/misc/$helper" . 2>/dev/null || true + done + ./configure --prefix="$PREFIX" --disable-static \ + --disable-examples --disable-spec --disable-doc >/dev/null + make -j"$JOBS" >/dev/null + make install >/dev/null + ) +fi + +theora_lib="$PREFIX/lib/libtheoradec.so.1" +[ -f "$theora_lib" ] || fail "libtheora build produced no libtheoradec.so.1" +if objdump -p "$theora_lib" | grep -q "NEEDED.*libcairo"; then + fail "libtheoradec still links libcairo (--disable-examples stopped working)" +fi + # ------------------------------------------------------------ compile LÖVE -# The prefix is cached because this is the only slow step (~3 min on a Pi 5, -# and it is identical for every game version). Keyed by LÖVE version so a -# LOVE_VERSION bump cannot silently reuse the old build. -PREFIX="$CACHE/love-$LOVE_VERSION-prefix" if [ -x "$PREFIX/bin/love" ] && [ -f "$PREFIX/lib/liblove-$LOVE_VERSION.so" ]; then say "reusing cached LÖVE $LOVE_VERSION aarch64 build" else say "compiling LÖVE $LOVE_VERSION for aarch64 (jobs: $JOBS)" - rm -rf "$PREFIX" "$WORK/love-src" + rm -rf "$WORK/love-src" mkdir -p "$WORK/love-src" tar -xzf "$CACHE/love-$LOVE_VERSION-linux-src.tar.gz" \ -C "$WORK/love-src" --strip-components=1 @@ -46,8 +213,11 @@ else cd "$WORK/love-src" # No --disable-* flags on purpose: configure silently drops a love module # when its -dev package is absent, so the Dockerfile pins the full set and - # the assertions below prove each one actually linked. - ./configure --prefix="$PREFIX" --disable-static >/dev/null + # the assertions below prove each one actually linked. CPPFLAGS/LDFLAGS + # point at our prefix so the SDL2 and theora just built above win over + # anything the base image might still provide. + ./configure --prefix="$PREFIX" --disable-static \ + CPPFLAGS="-I$PREFIX/include" LDFLAGS="-L$PREFIX/lib" >/dev/null make -j"$JOBS" >/dev/null make install >/dev/null # Keep LÖVE's license inside the cached prefix: the unpacked source tree @@ -97,29 +267,23 @@ chmod +x "$APPDIR/bin/love" # 1. Driver/session coupled. A bundled libGL would bypass Mesa's V3D driver # on the Pi; a bundled libpulse/libdbus would fight the user's running # session. GL/EGL/gbm/drm, X11/xcb/wayland/xkbcommon, dbus, pulse, alsa, -# systemd/udev. +# systemd/udev. Note that after the source builds above, none of these are +# DT_NEEDED of anything we ship -- SDL2 and OpenAL dlopen them, so they are +# used when present and skipped when absent. # # 2. Loader coupled. glibc's pieces cannot be mixed with the host's ld.so at # all, and libstdc++/libgcc_s must be at least as new as the compiler -- # bullseye's gcc 10 is older than any supported host's, so the host copy # always satisfies us. # -# 3. Shared with the host's font stack -- the subtle one, and the reason -# this list is longer than LÖVE's own AppImage manifest. Bullseye's -# libtheoradec is (bizarrely, a Debian packaging artifact) linked against -# libcairo, so the HOST's cairo gets loaded into our process. Because the -# dynamic loader resolves one SONAME once per process, that host cairo -# then binds to whatever libfreetype.so.6 we bundled -- and a bullseye -# freetype 2.10.4 has no FT_Get_Transform, which cairo 1.18 needs: -# -# love -> liblove -> libtheoradec -> libcairo (host, new) -# `-> FT_Get_Transform -> libfreetype (ours, old) BOOM -# -# Bundling a newer freetype only moves the arms race. Excluding the whole -# font/compression stack instead makes it self-consistent: cairo, -# fontconfig and freetype all come from one host and agree with each -# other, while liblove -- compiled against 2.10.4 -- only ever asks for -# symbols every supported host already has. +# 3. The font/compression stack: freetype, fontconfig, libpng, brotli, zlib. +# These are shared with whatever the host's own graphics libraries have +# already loaded, and mixing vintages inside one process breaks the older +# copy. Bundling a bullseye freetype 2.10.4 is what made a host cairo fail +# to find FT_Get_Transform (added in 2.11) and killed the game at startup. +# Leaving the whole stack to the host keeps it self-consistent, and +# liblove -- compiled against 2.10.4 -- only ever asks for symbols every +# supported host already has. EXCLUDE_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libresolv\.so\.2|libutil\.so\.1|libanl\.so\.1|libnsl\.so\.[0-9]+|libstdc\+\+\.so\.6|libgcc_s\.so\.1|lib(GL|GLX|GLdispatch|OpenGL|EGL|GLESv[12]|glapi|gbm|drm)\..*|libX[a-z0-9]*\..*|libxcb.*|libwayland-.*|libxkbcommon.*|libdbus-1\..*|libpulse.*|libasound\..*|libsndfile\..*|libFLAC\..*|libopus\..*|libsystemd\..*|libudev\..*|libselinux\..*|libcap\..*|libgcrypt\..*|libgpg-error\..*|liblzma\..*|libzstd\..*|liblz4\..*|libffi\..*|libexpat\..*|libbsd\..*|libmd\..*|libuuid\..*|libg(lib|object|module|thread)-2\..*|libfontconfig\..*|libfreetype\..*|libpng[0-9]*\..*|libbrotli.*|libz\.so\..*|libwrap\..*|libasyncns\..*|libtirpc\..*|lib(gssapi_krb5|krb5|k5crypto|com_err|krb5support|keyutils)\..*|libpcre.*)$' # soname -> absolute path, harvested from the full ldd closure of both roots. @@ -156,6 +320,33 @@ bundle_needed "$APPDIR/bin/love" bundle_needed "$APPDIR/lib/liblove-$LOVE_VERSION.so" say "bundled $(ls "$APPDIR/lib" | wc -l) libraries: $(ls "$APPDIR/lib" | tr '\n' ' ')" +# ------------------------------------------------- host dependency contract +# The portability promise, stated as an assertion instead of a paragraph in a +# README: these are the ONLY sonames the shipped objects may require from the +# host. Everything driver-, session- or audio-related has to be reached +# through dlopen, so the AppImage starts on a box with no PulseAudio, no X11 +# or no ALSA and simply uses whatever it does find. +# +# The original build failed exactly here and nobody noticed until CI ran on a +# headless runner: Debian's SDL2 hard-links libpulse/libasound/libX11/ +# libwayland, so the image only ever started on a full desktop. +HOST_ALLOWED_RE='^(ld-linux-aarch64\.so\.1|libc\.so\.6|libm\.so\.6|libdl\.so\.2|libpthread\.so\.0|librt\.so\.1|libstdc\+\+\.so\.6|libgcc_s\.so\.1|libatomic\.so\.1|libfreetype\.so\.6|libpng[0-9]*\.so\.[0-9]+|libz\.so\.1|libbrotli(dec|common)\.so\.1)$' + +unexpected="" +for object in "$APPDIR/bin/love" "$APPDIR"/lib/*.so*; do + while read -r soname; do + [ -n "$soname" ] || continue + # Satisfied from inside the AppDir, so not a host requirement at all. + if [ -n "${BUNDLED[$soname]:-}" ]; then continue; fi + if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then continue; fi + unexpected="$unexpected $(basename "$object") -> $soname"$'\n' + done < <(objdump -p "$object" | awk '/NEEDED/ {print $2}') +done +[ -z "$unexpected" ] || fail "$(printf '%s\n%s' \ + "these objects hard-require host libraries outside the allowed set (they must be dlopened, not linked):" \ + "$unexpected")" +say "host dependency contract holds (glibc, libstdc++ and the font stack only)" + # LÖVE loads jit.* (jit.status, the profiler) through LUA_PATH; without these # the modules are simply absent, so ship them the way upstream's image does. jit_share="$(ls -d /usr/share/luajit-* 2>/dev/null | head -1)" diff --git a/scripts/linux-arm64/common.sh b/scripts/linux-arm64/common.sh index 210c67c2..39e196b4 100755 --- a/scripts/linux-arm64/common.sh +++ b/scripts/linux-arm64/common.sh @@ -18,6 +18,78 @@ LOVE_SRC_TARBALL="love-$LOVE_VERSION-linux-src.tar.gz" LOVE_SRC_URL="https://github.com/love2d/love/releases/download/$LOVE_VERSION/$LOVE_SRC_TARBALL" LOVE_SRC_SHA256="066e0843f71aa9fd28b8eaf27d41abb74bfaef7556153ac2e3cf08eafc874c39" +# SDL2 is built from source rather than taken from bullseye, and this is a +# correctness requirement, not a version preference. Debian's libSDL2 lists +# libpulse, libasound, libX11 and libwayland-client as DT_NEEDED -- hard links +# resolved by the loader at startup -- so an AppImage bundling it refuses to +# launch unless the host has ALL FOUR installed. That is wrong for an artifact +# whose whole job is to run on arbitrary arm64 systems: an ALSA-only handheld +# or a minimal Wayland box would die before main(). Built from source, SDL +# defaults to dlopening every audio and video backend (--enable-*-shared), so +# it loads whichever the host actually has and degrades gracefully. +# The newer version is a bonus: 2.30 has a far better controller database and +# real KMSDRM support, both of which matter on Pi-class and handheld hardware. +SDL2_VERSION="2.30.12" +SDL2_TARBALL="SDL2-$SDL2_VERSION.tar.gz" +SDL2_URL="https://github.com/libsdl-org/SDL/releases/download/release-$SDL2_VERSION/$SDL2_TARBALL" +SDL2_SHA256="ac356ea55e8b9dd0b2d1fa27da40ef7e238267ccf9324704850d5d47375b48ea" + +# libtheora likewise. Debian's libtheoradec.so.1 is linked against libcairo -- +# a packaging artifact, since a video decoder has no business drawing vector +# graphics -- and cairo drags in libX11, libxcb, libfontconfig and libfreetype +# as hard dependencies. LOVE needs theora for love.video, so that link would +# put the entire X11 and font stack on the critical path at startup, and it is +# what caused the FT_Get_Transform crash this build hit on a trixie host. +# Upstream's tarball with --disable-examples produces a libtheoradec that +# needs only libogg. +THEORA_VERSION="1.1.1" +THEORA_TARBALL="libtheora-$THEORA_VERSION.tar.bz2" +THEORA_URL="https://downloads.xiph.org/releases/theora/$THEORA_TARBALL" +THEORA_SHA256="b6ae1ee2fa3d42ac489287d3ec34c5885730b1296f0801ae577a35193d3affbc" + +# OpenAL for the same reason as SDL2, one level down. Debian's libopenal is +# openal-soft built with the sndio backend enabled, so it hard-links +# libsndio, which itself hard-links libasound -- reintroducing exactly the +# mandatory-ALSA dependency the SDL2 source build exists to remove. Upstream +# openal-soft dlopens its backends, so building it here leaves the shipped +# library with no audio-stack dependency at all. +OPENAL_VERSION="1.23.1" +OPENAL_TARBALL="openal-soft-$OPENAL_VERSION.tar.gz" +OPENAL_URL="https://github.com/kcat/openal-soft/archive/refs/tags/$OPENAL_VERSION.tar.gz" +OPENAL_SHA256="dfddf3a1f61059853c625b7bb03de8433b455f2f79f89548cbcbd5edca3d4a4a" + +# The audio codecs are built from source for a third, different reason: SONAME +# collision with the host's audio stack. +# +# OpenAL dlopens ALSA, ALSA's config loads its PulseAudio hook plugin, and that +# plugin pulls the HOST's libsndfile into our process. libsndfile links +# libogg, libvorbis and libmpg123 -- the same three we bundle. The loader +# resolves a SONAME once per process, so the host's libsndfile binds to OUR +# copies, and a bullseye libmpg123 has no mpg123_info2 (added in 1.32): +# +# openal -> libasound -> libasound_module_conf_pulse -> libsndfile (host) +# `-> mpg123_info2 -> libmpg123 (ours, bullseye) +# +# which failed to relocate and left the game with no audio device at all. +# Not bundling them instead would make libogg/libvorbis/libmpg123 mandatory +# host packages; building them current means our copies satisfy the host's +# libsndfile rather than starving it. libvorbisfile ships in the vorbis +# tarball. +OGG_VERSION="1.3.5" +OGG_TARBALL="libogg-$OGG_VERSION.tar.gz" +OGG_URL="https://downloads.xiph.org/releases/ogg/$OGG_TARBALL" +OGG_SHA256="0eb4b4b9420a0f51db142ba3f9c64b333f826532dc0f48c6410ae51f4799b664" + +VORBIS_VERSION="1.3.7" +VORBIS_TARBALL="libvorbis-$VORBIS_VERSION.tar.gz" +VORBIS_URL="https://downloads.xiph.org/releases/vorbis/$VORBIS_TARBALL" +VORBIS_SHA256="0e982409a9c3fc82ee06e08205b1355e5c6aa4c36bca58146ef399621b0ce5ab" + +MPG123_VERSION="1.32.10" +MPG123_TARBALL="mpg123-$MPG123_VERSION.tar.bz2" +MPG123_URL="https://www.mpg123.de/download/$MPG123_TARBALL" +MPG123_SHA256="87b2c17fe0c979d3ef38eeceff6362b35b28ac8589fbf1854b5be75c9ab6557c" + # AppImage type-2 runtime: the ~900 KB static-pie ELF that gets prepended to # the squashfs payload. Pinned to a dated tag, never "continuous", so a # rebuild months from now produces the same bytes. diff --git a/scripts/linux-arm64/selftest_build_linux_arm64.sh b/scripts/linux-arm64/selftest_build_linux_arm64.sh index 1a25b177..81ac3008 100755 --- a/scripts/linux-arm64/selftest_build_linux_arm64.sh +++ b/scripts/linux-arm64/selftest_build_linux_arm64.sh @@ -43,7 +43,8 @@ printf '%s' "$guard_out" | grep -q 'aarch64 host' \ say "checking pinned inputs" # Pins must be real digests, and the AppImage runtime must come from a dated # tag: "continuous" is a moving target and would make rebuilds unreproducible. -for pin_name in LOVE_SRC_SHA256 APPIMAGE_RUNTIME_SHA256; do +for pin_name in LOVE_SRC_SHA256 SDL2_SHA256 OPENAL_SHA256 THEORA_SHA256 \ + OGG_SHA256 VORBIS_SHA256 MPG123_SHA256 APPIMAGE_RUNTIME_SHA256; do pin_value="${!pin_name}" printf '%s' "$pin_value" | grep -Eq '^[0-9a-f]{64}$' \ || fail "$pin_name is not a sha256 digest: $pin_value" @@ -113,6 +114,48 @@ for soname in libSDL2-2.0.so.0 libopenal.so.1 libfreetype.so.6 libmodplug.so.1 \ || fail "build_appimage.sh no longer asserts liblove links $soname" done +say "checking the dlopen guarantees" +# SDL2, OpenAL and libtheora are compiled from source for correctness, not for +# a newer version number: Debian's builds hard-link libpulse/libasound/libX11/ +# libwayland (SDL2), libsndio (OpenAL) and libcairo (libtheora), each of which +# turns an optional runtime capability into a mandatory startup dependency. +# If a future edit drops the source build and reaches for the -dev package +# again, the AppImage silently stops starting on lean systems. +for forbidden_pkg in libsdl2-dev libtheora-dev libopenal-dev; do + if grep -qE "^ +.*\b$forbidden_pkg\b" "$SCRIPT_DIR/Dockerfile"; then + fail "Dockerfile installs $forbidden_pkg; that library is built from source on purpose" + fi +done +grep -qF -- '--enable-alsa-shared' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "SDL2 is no longer configured to dlopen its audio backends" +grep -qF -- '--enable-x11-shared' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "SDL2 is no longer configured to dlopen its video backends" +grep -qF 'ALSOFT_DLOPEN=ON' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "openal-soft is no longer configured to dlopen its backends" +grep -qF -- '--disable-examples' "$SCRIPT_DIR/build_appimage.sh" \ + || fail "libtheora is no longer built with --disable-examples (it regains the libcairo link)" + +say "checking the host dependency contract" +# The shipped objects may require nothing from the host beyond glibc, +# libstdc++ and the font stack. Everything driver-, session- or audio-related +# has to be dlopened. This is the invariant a headless CI runner proved was +# broken the first time round. +HOST_ALLOWED_RE="$( + grep -m1 "^HOST_ALLOWED_RE=" "$SCRIPT_DIR/build_appimage.sh" \ + | sed "s/^HOST_ALLOWED_RE='//; s/'\$//" +)" +[ -n "$HOST_ALLOWED_RE" ] || fail "could not read HOST_ALLOWED_RE out of build_appimage.sh" +for soname in libpulse.so.0 libasound.so.2 libX11.so.6 libwayland-client.so.0 \ + libGL.so.1 libcairo.so.2 libsndio.so.7.0 libdbus-1.so.3; do + if [[ "$soname" =~ $HOST_ALLOWED_RE ]]; then + fail "$soname is allowed as a hard host dependency; it must be dlopened" + fi +done +for soname in libc.so.6 libstdc++.so.6 libfreetype.so.6 libz.so.1; do + [[ "$soname" =~ $HOST_ALLOWED_RE ]] \ + || fail "$soname must be allowed as a host dependency but the contract rejects it" +done + say "checking the shared game.love payload" temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/gen1recomp-linux-arm64-selftest.XXXXXX")" trap 'rm -rf "$temp_dir"' EXIT From e2820e02c5c9b79f1f2f580371a04413bc7dd0fa Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 5 Aug 2026 16:36:37 -0400 Subject: [PATCH 08/12] CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOSES #781, CLOSES #784, CLOSES #799, CLOSES #801, CLOSES #810, CLOSES #828, CLOSES #834, CLOSES #838, CLOSES #849, CLOSES #852, CLOSES #857, CLOSES #863, CLOSES #864, CLOSES #867, CLOSES #869, CLOSES #870, CLOSES #872, CLOSES #839 --- data/scripts/story.lua | 47 +- data/scripts/story3.lua | 24 +- main.lua | 35 +- .../love/src/jni/love/src/common/android.cpp | 32 +- .../java/org/love2d/android/GameActivity.java | 56 +- src/core/SafeArea.lua | 18 + src/core/SaveData.lua | 12 + src/import/LauncherView.lua | 10 +- src/import/RomImporter.lua | 14 +- src/mods/LauncherMods.lua | 59 +- src/render/Renderer.lua | 19 +- src/save_convert/GenSave.lua | 88 ++ src/save_convert/SaveConvert.lua | 20 + src/save_convert/data/event_flags_yellow.lua | 1057 +++++++++++++++++ src/save_convert/data/toggle_objects.lua | 243 ++++ src/ui/TitleState.lua | 9 +- src/update/check_worker.lua | 13 + src/world/OverworldController.lua | 51 +- src/world/PikachuFollower.lua | 11 +- tests/drivers/bag_full_pickup_bug872_test.lua | 222 ++++ .../faithful_res_mobile_veil_bug864_test.lua | 296 +++++ .../drivers/giovanni_silph11f_bug869_test.lua | 224 ++++ .../drivers/marowak_departed_bug867_test.lua | 188 +++ .../pikachu_warp_spawn_bug863_test.lua | 164 +++ .../title_menu_palette_bug870_test.lua | 149 +++ .../launcher_one_column_reach_bug852.lua | 112 ++ .../options_backup_rollforward_bug828.lua | 109 ++ .../engine/options_write_readback_bug828.lua | 25 + tests/engine/safe_area_units_test.lua | 51 + tests/engine/save_convert_toggle_objects.lua | 187 +++ tests/launcher_mods_install_zip_test.lua | 37 + ...ncher_mods_shadow_copy_bug801_834_test.lua | 225 ++++ tests/mod_ui_tests.lua | 16 +- tests/rom_importer_cursor_bug781_test.lua | 71 ++ tests/rom_importer_cursor_test.lua | 8 + tests/save_convert_yellow_bug838_test.lua | 208 ++++ 36 files changed, 4046 insertions(+), 64 deletions(-) create mode 100644 src/save_convert/data/event_flags_yellow.lua create mode 100644 src/save_convert/data/toggle_objects.lua create mode 100644 tests/drivers/bag_full_pickup_bug872_test.lua create mode 100644 tests/drivers/faithful_res_mobile_veil_bug864_test.lua create mode 100644 tests/drivers/giovanni_silph11f_bug869_test.lua create mode 100644 tests/drivers/marowak_departed_bug867_test.lua create mode 100644 tests/drivers/pikachu_warp_spawn_bug863_test.lua create mode 100644 tests/drivers/title_menu_palette_bug870_test.lua create mode 100644 tests/engine/launcher_one_column_reach_bug852.lua create mode 100644 tests/engine/options_backup_rollforward_bug828.lua create mode 100644 tests/engine/safe_area_units_test.lua create mode 100644 tests/engine/save_convert_toggle_objects.lua create mode 100644 tests/launcher_mods_shadow_copy_bug801_834_test.lua create mode 100644 tests/rom_importer_cursor_bug781_test.lua create mode 100644 tests/save_convert_yellow_bug838_test.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index cc540257..3b114d6b 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -800,9 +800,13 @@ M.SILPH_CO_11F = { -- line) would touch, and the whole Silph ending -- the flag, the Master -- Ball, the Saffron streets clearing -- silently never happened. -- - -- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and, - -- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss - -- sets nothing, so the trigger re-arms exactly as vanilla does. + -- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI + -- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk + -- and only then walks the three tiles down. Moving him before the box made + -- him cross the room in silence and deliver the speech point-blank (#869), + -- so the box comes first here and engageTrainer skips its own battle text. + -- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets + -- nothing, so the trigger re-arms exactly as vanilla does. onStep = function(game, ow, x, y) if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end @@ -811,21 +815,28 @@ M.SILPH_CO_11F = { if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end end if not gio or ow:trainerDefeated(gio) then return false end - ow:scriptMove(gio, "down", 3, function() - gio:facePlayer(ow.player) - ow:engageTrainer(gio, function() - -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech, - -- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph - -- rocket leaves off-screen (the street rockets are handled by - -- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the - -- battle's own callbacks are still unwinding, so queueScript starts - -- it on the first idle overworld frame -- after the end-battle - -- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722). - if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then - ow:queueScript(silphAftermathRows()) - end - end) - end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._SilphCo11FGiovanniText + or "Ah {PLAYER}!\nSo we meet again!", + function() + ow:scriptMove(gio, "down", 3, function() + gio:facePlayer(ow.player) + ow:engageTrainer(gio, function() + -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" + -- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so + -- every Silph rocket leaves off-screen (the street rockets are + -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not + -- run here: the battle's own callbacks are still unwinding, so + -- queueScript starts it on the first idle overworld frame -- + -- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2 + -- pushes (#722). + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + ow:queueScript(silphAftermathRows()) + end + end, nil, true) + end) + end)) return true end, onEnter = function(game, ow) diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 8cca471b..4f0c507e 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = { -- trick, and the speedrun route this bot follows depends on it. if result == "win" or battle.pokeDollEscape then game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true - game.stack:push(TextBox.new(game, - t._PokemonTower6FSoulWasCalmedText - or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!")) + -- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm) + -- is two texts, not one: the CUBONE's-mother line first, then + -- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants + -- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the + -- calmed line; the port dropped the first text and the cry + -- (#867). play_cry arms the next show_text, so the cry rides + -- the calmed box's open with the button prompt kept, and the + -- wait row stands in for the asm's 30-frame gap. + local rows = { + { "show_text", t._PokemonTower6FGhostWasCubonesMotherText + or "The GHOST was the\nrestless soul of\vCUBONE's mother!" }, + { "play_cry", "MAROWAK", true }, + { "wait", 30 }, + { "show_text", t._PokemonTower6FSoulWasCalmedText + or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" }, + } + if ow.runner then + ow.runner:run(rows) + elseif ow.queueScript then + ow:queueScript(rows) + end elseif result ~= "lose" then -- .did_not_defeat: one simulated step right, off the trigger, -- so fleeing does not leave you standing on a cell that diff --git a/main.lua b/main.lua index f5dbc4d7..ab3c8623 100644 --- a/main.lua +++ b/main.lua @@ -667,7 +667,33 @@ function love.wheelmoved(x, y) Game:wheelmoved(x, y) end +-- #781: Linux X11 multi-monitor with the primary display away from desktop +-- (0,0): SDL's polled mouse state can come back in desktop-virtual +-- coordinates while the event stream stays window-relative, which strands +-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor +-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize +-- the poll once here: remember the last window-relative event coordinates +-- and substitute them whenever the polled value falls outside the window. +-- Linux only -- macOS / Windows / mobile keep the stock function, and the +-- NX launcher shim still composes because it captures whatever +-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge). +local eventMouseX, eventMouseY +if love.system and love.system.getOS() == "Linux" + and love.mouse and love.mouse.getPosition then + local polledGetPosition = love.mouse.getPosition + love.mouse.getPosition = function() + local x, y = polledGetPosition() + local w, h = love.graphics.getDimensions() + if x < 0 or y < 0 or x > w or y > h then + if eventMouseX then return eventMouseX, eventMouseY end + return math.max(0, math.min(x, w)), math.max(0, math.min(y, h)) + end + return x, y + end +end + function love.mousepressed(x, y, button, istouch) + if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second -- mouse path would double-fire Done / begin a second drag. @@ -720,6 +746,7 @@ function love.mousereleased(x, y, button, istouch) end function love.mousemoved(x, y, dx, dy, istouch) + if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) @@ -748,7 +775,13 @@ local quitToLauncher = false function love.quit() if editorMode and EditorApp.quit then - return EditorApp.quit() -- return true to abort quit + -- true blocks the quit (unsaved-changes prompt). A quit that proceeds + -- must fall through to the worker shutdowns below instead of returning: + -- the bundled editor opens from a live launcher whose update-check and + -- fetch-pool workers are still parked in Channel:demand(), and returning + -- here skipped their "quit" push, so the process outlived the closed + -- window and kept the install folder locked on Windows (#727). + if EditorApp.quit() then return true end end -- Closing the window of a running game returns to the launcher instead of -- exiting the app, so testing a mod does not need a relaunch every time diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 3c2153fc..6cba2fb5 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -37,6 +37,13 @@ #include "filesystem/physfs/PhysfsIo.h" +// #604 / #839: the SAF bridges below must hand GameActivity the exact +// directory physfs mounted as the save dir -- the same contract the iOS +// GRPickerBridge already gets (mobile/ios/patch_love_src.py, +// gr_saveDirectory) -- instead of letting Java recompute the root on its +// own, which can name a different volume on merged / adopted-SD storage. +#include "filesystem/Filesystem.h" + namespace love { namespace android @@ -183,6 +190,19 @@ void vibrate(double seconds) env->DeleteLocalRef(activity); } +// The directory physfs actually mounted as the save dir, or "" before the +// filesystem module is up. GameActivity must copy SAF picks HERE: its own +// getExternalFilesDir(null) recomputation can disagree with the mounted +// root on merged / adopted-SD storage (#604, #839). +static const char *bridgeSaveDirectory() +{ + auto fs = Module::getInstance(Module::M_FILESYSTEM); + if (fs == nullptr) + return ""; + const char *dir = fs->getSaveDirectory(); + return dir != nullptr ? dir : ""; +} + bool showFilePicker(const char *destFilename) { if (destFilename == nullptr || destFilename[0] == '\0') @@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename) jclass activity = env->FindClass("org/love2d/android/GameActivity"); jmethodID method = env->GetStaticMethodID(activity, "showFilePicker", - "(Ljava/lang/String;)Z"); + "(Ljava/lang/String;Ljava/lang/String;)Z"); jstring jname = env->NewStringUTF(destFilename); - jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir); + env->DeleteLocalRef(jsavedir); env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); @@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName) jclass activity = env->FindClass("org/love2d/android/GameActivity"); jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument", - "(Ljava/lang/String;)Z"); + "(Ljava/lang/String;Ljava/lang/String;)Z"); jstring jname = env->NewStringUTF(suggestedName); - jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir); + env->DeleteLocalRef(jsavedir); env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 04c8a827..1d70c7fa 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity { // bad ROM instead of installing it (#553). private String pendingPickFilename = PICKED_ROM_FILENAME; private static final String STATE_PENDING_PICK = "pendingPickFilename"; + // Absolute save directory physfs actually mounted, as reported by the + // native bridge call that opened the picker (love/src/common/android.cpp, + // bridgeSaveDirectory). This activity used to recompute + // getExternalFilesDir(null)/save/ on its own at result time; on + // merged / adopted-SD storage that can name a different volume than the + // one LOVE mounted, so the copied pick (and pick_error.flag) landed where + // Lua never scans -- the launcher then "did nothing" after a pick (#604) + // and the folders a file manager can browse stayed empty while the game + // saved fine elsewhere (#839). Empty string means "not told yet": fall + // back to the historical computation. + private String pendingPickSaveDir = ""; + private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir"; private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName"; // Suggested download name for the in-flight SAF create (set by showCreateDocument). private String pendingCreateSuggestedName = "export.sav"; @@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity { // a recreated activity still lands under the basename it asked for. String pick = savedInstanceState.getString(STATE_PENDING_PICK); if (pick != null) pendingPickFilename = pick; + String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR); + if (pickDir != null) pendingPickSaveDir = pickDir; String create = savedInstanceState.getString(STATE_PENDING_CREATE); if (create != null) pendingCreateSuggestedName = create; } @@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity { * @param destFilename basename under the app save identity (e.g. * picked_rom.gb, picked_mod.zip, picked_save.sav) */ + /** Legacy single-argument entry; resolves the save dir itself. */ @Keep public static boolean showFilePicker(String destFilename) { + return showFilePicker(destFilename, null); + } + + @Keep + public static boolean showFilePicker(String destFilename, String saveDir) { GameActivity self = (GameActivity) mSingleton; if (self == null) return false; if (destFilename == null || destFilename.length() == 0) { destFilename = PICKED_ROM_FILENAME; } + // Remember where LOVE's filesystem is really mounted so + // onActivityResult copies the pick there, not into a recomputed + // (possibly different-volume) root (#604, #839). + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; // Reject path separators so a hostile JNI caller cannot escape the // save identity directory. if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { @@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity { * return degrades on the Lua side (RomImporter export) to "Exported * inside the app folder", which is the correct pre-KitKat behavior. */ + /** Legacy single-argument entry; resolves the save dir itself. */ @Keep public static boolean showCreateDocument(String suggestedName) { + return showCreateDocument(suggestedName, null); + } + + @Keep + public static boolean showCreateDocument(String suggestedName, String saveDir) { if (android.os.Build.VERSION.SDK_INT < 19) return false; // (see showFilePicker for why the import side got a pre-19 path) GameActivity self = (GameActivity) mSingleton; @@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); return false; } - File source = new File( - new File(self.getExternalFilesDir(null), "save"), - ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); + // Route through the mounted save dir (#604, #839): Lua staged + // pending_export.sav where physfs writes, which is not necessarily + // where a fresh getExternalFilesDir(null) points on merged / + // adopted-SD storage. + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; + File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME); if (!source.isFile()) { Log.d("GameActivity", "no pending export at " + source); return false; @@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity { } private File saveIdentityDir() { - return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + // Prefer the mounted save dir the last bridge call reported: the + // recomputation below can name a different volume than the one LOVE + // mounted on merged / adopted-SD storage (#604, #839). + if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) { + return new File(pendingPickSaveDir); + } + File ext = getExternalFilesDir(null); + if (ext == null) { + // Shared storage unavailable (ejected / mid-adoption): without + // this guard File(null, "save") silently built the RELATIVE + // path save/, mkdirs() failed against "/", and the + // pick was dropped with no message at all (#604). + ext = getFilesDir(); + } + return new File(new File(ext, "save"), ROM_SAVE_IDENTITY); } /** Drops a small flag file in the save identity for Lua to consume on focus. */ @@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity { protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(STATE_PENDING_PICK, pendingPickFilename); + outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir); outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName); } diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 86c404fb..37d600a5 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -28,6 +28,24 @@ function SafeArea.rect() return 0, 0, ww, wh end + -- A safe rect that cannot fit the window's unit space is a backend + -- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this + -- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top + -- inset that pushed the whole launcher a band down the screen (#810). + -- Convert back to units with per-axis ratios; the axes can disagree on + -- forced-rotation devices (see displayMetrics in src/render/Renderer.lua, + -- #208). + if (w > ww + 0.5 or h > wh + 0.5) + and love.graphics.getPixelDimensions then + local pw, ph = love.graphics.getPixelDimensions() + local dx = (pw and pw > 0) and (pw / ww) or 1 + local dy = (ph and ph > 0) and (ph / wh) or 1 + if dx > 1.01 or dy > 1.01 then + x, w = x / dx, w / dx + y, h = y / dy, h / dy + end + end + -- Clamp to the drawable window so a bad / mid-rotation backend cannot -- push layout outside the surface. x = math.max(0, math.min(x, ww)) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index d9c0569d..882897bb 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -417,6 +417,18 @@ function SaveData.saveOptions(opts, fs) #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") return nil end + -- #828: roll the backup FORWARD to the bytes just verified. The + -- pre-write roll above only preserves the previous file for a death + -- during this rewrite; at rest the backup must hold the newest verified + -- state, because the hard teardown out of a game session (HostShell's + -- restartApp kill on Android, execv on a SteamOS AppImage) can eat the + -- main file outright and loadOptions then promotes this copy. The + -- encoder is key-sorted, so the follow-up rewrites a play session makes + -- (play()'s lastVersion stamp, the in-game save flush) are byte-identical + -- and skip the conditional roll -- without this line the backup still + -- held the file from BEFORE the launcher's change, and recovery reverted + -- the just-changed setting (BATTLE LAYOUT back to OG). + fs.write(OPTIONS_BACKUP_FILENAME, encoded) -- the staged witness has served its purpose; the main file is verified remove(fs, OPTIONS_TMP_FILENAME) return opts diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 0ae0d5b0..6b9e5c9b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2200,7 +2200,15 @@ end -- pinned Play block used to walk up over the cards on a short window, which -- is unusable, and the footer simply lives below the fold until scrolled to. local function minPanelHeight(m) - return math.floor(460 * m.s) + -- One column stacks the actions card, the slot card and the pinned Play + -- block in a single pile, so it needs more room than the side-by-side + -- layout: 460 was tuned for two columns, and on a squat one-column window + -- (a 4:3 device, a phone held upright) it left the slot card clipped + -- inert against the pinned buttons -- Kit's clip bounds hit-testing, so + -- no slot could be picked at all (#852). 660 fits the actions card, one + -- slot row with its pager and New button, and the pinned block; whatever + -- the window cannot show, the page scroll above reaches. + return math.floor((m.twoCol and 460 or 660) * m.s) end function LauncherView.draw(imp) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index eb46ad29..50cae927 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2409,10 +2409,18 @@ function RomImporter:runActions(queue) end -- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded --- mousepressed stays inert so Android's synthesized mouse path cannot --- double-fire a tap (#553). Touch move/press/release must still reach +-- mousepressed mints no click, so Android's synthesized mouse path cannot +-- double-fire a tap (#553). It DOES hand the pointer back from the pad +-- cursor (#781): a Linux boot with a joystick present arms it (see the +-- getJoystickCount block in new()), and while it is active +-- LauncherView.update refuses to mint mouse clicks, so a real press must +-- win the pointer back even when the polled motion yield misses (X11 +-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for +-- the overlay hosts. Touch move/press/release must still reach -- FlexLove.touch* or scroll containers never drag on phones. -function RomImporter:mousepressed() end +function RomImporter:mousepressed() + self._padCursorActive = false +end function RomImporter:touchpressed(id, x, y, dx, dy, pressure) if not self._flex then return end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index b2834028..e078b232 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -474,6 +474,33 @@ local function removeTree(path) fs.remove(path) end +-- Every mods/ folder currently holding this id, plus the bare mods/ tree +-- even when its manifest is missing or unreadable. Second return: whether any +-- of them carries a manifest the panel can actually list. An install names +-- its dest after the manifest id, but a hand-unzipped copy keeps whatever +-- folder name the archive carried, and discover()'s first-id-wins rule means +-- whichever folder physfs happens to enumerate first is the one the panel and +-- the loader really use. Replacing only mods/ let an update report +-- success while the old copy kept winning that race (#801); and a +-- manifest-less mods/ left by an interrupted copy blocked every re-import +-- as "already installed" while showing nowhere the player could see (#834). +local function sameIdTrees(fs, id) + local out, installed = {}, false + if not fs.getInfo("mods") then return out, installed end + for _, name in ipairs(fs.getDirectoryItems("mods")) do + local path = "mods/" .. name + local raw = fs.read(path .. "/manifest.json") + local manifest = raw and decodeManifest(raw, path) + if manifest and manifest.id == id then + out[#out + 1] = path + installed = true + elseif name == id and fs.getInfo(path) then + out[#out + 1] = path + end + end + return out, installed +end + -- ------- strays: mods dropped beside the game that it cannot see -- love.filesystem looks in two places for "mods/": the save directory, and -- @@ -666,16 +693,21 @@ function LauncherMods._installZipInner(source, opts) end local dest = "mods/" .. manifest.id - if fs.getInfo(dest) then - if not opts.replace then - cleanup() - return nil, "a mod named '" .. manifest.id .. "' is already installed" - end - -- drop the old tree before copy; enable-flag is preserved (uninstall - -- would clear it, which would surprise an update) + local existing, installedSomewhere = sameIdTrees(fs, manifest.id) + if installedSomewhere and not opts.replace then + cleanup() + return nil, "a mod named '" .. manifest.id .. "' is already installed" + end + if #existing > 0 then + -- drop every old tree before copy -- mods/ and any same-id folder + -- under another name, or the survivor keeps winning discover()'s + -- first-id-wins race after the "successful" update (#801). A tree with + -- no readable manifest is debris from an interrupted copy: it never + -- refuses the install, it only gets cleared (#834). Enable-flag is + -- preserved (uninstall would clear it, which would surprise an update). local savedPrefix = CacheFs.prefix CacheFs.prefix = "" - removeTree(dest) + for _, path in ipairs(existing) do removeTree(path) end CacheFs.prefix = savedPrefix end @@ -789,14 +821,17 @@ function LauncherMods.uninstall(id) return nil, "mod uninstall needs LOVE" end local fs = love.filesystem - local dest = "mods/" .. id - if not fs.getInfo(dest) then + local trees = sameIdTrees(fs, id) + if #trees == 0 then return nil, "mod '" .. id .. "' is not installed" end - -- same root pin as installZip: the mods tree is not version-prefixed (#330) + -- same root pin as installZip: the mods tree is not version-prefixed (#330). + -- Every same-id tree goes, folder name notwithstanding, so Delete works on a + -- hand-unzipped copy too and cannot leave a shadow copy for discover()'s + -- first-id-wins rule to resurrect on the next boot (#801) local savedPrefix = CacheFs.prefix CacheFs.prefix = "" - removeTree(dest) + for _, path in ipairs(trees) do removeTree(path) end CacheFs.prefix = savedPrefix -- Drop the enable flag so a reinstall of the same id starts from the -- loader's default (enabled) rather than a stale false. diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 69f2bd3f..3b69e2d0 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -820,8 +820,13 @@ function Renderer:endFrame(zones, worldZones) -- non-battle state that opts in) uses the paper shade. "world" never -- reaches here -- it makes the battle non-opaque, so the world pass is -- active and this whole branch is skipped. + -- FAITHFUL RATIO's mobile lock promises the display outside the GB + -- screen stays black (src/core/FaithfulRes.lua); the paper surround + -- painted the whole phone white on New Game and in battle (#864), so + -- the lock keeps the default black bars. if state and state.letterboxWhite - and not (state.bgMode and state:bgMode() == "black") then + and not (state.bgMode and state:bgMode() == "black") + and not FaithfulRes.scaleCap() then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end end @@ -1029,7 +1034,17 @@ function Renderer:endFrame(zones, worldZones) local veil = self.screenVeil if veil and veil[2] > 0 then love.graphics.setColor(veil[1], veil[1], veil[1], veil[2]) - love.graphics.rectangle("fill", 0, 0, ww, wh) + -- FAITHFUL RATIO's mobile lock: the surface the player sees is the + -- locked viewport and the bars around it are dead display, not screen + -- (src/core/FaithfulRes.lua). A whole-window veil lit the entire phone + -- for the battle flash and the post-battle fade (#864), so under the + -- lock the veil stops at the letterbox. The desktop lock is unaffected: + -- there the window IS the viewport. + if FaithfulRes.scaleCap() then + love.graphics.rectangle("fill", ox, oy, vpw, vph) + else + love.graphics.rectangle("fill", 0, 0, ww, wh) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 9f1c3274..6a465acf 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -93,6 +93,12 @@ O.statusFlags1 = O.townVisited + 29 -- 1B O.statusFlags4 = O.townVisited + 35 -- 1B O.elite4Flags = O.townVisited + 41 -- 1B O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES) +-- wToggleableObjectFlags (ram/wram.asm, flag_array $100): the ShowObject/ +-- HideObject persistence, one bit per data/maps/toggleable_objects.asm entry, +-- set = hidden (engine/overworld/toggleable_objects.asm IsObjectHidden). +-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute +-- 0x2852 (#763, #857). +O.toggleObjectFlags = O.coins + 2 -- 32B -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified @@ -107,6 +113,15 @@ O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 2 O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59) O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59) O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks) +-- wPikachuHappiness, Yellow only (pret/pokeyellow ram/wram.asm; no local +-- pokeyellow checkout, so verified against the pokeyellow symbol file +-- instead: d46f - wMainDataStart d2f6 = 377, the well-known absolute +-- 0x271C). In Red/Blue this byte is current-map scratch the game +-- regenerates on load, so the codec touches it only when the crosswalk +-- data set names the game "yellow" (#763, #838). Every other modeled +-- offset is identical between pokered and pokeyellow (same sram.asm, same +-- wMainData field spacing per both symbol files). +O.pikachuHappiness = O.mainData + 377 O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart O.spriteData = O.mainData + O.mainDataSize @@ -729,6 +744,25 @@ function GenSave.decode(bytes, data, opts) if save.flags[vanillaName] then save.flags[portName] = true end end + -- wToggleableObjectFlags -> save.objectToggles (bit set = hidden). A few + -- of these are re-derived from flags on map entry (#106/#234 onEnter + -- re-applies), but most ShowObject/HideObject state -- the Mt Moon + -- fossils, the Cerulean guard swap -- has no flag to re-derive from, so + -- an import that drops the array resurrects taken fossils and blocking + -- guards (#763, #857). + local toggles = data.toggleObjects + if toggles then + save.objectToggles = {} + for bitIdx, e in pairs(toggles.byBit) do + local mapToggles = save.objectToggles[e[1]] + if not mapToggles then + mapToggles = {} + save.objectToggles[e[1]] = mapToggles + end + mapToggles[e[2]] = not bitGet(bytes, O.toggleObjectFlags, bitIdx) + end + end + -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index: -- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- into de and rotates it right one bit per iteration with b counting up @@ -774,6 +808,15 @@ function GenSave.decode(bytes, data, opts) + u8(bytes, O.playTimeSeconds) + u8(bytes, O.playTimeFrames) / 60 + -- Yellow starter friendship (save.pikachuHappiness, + -- src/world/PikachuFollower.lua reads it; pokeyellow's + -- init_player_data.asm seeds 90 on a new game), gated on the data set's + -- game because the byte is map scratch in Red/Blue (see + -- O.pikachuHappiness) (#763, #838). + if data.gameVersion == "yellow" then + save.pikachuHappiness = u8(bytes, O.pikachuHappiness) + end + save.warnings = warnings save.rawImport = bytes -- template for a later encode(); see file header return save @@ -868,6 +911,41 @@ function GenSave.encode(save, data, template) end end + -- wToggleableObjectFlags, written both ways like the #396 extras: this + -- port's save is the authority, and vanilla folds three stores this port + -- keeps separate into these same bits -- script ShowObject/HideObject + -- (save.objectToggles), taken overworld items (engine/events/ + -- pick_up_item.asm -> save.itemsTaken) and beaten static encounters + -- (home/trainers.asm HideObject after battle -> save.defeatedTrainers) -- + -- so all three fold back in here or an exported save resurrects them + -- (#763, #857). + local toggleData = data.toggleObjects + if toggleData then + local objectToggles = save.objectToggles or {} + local itemsTaken = save.itemsTaken or {} + local beaten = save.defeatedTrainers or {} + for bitIdx, e in pairs(toggleData.byBit) do + local mapId, objName, visible = e[1], e[2], e[3] + local mapToggles = objectToggles[mapId] + if mapToggles and mapToggles[objName] ~= nil then + visible = mapToggles[objName] + end + if visible and data.maps and data.maps[mapId] then + for _, obj in ipairs(data.maps[mapId].objects or {}) do + if obj.name == objName then + local key = mapId .. "_obj_" .. obj.index + if (obj.item and itemsTaken[key]) + or (obj.pokemon and beaten[key]) then + visible = false + end + break + end + end + end + bitSet(buf, O.toggleObjectFlags, bitIdx, not visible) + end + end + -- FLY destinations back into wTownVisitedFlag (see the decode note), so a -- save exported from this port is flyable on hardware (#263). A save -- table with no `visited` key at all says nothing about the set, so leave @@ -967,6 +1045,16 @@ function GenSave.encode(save, data, template) setByte(buf, O.playTimeFrames, rem - secs * 60) end + -- Yellow starter friendship back out (see O.pikachuHappiness); Red/Blue + -- data sets never reach this write. 90 is the fresh-game seed the + -- follower system itself uses when the save has never tracked it. + -- Placed before the checksum pass so the byte is covered by the + -- main-data checksum automatically (#763, #838). + if data.gameVersion == "yellow" then + local h = tonumber(save.pikachuHappiness) or 90 + setByte(buf, O.pikachuHappiness, math.max(0, math.min(255, math.floor(h)))) + end + local out = table.concat(buf) -- checksums, computed last over the now-final bytes local outBuf = {} diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index f6ca12d8..b4dc9d34 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -44,6 +44,19 @@ local DATA_MODULES = { maps = { "data.generated.maps", "data/generated/maps.lua" }, charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, + toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, +} + +-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm +-- inserts events pokered does not have (the Jessie & James fights, catch +-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co +-- 11F block, so writing a Yellow save through the Red table lands bits on the +-- wrong events and drops every Yellow-only flag. Kept outside DATA_MODULES so +-- the ensureData loop never loads it as a crosswalk of its own -- it +-- substitutes for `eventFlags` when the caller names Yellow (#838). +local YELLOW_EVENT_FLAGS = { + "src.save_convert.data.event_flags_yellow", + "src/save_convert/data/event_flags_yellow.lua", } local function loadTable(requirePath, filePath) @@ -112,6 +125,9 @@ local function ensureData(gameVersion) local data = {} for name, spec in pairs(DATA_MODULES) do if name ~= "charmap" then + if name == "eventFlags" and gameVersion == "yellow" then + spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838) + end local mod = loadCacheTable(gameVersion, spec[2]) if not mod then local e @@ -121,6 +137,10 @@ local function ensureData(gameVersion) data[name] = mod end end + -- record which game's tables these are: the codec gates Yellow-only + -- bytes (wPikachuFriendship) on it, since those offsets are map + -- scratch in Red/Blue (#763, #838) + data.gameVersion = gameVersion crosswalks[key] = data end if not charmapReady then diff --git a/src/save_convert/data/event_flags_yellow.lua b/src/save_convert/data/event_flags_yellow.lua new file mode 100644 index 00000000..5b65b445 --- /dev/null +++ b/src/save_convert/data/event_flags_yellow.lua @@ -0,0 +1,1057 @@ +-- Generated by tools/build_data.py. DO NOT EDIT. +-- wEventFlags bit index <-> EVENT_* name (pokeyellow numbering; see +-- pokeyellow ram/wram.asm wEventFlags, a flat NUM_EVENTS-bit +-- array). byBit only has entries for bits with a name -- +-- reserved/padding bits are intentionally absent. +return { + byBit = { + [0] = "EVENT_FOLLOWED_OAK_INTO_LAB", + [3] = "EVENT_HALL_OF_FAME_DEX_RATING", + [5] = "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN", + [6] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS", + [24] = "EVENT_GOT_TOWN_MAP", + [25] = "EVENT_ENTERED_BLUES_HOUSE", + [26] = "EVENT_DAISY_WALKING", + [32] = "EVENT_FOLLOWED_OAK_INTO_LAB_2", + [33] = "EVENT_OAK_ASKED_TO_CHOOSE_MON", + [34] = "EVENT_GOT_STARTER", + [35] = "EVENT_BATTLED_RIVAL_IN_OAKS_LAB", + [36] = "EVENT_GOT_POKEBALLS_FROM_OAK", + [37] = "EVENT_GOT_POKEDEX", + [38] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2", + [39] = "EVENT_OAK_APPEARED_IN_PALLET", + [40] = "EVENT_VIRIDIAN_GYM_OPEN", + [41] = "EVENT_GOT_TM42", + [44] = "EVENT_SPAWNED_OLD_MAN_1", + [45] = "EVENT_COMPLETED_CATCH_TRAINING", + [46] = "EVENT_COMPLETED_CATCH_TRAINING_AGAIN", + [47] = "EVENT_INITIAL_CATCH_TRAINING", + [56] = "EVENT_OAK_GOT_PARCEL", + [57] = "EVENT_GOT_OAKS_PARCEL", + [80] = "EVENT_GOT_TM27", + [81] = "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI", + [82] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0", + [83] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1", + [84] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2", + [85] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3", + [86] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4", + [87] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5", + [88] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6", + [89] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7", + [104] = "EVENT_BOUGHT_MUSEUM_TICKET", + [105] = "EVENT_GOT_OLD_AMBER", + [114] = "EVENT_BEAT_PEWTER_GYM_TRAINER_0", + [118] = "EVENT_GOT_TM34", + [119] = "EVENT_BEAT_BROCK", + [152] = "EVENT_BEAT_CERULEAN_RIVAL", + [167] = "EVENT_BEAT_CERULEAN_ROCKET_THIEF", + [168] = "EVENT_GOT_BULBASAUR_IN_CERULEAN", + [186] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_0", + [187] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_1", + [190] = "EVENT_GOT_TM11", + [191] = "EVENT_BEAT_MISTY", + [192] = "EVENT_GOT_BICYCLE", + [238] = "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT", + [239] = "EVENT_BEAT_POKEMON_TOWER_RIVAL", + [241] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0", + [242] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1", + [243] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2", + [249] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0", + [250] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1", + [251] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2", + [258] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0", + [259] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1", + [260] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2", + [261] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3", + [263] = "EVENT_IN_PURIFIED_ZONE", + [265] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0", + [266] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1", + [267] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2", + [271] = "EVENT_BEAT_GHOST_MAROWAK", + [273] = "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES", + [274] = "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT", + [279] = "EVENT_RESCUED_MR_FUJI_2", + [296] = "EVENT_GOT_POKE_FLUTE", + [327] = "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY", + [337] = "EVENT_GOT_BIKE_VOUCHER", + [338] = "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER", + [342] = "EVENT_SEEL_FAN_BOAST", + [343] = "EVENT_PIKACHU_FAN_BOAST", + [352] = "EVENT_2ND_LOCK_OPENED", + [353] = "EVENT_1ST_LOCK_OPENED", + [354] = "EVENT_BEAT_VERMILION_GYM_TRAINER_0", + [355] = "EVENT_BEAT_VERMILION_GYM_TRAINER_1", + [356] = "EVENT_BEAT_VERMILION_GYM_TRAINER_2", + [358] = "EVENT_GOT_TM24", + [359] = "EVENT_BEAT_LT_SURGE", + [384] = "EVENT_GOT_TM41", + [396] = "EVENT_GOT_TM13", + [397] = "EVENT_GOT_TM48", + [398] = "EVENT_GOT_TM49", + [399] = "EVENT_GOT_TM18", + [424] = "EVENT_GOT_TM21", + [425] = "EVENT_BEAT_ERIKA", + [426] = "EVENT_BEAT_CELADON_GYM_TRAINER_0", + [427] = "EVENT_BEAT_CELADON_GYM_TRAINER_1", + [428] = "EVENT_BEAT_CELADON_GYM_TRAINER_2", + [429] = "EVENT_BEAT_CELADON_GYM_TRAINER_3", + [430] = "EVENT_BEAT_CELADON_GYM_TRAINER_4", + [431] = "EVENT_BEAT_CELADON_GYM_TRAINER_5", + [432] = "EVENT_BEAT_CELADON_GYM_TRAINER_6", + [440] = "EVENT_1B8", + [441] = "EVENT_FOUND_ROCKET_HIDEOUT", + [442] = "EVENT_GOT_10_COINS", + [443] = "EVENT_GOT_20_COINS", + [444] = "EVENT_GOT_20_COINS_2", + [447] = "EVENT_1BF", + [480] = "EVENT_GOT_COIN_CASE", + [568] = "EVENT_GOT_HM04", + [569] = "EVENT_GAVE_GOLD_TEETH", + [590] = "EVENT_SAFARI_GAME_OVER", + [591] = "EVENT_IN_SAFARI_ZONE", + [600] = "EVENT_GOT_TM06", + [601] = "EVENT_BEAT_KOGA", + [602] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0", + [603] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1", + [604] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2", + [605] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3", + [606] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4", + [607] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5", + [632] = "EVENT_MANSION_SWITCH_ON", + [649] = "EVENT_BEAT_MANSION_1_TRAINER_0", + [664] = "EVENT_GOT_TM38", + [665] = "EVENT_BEAT_BLAINE", + [666] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_0", + [667] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_1", + [668] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_2", + [669] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_3", + [670] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_4", + [671] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_5", + [672] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_6", + [679] = "EVENT_2A7", + [680] = "EVENT_CINNABAR_GYM_GATE0_UNLOCKED", + [681] = "EVENT_CINNABAR_GYM_GATE1_UNLOCKED", + [682] = "EVENT_CINNABAR_GYM_GATE2_UNLOCKED", + [683] = "EVENT_CINNABAR_GYM_GATE3_UNLOCKED", + [684] = "EVENT_CINNABAR_GYM_GATE4_UNLOCKED", + [685] = "EVENT_CINNABAR_GYM_GATE5_UNLOCKED", + [686] = "EVENT_CINNABAR_GYM_GATE6_UNLOCKED", + [727] = "EVENT_GOT_TM35", + [736] = "EVENT_GAVE_FOSSIL_TO_LAB", + [737] = "EVENT_LAB_STILL_REVIVING_FOSSIL", + [738] = "EVENT_LAB_HANDING_OVER_FOSSIL_MON", + [832] = "EVENT_GOT_TM31", + [848] = "EVENT_DEFEATED_FIGHTING_DOJO", + [849] = "EVENT_BEAT_KARATE_MASTER", + [850] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0", + [851] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1", + [852] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2", + [853] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3", + [854] = "EVENT_GOT_HITMONLEE", + [855] = "EVENT_GOT_HITMONCHAN", + [864] = "EVENT_GOT_TM46", + [865] = "EVENT_BEAT_SABRINA", + [866] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_0", + [867] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_1", + [868] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_2", + [869] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_3", + [870] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_4", + [871] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_5", + [872] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_6", + [919] = "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK", + [944] = "EVENT_GOT_TM29", + [960] = "EVENT_GOT_POTION_SAMPLE", + [984] = "EVENT_GOT_HM05", + [994] = "EVENT_BEAT_ROUTE_3_TRAINER_0", + [995] = "EVENT_BEAT_ROUTE_3_TRAINER_1", + [996] = "EVENT_BEAT_ROUTE_3_TRAINER_2", + [997] = "EVENT_BEAT_ROUTE_3_TRAINER_3", + [998] = "EVENT_BEAT_ROUTE_3_TRAINER_4", + [999] = "EVENT_BEAT_ROUTE_3_TRAINER_5", + [1000] = "EVENT_BEAT_ROUTE_3_TRAINER_6", + [1001] = "EVENT_BEAT_ROUTE_3_TRAINER_7", + [1010] = "EVENT_BEAT_ROUTE_4_TRAINER_0", + [1023] = "EVENT_BOUGHT_MAGIKARP", + [1041] = "EVENT_BEAT_ROUTE_6_TRAINER_0", + [1042] = "EVENT_BEAT_ROUTE_6_TRAINER_1", + [1043] = "EVENT_BEAT_ROUTE_6_TRAINER_2", + [1044] = "EVENT_BEAT_ROUTE_6_TRAINER_3", + [1045] = "EVENT_BEAT_ROUTE_6_TRAINER_4", + [1046] = "EVENT_BEAT_ROUTE_6_TRAINER_5", + [1073] = "EVENT_BEAT_ROUTE_8_TRAINER_0", + [1074] = "EVENT_BEAT_ROUTE_8_TRAINER_1", + [1075] = "EVENT_BEAT_ROUTE_8_TRAINER_2", + [1076] = "EVENT_BEAT_ROUTE_8_TRAINER_3", + [1077] = "EVENT_BEAT_ROUTE_8_TRAINER_4", + [1078] = "EVENT_BEAT_ROUTE_8_TRAINER_5", + [1079] = "EVENT_BEAT_ROUTE_8_TRAINER_6", + [1080] = "EVENT_BEAT_ROUTE_8_TRAINER_7", + [1081] = "EVENT_BEAT_ROUTE_8_TRAINER_8", + [1089] = "EVENT_BEAT_ROUTE_9_TRAINER_0", + [1090] = "EVENT_BEAT_ROUTE_9_TRAINER_1", + [1091] = "EVENT_BEAT_ROUTE_9_TRAINER_2", + [1092] = "EVENT_BEAT_ROUTE_9_TRAINER_3", + [1093] = "EVENT_BEAT_ROUTE_9_TRAINER_4", + [1094] = "EVENT_BEAT_ROUTE_9_TRAINER_5", + [1095] = "EVENT_BEAT_ROUTE_9_TRAINER_6", + [1096] = "EVENT_BEAT_ROUTE_9_TRAINER_7", + [1097] = "EVENT_BEAT_ROUTE_9_TRAINER_8", + [1105] = "EVENT_BEAT_ROUTE_10_TRAINER_0", + [1106] = "EVENT_BEAT_ROUTE_10_TRAINER_1", + [1107] = "EVENT_BEAT_ROUTE_10_TRAINER_2", + [1108] = "EVENT_BEAT_ROUTE_10_TRAINER_3", + [1109] = "EVENT_BEAT_ROUTE_10_TRAINER_4", + [1110] = "EVENT_BEAT_ROUTE_10_TRAINER_5", + [1113] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0", + [1114] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1", + [1115] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2", + [1116] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3", + [1117] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4", + [1118] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5", + [1119] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6", + [1121] = "EVENT_BEAT_POWER_PLANT_VOLTORB_0", + [1122] = "EVENT_BEAT_POWER_PLANT_VOLTORB_1", + [1123] = "EVENT_BEAT_POWER_PLANT_VOLTORB_2", + [1124] = "EVENT_BEAT_POWER_PLANT_VOLTORB_3", + [1125] = "EVENT_BEAT_POWER_PLANT_VOLTORB_4", + [1126] = "EVENT_BEAT_POWER_PLANT_VOLTORB_5", + [1127] = "EVENT_BEAT_POWER_PLANT_VOLTORB_6", + [1128] = "EVENT_BEAT_POWER_PLANT_VOLTORB_7", + [1129] = "EVENT_BEAT_ZAPDOS", + [1137] = "EVENT_BEAT_ROUTE_11_TRAINER_0", + [1138] = "EVENT_BEAT_ROUTE_11_TRAINER_1", + [1139] = "EVENT_BEAT_ROUTE_11_TRAINER_2", + [1140] = "EVENT_BEAT_ROUTE_11_TRAINER_3", + [1141] = "EVENT_BEAT_ROUTE_11_TRAINER_4", + [1142] = "EVENT_BEAT_ROUTE_11_TRAINER_5", + [1143] = "EVENT_BEAT_ROUTE_11_TRAINER_6", + [1144] = "EVENT_BEAT_ROUTE_11_TRAINER_7", + [1145] = "EVENT_BEAT_ROUTE_11_TRAINER_8", + [1146] = "EVENT_BEAT_ROUTE_11_TRAINER_9", + [1151] = "EVENT_GOT_ITEMFINDER", + [1152] = "EVENT_GOT_TM39", + [1154] = "EVENT_BEAT_ROUTE_12_TRAINER_0", + [1155] = "EVENT_BEAT_ROUTE_12_TRAINER_1", + [1156] = "EVENT_BEAT_ROUTE_12_TRAINER_2", + [1157] = "EVENT_BEAT_ROUTE_12_TRAINER_3", + [1158] = "EVENT_BEAT_ROUTE_12_TRAINER_4", + [1159] = "EVENT_BEAT_ROUTE_12_TRAINER_5", + [1160] = "EVENT_BEAT_ROUTE_12_TRAINER_6", + [1166] = "EVENT_FIGHT_ROUTE12_SNORLAX", + [1167] = "EVENT_BEAT_ROUTE12_SNORLAX", + [1169] = "EVENT_BEAT_ROUTE_13_TRAINER_0", + [1170] = "EVENT_BEAT_ROUTE_13_TRAINER_1", + [1171] = "EVENT_BEAT_ROUTE_13_TRAINER_2", + [1172] = "EVENT_BEAT_ROUTE_13_TRAINER_3", + [1173] = "EVENT_BEAT_ROUTE_13_TRAINER_4", + [1174] = "EVENT_BEAT_ROUTE_13_TRAINER_5", + [1175] = "EVENT_BEAT_ROUTE_13_TRAINER_6", + [1176] = "EVENT_BEAT_ROUTE_13_TRAINER_7", + [1177] = "EVENT_BEAT_ROUTE_13_TRAINER_8", + [1178] = "EVENT_BEAT_ROUTE_13_TRAINER_9", + [1185] = "EVENT_BEAT_ROUTE_14_TRAINER_0", + [1186] = "EVENT_BEAT_ROUTE_14_TRAINER_1", + [1187] = "EVENT_BEAT_ROUTE_14_TRAINER_2", + [1188] = "EVENT_BEAT_ROUTE_14_TRAINER_3", + [1189] = "EVENT_BEAT_ROUTE_14_TRAINER_4", + [1190] = "EVENT_BEAT_ROUTE_14_TRAINER_5", + [1191] = "EVENT_BEAT_ROUTE_14_TRAINER_6", + [1192] = "EVENT_BEAT_ROUTE_14_TRAINER_7", + [1193] = "EVENT_BEAT_ROUTE_14_TRAINER_8", + [1194] = "EVENT_BEAT_ROUTE_14_TRAINER_9", + [1200] = "EVENT_GOT_EXP_ALL", + [1201] = "EVENT_BEAT_ROUTE_15_TRAINER_0", + [1202] = "EVENT_BEAT_ROUTE_15_TRAINER_1", + [1203] = "EVENT_BEAT_ROUTE_15_TRAINER_2", + [1204] = "EVENT_BEAT_ROUTE_15_TRAINER_3", + [1205] = "EVENT_BEAT_ROUTE_15_TRAINER_4", + [1206] = "EVENT_BEAT_ROUTE_15_TRAINER_5", + [1207] = "EVENT_BEAT_ROUTE_15_TRAINER_6", + [1208] = "EVENT_BEAT_ROUTE_15_TRAINER_7", + [1209] = "EVENT_BEAT_ROUTE_15_TRAINER_8", + [1210] = "EVENT_BEAT_ROUTE_15_TRAINER_9", + [1217] = "EVENT_BEAT_ROUTE_16_TRAINER_0", + [1218] = "EVENT_BEAT_ROUTE_16_TRAINER_1", + [1219] = "EVENT_BEAT_ROUTE_16_TRAINER_2", + [1220] = "EVENT_BEAT_ROUTE_16_TRAINER_3", + [1221] = "EVENT_BEAT_ROUTE_16_TRAINER_4", + [1222] = "EVENT_BEAT_ROUTE_16_TRAINER_5", + [1224] = "EVENT_FIGHT_ROUTE16_SNORLAX", + [1225] = "EVENT_BEAT_ROUTE16_SNORLAX", + [1230] = "EVENT_GOT_HM02", + [1231] = "EVENT_RESCUED_MR_FUJI", + [1233] = "EVENT_BEAT_ROUTE_17_TRAINER_0", + [1234] = "EVENT_BEAT_ROUTE_17_TRAINER_1", + [1235] = "EVENT_BEAT_ROUTE_17_TRAINER_2", + [1236] = "EVENT_BEAT_ROUTE_17_TRAINER_3", + [1237] = "EVENT_BEAT_ROUTE_17_TRAINER_4", + [1238] = "EVENT_BEAT_ROUTE_17_TRAINER_5", + [1239] = "EVENT_BEAT_ROUTE_17_TRAINER_6", + [1240] = "EVENT_BEAT_ROUTE_17_TRAINER_7", + [1241] = "EVENT_BEAT_ROUTE_17_TRAINER_8", + [1242] = "EVENT_BEAT_ROUTE_17_TRAINER_9", + [1249] = "EVENT_BEAT_ROUTE_18_TRAINER_0", + [1250] = "EVENT_BEAT_ROUTE_18_TRAINER_1", + [1251] = "EVENT_BEAT_ROUTE_18_TRAINER_2", + [1265] = "EVENT_BEAT_ROUTE_19_TRAINER_0", + [1266] = "EVENT_BEAT_ROUTE_19_TRAINER_1", + [1267] = "EVENT_BEAT_ROUTE_19_TRAINER_2", + [1268] = "EVENT_BEAT_ROUTE_19_TRAINER_3", + [1269] = "EVENT_BEAT_ROUTE_19_TRAINER_4", + [1270] = "EVENT_BEAT_ROUTE_19_TRAINER_5", + [1271] = "EVENT_BEAT_ROUTE_19_TRAINER_6", + [1272] = "EVENT_BEAT_ROUTE_19_TRAINER_7", + [1273] = "EVENT_BEAT_ROUTE_19_TRAINER_8", + [1274] = "EVENT_BEAT_ROUTE_19_TRAINER_9", + [1280] = "EVENT_IN_SEAFOAM_ISLANDS", + [1281] = "EVENT_BEAT_ROUTE_20_TRAINER_0", + [1282] = "EVENT_BEAT_ROUTE_20_TRAINER_1", + [1283] = "EVENT_BEAT_ROUTE_20_TRAINER_2", + [1284] = "EVENT_BEAT_ROUTE_20_TRAINER_3", + [1285] = "EVENT_BEAT_ROUTE_20_TRAINER_4", + [1286] = "EVENT_BEAT_ROUTE_20_TRAINER_5", + [1287] = "EVENT_BEAT_ROUTE_20_TRAINER_6", + [1288] = "EVENT_BEAT_ROUTE_20_TRAINER_7", + [1289] = "EVENT_BEAT_ROUTE_20_TRAINER_8", + [1290] = "EVENT_BEAT_ROUTE_20_TRAINER_9", + [1294] = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", + [1295] = "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE", + [1297] = "EVENT_BEAT_ROUTE_21_TRAINER_0", + [1298] = "EVENT_BEAT_ROUTE_21_TRAINER_1", + [1299] = "EVENT_BEAT_ROUTE_21_TRAINER_2", + [1300] = "EVENT_BEAT_ROUTE_21_TRAINER_3", + [1301] = "EVENT_BEAT_ROUTE_21_TRAINER_4", + [1302] = "EVENT_BEAT_ROUTE_21_TRAINER_5", + [1303] = "EVENT_BEAT_ROUTE_21_TRAINER_6", + [1304] = "EVENT_BEAT_ROUTE_21_TRAINER_7", + [1305] = "EVENT_BEAT_ROUTE_21_TRAINER_8", + [1312] = "EVENT_1ST_ROUTE22_RIVAL_BATTLE", + [1313] = "EVENT_2ND_ROUTE22_RIVAL_BATTLE", + [1317] = "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", + [1318] = "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", + [1319] = "EVENT_ROUTE22_RIVAL_WANTS_BATTLE", + [1328] = "EVENT_PASSED_CASCADEBADGE_CHECK", + [1329] = "EVENT_PASSED_THUNDERBADGE_CHECK", + [1330] = "EVENT_PASSED_RAINBOWBADGE_CHECK", + [1331] = "EVENT_PASSED_SOULBADGE_CHECK", + [1332] = "EVENT_PASSED_MARSHBADGE_CHECK", + [1333] = "EVENT_PASSED_VOLCANOBADGE_CHECK", + [1334] = "EVENT_PASSED_EARTHBADGE_CHECK", + [1336] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", + [1337] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0", + [1338] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1", + [1339] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2", + [1340] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3", + [1341] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4", + [1342] = "EVENT_BEAT_MOLTRES", + [1343] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", + [1344] = "EVENT_GOT_NUGGET", + [1345] = "EVENT_BEAT_ROUTE24_ROCKET", + [1346] = "EVENT_BEAT_ROUTE_24_TRAINER_0", + [1347] = "EVENT_BEAT_ROUTE_24_TRAINER_1", + [1348] = "EVENT_BEAT_ROUTE_24_TRAINER_2", + [1349] = "EVENT_BEAT_ROUTE_24_TRAINER_3", + [1350] = "EVENT_BEAT_ROUTE_24_TRAINER_4", + [1351] = "EVENT_BEAT_ROUTE_24_TRAINER_5", + [1353] = "EVENT_NUGGET_REWARD_AVAILABLE", + [1359] = "EVENT_54F", + [1360] = "EVENT_MET_BILL", + [1361] = "EVENT_BEAT_ROUTE_25_TRAINER_0", + [1362] = "EVENT_BEAT_ROUTE_25_TRAINER_1", + [1363] = "EVENT_BEAT_ROUTE_25_TRAINER_2", + [1364] = "EVENT_BEAT_ROUTE_25_TRAINER_3", + [1365] = "EVENT_BEAT_ROUTE_25_TRAINER_4", + [1366] = "EVENT_BEAT_ROUTE_25_TRAINER_5", + [1367] = "EVENT_BEAT_ROUTE_25_TRAINER_6", + [1368] = "EVENT_BEAT_ROUTE_25_TRAINER_7", + [1369] = "EVENT_BEAT_ROUTE_25_TRAINER_8", + [1371] = "EVENT_USED_CELL_SEPARATOR_ON_BILL", + [1372] = "EVENT_GOT_SS_TICKET", + [1373] = "EVENT_MET_BILL_2", + [1374] = "EVENT_BILL_SAID_USE_CELL_SEPARATOR", + [1375] = "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING", + [1378] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0", + [1379] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1", + [1380] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2", + [1381] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3", + [1382] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4", + [1393] = "EVENT_BEAT_MT_MOON_1_TRAINER_0", + [1394] = "EVENT_BEAT_MT_MOON_1_TRAINER_1", + [1395] = "EVENT_BEAT_MT_MOON_1_TRAINER_2", + [1396] = "EVENT_BEAT_MT_MOON_1_TRAINER_3", + [1397] = "EVENT_BEAT_MT_MOON_1_TRAINER_4", + [1398] = "EVENT_BEAT_MT_MOON_1_TRAINER_5", + [1399] = "EVENT_BEAT_MT_MOON_1_TRAINER_6", + [1400] = "EVENT_GOT_DOME_FOSSIL", + [1401] = "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD", + [1402] = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES", + [1403] = "EVENT_BEAT_MT_MOON_3_TRAINER_0", + [1404] = "EVENT_BEAT_MT_MOON_3_TRAINER_1", + [1405] = "EVENT_BEAT_MT_MOON_3_TRAINER_2", + [1406] = "EVENT_57E", + [1407] = "EVENT_GOT_HELIX_FOSSIL", + [1476] = "EVENT_BEAT_SS_ANNE_5_TRAINER_0", + [1477] = "EVENT_BEAT_SS_ANNE_5_TRAINER_1", + [1504] = "EVENT_GOT_HM01", + [1505] = "EVENT_RUBBED_CAPTAINS_BACK", + [1506] = "EVENT_SS_ANNE_LEFT", + [1507] = "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT", + [1508] = "EVENT_STARTED_WALKING_OUT_OF_DOCK", + [1509] = "EVENT_WALKED_OUT_OF_DOCK", + [1521] = "EVENT_BEAT_SS_ANNE_8_TRAINER_0", + [1522] = "EVENT_BEAT_SS_ANNE_8_TRAINER_1", + [1523] = "EVENT_BEAT_SS_ANNE_8_TRAINER_2", + [1524] = "EVENT_BEAT_SS_ANNE_8_TRAINER_3", + [1537] = "EVENT_BEAT_SS_ANNE_9_TRAINER_0", + [1538] = "EVENT_BEAT_SS_ANNE_9_TRAINER_1", + [1539] = "EVENT_BEAT_SS_ANNE_9_TRAINER_2", + [1540] = "EVENT_BEAT_SS_ANNE_9_TRAINER_3", + [1553] = "EVENT_BEAT_SS_ANNE_10_TRAINER_0", + [1554] = "EVENT_BEAT_SS_ANNE_10_TRAINER_1", + [1555] = "EVENT_BEAT_SS_ANNE_10_TRAINER_2", + [1556] = "EVENT_BEAT_SS_ANNE_10_TRAINER_3", + [1557] = "EVENT_BEAT_SS_ANNE_10_TRAINER_4", + [1558] = "EVENT_BEAT_SS_ANNE_10_TRAINER_5", + [1632] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", + [1633] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0", + [1634] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1", + [1635] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2", + [1636] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3", + [1638] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2", + [1649] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0", + [1650] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1", + [1651] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2", + [1652] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3", + [1653] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", + [1655] = "EVENT_ENTERED_ROCKET_HIDEOUT", + [1663] = "EVENT_67F", + [1665] = "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0", + [1681] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0", + [1682] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1", + [1696] = "EVENT_6A0", + [1698] = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES", + [1699] = "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT", + [1700] = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2", + [1701] = "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED", + [1702] = "EVENT_ROCKET_DROPPED_LIFT_KEY", + [1703] = "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI", + [1778] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_0", + [1779] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_1", + [1780] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_2", + [1781] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_3", + [1789] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", + [1790] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", + [1791] = "EVENT_GOT_TM36", + [1794] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_0", + [1795] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_1", + [1800] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", + [1801] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", + [1810] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_0", + [1811] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_1", + [1812] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_2", + [1816] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", + [1817] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", + [1826] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_0", + [1827] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_1", + [1828] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_2", + [1829] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_3", + [1832] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", + [1833] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", + [1834] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", + [1846] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_0", + [1847] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_1", + [1848] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_2", + [1855] = "EVENT_SILPH_CO_6_UNLOCKED_DOOR", + [1856] = "EVENT_BEAT_SILPH_CO_RIVAL", + [1861] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_0", + [1862] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_1", + [1863] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_2", + [1864] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_3", + [1868] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", + [1869] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", + [1870] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", + [1874] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_0", + [1875] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_1", + [1876] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_2", + [1880] = "EVENT_SILPH_CO_8_UNLOCKED_DOOR", + [1890] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_0", + [1891] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_1", + [1892] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_2", + [1896] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", + [1897] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", + [1898] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", + [1899] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", + [1905] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_0", + [1906] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_1", + [1912] = "EVENT_SILPH_CO_10_UNLOCKED_DOOR", + [1920] = "EVENT_780", + [1921] = "EVENT_781", + [1922] = "EVENT_782", + [1924] = "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES", + [1925] = "EVENT_BEAT_SILPH_CO_11F_TRAINER_0", + [1928] = "EVENT_SILPH_CO_11_UNLOCKED_DOOR", + [1933] = "EVENT_GOT_MASTER_BALL", + [1935] = "EVENT_BEAT_SILPH_CO_GIOVANNI", + [2049] = "EVENT_BEAT_MANSION_2_TRAINER_0", + [2065] = "EVENT_BEAT_MANSION_3_TRAINER_0", + [2066] = "EVENT_BEAT_MANSION_3_TRAINER_1", + [2081] = "EVENT_BEAT_MANSION_4_TRAINER_0", + [2082] = "EVENT_BEAT_MANSION_4_TRAINER_1", + [2176] = "EVENT_GOT_HM03", + [2241] = "EVENT_BEAT_MEWTWO", + [2273] = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", + [2278] = "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM", + [2281] = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", + [2286] = "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM", + [2289] = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", + [2294] = "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM", + [2297] = "EVENT_BEAT_LANCES_ROOM_TRAINER_0", + [2302] = "EVENT_BEAT_LANCE", + [2303] = "EVENT_LANCES_ROOM_LOCK_DOOR", + [2305] = "EVENT_BEAT_CHAMPION_RIVAL", + [2321] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0", + [2322] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1", + [2327] = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", + [2481] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0", + [2482] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1", + [2483] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2", + [2484] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3", + [2485] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4", + [2486] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5", + [2487] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6", + [2488] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7", + [2496] = "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE", + [2497] = "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE", + [2504] = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + [2505] = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE", + [2512] = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + [2513] = "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE", + [2522] = "EVENT_BEAT_ARTICUNO", + }, + byName = { + EVENT_1B8 = 440, + EVENT_1BF = 447, + EVENT_1ST_LOCK_OPENED = 353, + EVENT_1ST_ROUTE22_RIVAL_BATTLE = 1312, + EVENT_2A7 = 679, + EVENT_2ND_LOCK_OPENED = 352, + EVENT_2ND_ROUTE22_RIVAL_BATTLE = 1313, + EVENT_54F = 1359, + EVENT_57E = 1406, + EVENT_67F = 1663, + EVENT_6A0 = 1696, + EVENT_780 = 1920, + EVENT_781 = 1921, + EVENT_782 = 1922, + EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = 2294, + EVENT_AUTOWALKED_INTO_BRUNOS_ROOM = 2286, + EVENT_AUTOWALKED_INTO_LORELEIS_ROOM = 2278, + EVENT_BATTLED_RIVAL_IN_OAKS_LAB = 35, + EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = 2289, + EVENT_BEAT_ARTICUNO = 2522, + EVENT_BEAT_BLAINE = 665, + EVENT_BEAT_BROCK = 119, + EVENT_BEAT_BRUNOS_ROOM_TRAINER_0 = 2281, + EVENT_BEAT_CELADON_GYM_TRAINER_0 = 426, + EVENT_BEAT_CELADON_GYM_TRAINER_1 = 427, + EVENT_BEAT_CELADON_GYM_TRAINER_2 = 428, + EVENT_BEAT_CELADON_GYM_TRAINER_3 = 429, + EVENT_BEAT_CELADON_GYM_TRAINER_4 = 430, + EVENT_BEAT_CELADON_GYM_TRAINER_5 = 431, + EVENT_BEAT_CELADON_GYM_TRAINER_6 = 432, + EVENT_BEAT_CERULEAN_GYM_TRAINER_0 = 186, + EVENT_BEAT_CERULEAN_GYM_TRAINER_1 = 187, + EVENT_BEAT_CERULEAN_RIVAL = 152, + EVENT_BEAT_CERULEAN_ROCKET_THIEF = 167, + EVENT_BEAT_CHAMPION_RIVAL = 2305, + EVENT_BEAT_CINNABAR_GYM_TRAINER_0 = 666, + EVENT_BEAT_CINNABAR_GYM_TRAINER_1 = 667, + EVENT_BEAT_CINNABAR_GYM_TRAINER_2 = 668, + EVENT_BEAT_CINNABAR_GYM_TRAINER_3 = 669, + EVENT_BEAT_CINNABAR_GYM_TRAINER_4 = 670, + EVENT_BEAT_CINNABAR_GYM_TRAINER_5 = 671, + EVENT_BEAT_CINNABAR_GYM_TRAINER_6 = 672, + EVENT_BEAT_ERIKA = 425, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = 850, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = 851, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = 852, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = 853, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_0 = 602, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_1 = 603, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_2 = 604, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_3 = 605, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_4 = 606, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_5 = 607, + EVENT_BEAT_GHOST_MAROWAK = 271, + EVENT_BEAT_KARATE_MASTER = 849, + EVENT_BEAT_KOGA = 601, + EVENT_BEAT_LANCE = 2302, + EVENT_BEAT_LANCES_ROOM_TRAINER_0 = 2297, + EVENT_BEAT_LORELEIS_ROOM_TRAINER_0 = 2273, + EVENT_BEAT_LT_SURGE = 359, + EVENT_BEAT_MANSION_1_TRAINER_0 = 649, + EVENT_BEAT_MANSION_2_TRAINER_0 = 2049, + EVENT_BEAT_MANSION_3_TRAINER_0 = 2065, + EVENT_BEAT_MANSION_3_TRAINER_1 = 2066, + EVENT_BEAT_MANSION_4_TRAINER_0 = 2081, + EVENT_BEAT_MANSION_4_TRAINER_1 = 2082, + EVENT_BEAT_MEWTWO = 2241, + EVENT_BEAT_MISTY = 191, + EVENT_BEAT_MOLTRES = 1342, + EVENT_BEAT_MT_MOON_1_TRAINER_0 = 1393, + EVENT_BEAT_MT_MOON_1_TRAINER_1 = 1394, + EVENT_BEAT_MT_MOON_1_TRAINER_2 = 1395, + EVENT_BEAT_MT_MOON_1_TRAINER_3 = 1396, + EVENT_BEAT_MT_MOON_1_TRAINER_4 = 1397, + EVENT_BEAT_MT_MOON_1_TRAINER_5 = 1398, + EVENT_BEAT_MT_MOON_1_TRAINER_6 = 1399, + EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = 1402, + EVENT_BEAT_MT_MOON_3_TRAINER_0 = 1403, + EVENT_BEAT_MT_MOON_3_TRAINER_1 = 1404, + EVENT_BEAT_MT_MOON_3_TRAINER_2 = 1405, + EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD = 1401, + EVENT_BEAT_PEWTER_GYM_TRAINER_0 = 114, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_0 = 241, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_1 = 242, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_2 = 243, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_0 = 249, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_1 = 250, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_2 = 251, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_0 = 258, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_1 = 259, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_2 = 260, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_3 = 261, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_0 = 265, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_1 = 266, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_2 = 267, + EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES = 273, + EVENT_BEAT_POKEMON_TOWER_RIVAL = 239, + EVENT_BEAT_POWER_PLANT_VOLTORB_0 = 1121, + EVENT_BEAT_POWER_PLANT_VOLTORB_1 = 1122, + EVENT_BEAT_POWER_PLANT_VOLTORB_2 = 1123, + EVENT_BEAT_POWER_PLANT_VOLTORB_3 = 1124, + EVENT_BEAT_POWER_PLANT_VOLTORB_4 = 1125, + EVENT_BEAT_POWER_PLANT_VOLTORB_5 = 1126, + EVENT_BEAT_POWER_PLANT_VOLTORB_6 = 1127, + EVENT_BEAT_POWER_PLANT_VOLTORB_7 = 1128, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0 = 1649, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1 = 1650, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2 = 1651, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3 = 1652, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = 1653, + EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0 = 1665, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0 = 1681, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1 = 1682, + EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES = 1698, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = 1700, + EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = 1703, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0 = 1113, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1 = 1114, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2 = 1115, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3 = 1116, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4 = 1117, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5 = 1118, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6 = 1119, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0 = 2481, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1 = 2482, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2 = 2483, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3 = 2484, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4 = 2485, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5 = 2486, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6 = 2487, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7 = 2488, + EVENT_BEAT_ROUTE12_SNORLAX = 1167, + EVENT_BEAT_ROUTE16_SNORLAX = 1225, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = 1317, + EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE = 1318, + EVENT_BEAT_ROUTE24_ROCKET = 1345, + EVENT_BEAT_ROUTE_10_TRAINER_0 = 1105, + EVENT_BEAT_ROUTE_10_TRAINER_1 = 1106, + EVENT_BEAT_ROUTE_10_TRAINER_2 = 1107, + EVENT_BEAT_ROUTE_10_TRAINER_3 = 1108, + EVENT_BEAT_ROUTE_10_TRAINER_4 = 1109, + EVENT_BEAT_ROUTE_10_TRAINER_5 = 1110, + EVENT_BEAT_ROUTE_11_TRAINER_0 = 1137, + EVENT_BEAT_ROUTE_11_TRAINER_1 = 1138, + EVENT_BEAT_ROUTE_11_TRAINER_2 = 1139, + EVENT_BEAT_ROUTE_11_TRAINER_3 = 1140, + EVENT_BEAT_ROUTE_11_TRAINER_4 = 1141, + EVENT_BEAT_ROUTE_11_TRAINER_5 = 1142, + EVENT_BEAT_ROUTE_11_TRAINER_6 = 1143, + EVENT_BEAT_ROUTE_11_TRAINER_7 = 1144, + EVENT_BEAT_ROUTE_11_TRAINER_8 = 1145, + EVENT_BEAT_ROUTE_11_TRAINER_9 = 1146, + EVENT_BEAT_ROUTE_12_TRAINER_0 = 1154, + EVENT_BEAT_ROUTE_12_TRAINER_1 = 1155, + EVENT_BEAT_ROUTE_12_TRAINER_2 = 1156, + EVENT_BEAT_ROUTE_12_TRAINER_3 = 1157, + EVENT_BEAT_ROUTE_12_TRAINER_4 = 1158, + EVENT_BEAT_ROUTE_12_TRAINER_5 = 1159, + EVENT_BEAT_ROUTE_12_TRAINER_6 = 1160, + EVENT_BEAT_ROUTE_13_TRAINER_0 = 1169, + EVENT_BEAT_ROUTE_13_TRAINER_1 = 1170, + EVENT_BEAT_ROUTE_13_TRAINER_2 = 1171, + EVENT_BEAT_ROUTE_13_TRAINER_3 = 1172, + EVENT_BEAT_ROUTE_13_TRAINER_4 = 1173, + EVENT_BEAT_ROUTE_13_TRAINER_5 = 1174, + EVENT_BEAT_ROUTE_13_TRAINER_6 = 1175, + EVENT_BEAT_ROUTE_13_TRAINER_7 = 1176, + EVENT_BEAT_ROUTE_13_TRAINER_8 = 1177, + EVENT_BEAT_ROUTE_13_TRAINER_9 = 1178, + EVENT_BEAT_ROUTE_14_TRAINER_0 = 1185, + EVENT_BEAT_ROUTE_14_TRAINER_1 = 1186, + EVENT_BEAT_ROUTE_14_TRAINER_2 = 1187, + EVENT_BEAT_ROUTE_14_TRAINER_3 = 1188, + EVENT_BEAT_ROUTE_14_TRAINER_4 = 1189, + EVENT_BEAT_ROUTE_14_TRAINER_5 = 1190, + EVENT_BEAT_ROUTE_14_TRAINER_6 = 1191, + EVENT_BEAT_ROUTE_14_TRAINER_7 = 1192, + EVENT_BEAT_ROUTE_14_TRAINER_8 = 1193, + EVENT_BEAT_ROUTE_14_TRAINER_9 = 1194, + EVENT_BEAT_ROUTE_15_TRAINER_0 = 1201, + EVENT_BEAT_ROUTE_15_TRAINER_1 = 1202, + EVENT_BEAT_ROUTE_15_TRAINER_2 = 1203, + EVENT_BEAT_ROUTE_15_TRAINER_3 = 1204, + EVENT_BEAT_ROUTE_15_TRAINER_4 = 1205, + EVENT_BEAT_ROUTE_15_TRAINER_5 = 1206, + EVENT_BEAT_ROUTE_15_TRAINER_6 = 1207, + EVENT_BEAT_ROUTE_15_TRAINER_7 = 1208, + EVENT_BEAT_ROUTE_15_TRAINER_8 = 1209, + EVENT_BEAT_ROUTE_15_TRAINER_9 = 1210, + EVENT_BEAT_ROUTE_16_TRAINER_0 = 1217, + EVENT_BEAT_ROUTE_16_TRAINER_1 = 1218, + EVENT_BEAT_ROUTE_16_TRAINER_2 = 1219, + EVENT_BEAT_ROUTE_16_TRAINER_3 = 1220, + EVENT_BEAT_ROUTE_16_TRAINER_4 = 1221, + EVENT_BEAT_ROUTE_16_TRAINER_5 = 1222, + EVENT_BEAT_ROUTE_17_TRAINER_0 = 1233, + EVENT_BEAT_ROUTE_17_TRAINER_1 = 1234, + EVENT_BEAT_ROUTE_17_TRAINER_2 = 1235, + EVENT_BEAT_ROUTE_17_TRAINER_3 = 1236, + EVENT_BEAT_ROUTE_17_TRAINER_4 = 1237, + EVENT_BEAT_ROUTE_17_TRAINER_5 = 1238, + EVENT_BEAT_ROUTE_17_TRAINER_6 = 1239, + EVENT_BEAT_ROUTE_17_TRAINER_7 = 1240, + EVENT_BEAT_ROUTE_17_TRAINER_8 = 1241, + EVENT_BEAT_ROUTE_17_TRAINER_9 = 1242, + EVENT_BEAT_ROUTE_18_TRAINER_0 = 1249, + EVENT_BEAT_ROUTE_18_TRAINER_1 = 1250, + EVENT_BEAT_ROUTE_18_TRAINER_2 = 1251, + EVENT_BEAT_ROUTE_19_TRAINER_0 = 1265, + EVENT_BEAT_ROUTE_19_TRAINER_1 = 1266, + EVENT_BEAT_ROUTE_19_TRAINER_2 = 1267, + EVENT_BEAT_ROUTE_19_TRAINER_3 = 1268, + EVENT_BEAT_ROUTE_19_TRAINER_4 = 1269, + EVENT_BEAT_ROUTE_19_TRAINER_5 = 1270, + EVENT_BEAT_ROUTE_19_TRAINER_6 = 1271, + EVENT_BEAT_ROUTE_19_TRAINER_7 = 1272, + EVENT_BEAT_ROUTE_19_TRAINER_8 = 1273, + EVENT_BEAT_ROUTE_19_TRAINER_9 = 1274, + EVENT_BEAT_ROUTE_20_TRAINER_0 = 1281, + EVENT_BEAT_ROUTE_20_TRAINER_1 = 1282, + EVENT_BEAT_ROUTE_20_TRAINER_2 = 1283, + EVENT_BEAT_ROUTE_20_TRAINER_3 = 1284, + EVENT_BEAT_ROUTE_20_TRAINER_4 = 1285, + EVENT_BEAT_ROUTE_20_TRAINER_5 = 1286, + EVENT_BEAT_ROUTE_20_TRAINER_6 = 1287, + EVENT_BEAT_ROUTE_20_TRAINER_7 = 1288, + EVENT_BEAT_ROUTE_20_TRAINER_8 = 1289, + EVENT_BEAT_ROUTE_20_TRAINER_9 = 1290, + EVENT_BEAT_ROUTE_21_TRAINER_0 = 1297, + EVENT_BEAT_ROUTE_21_TRAINER_1 = 1298, + EVENT_BEAT_ROUTE_21_TRAINER_2 = 1299, + EVENT_BEAT_ROUTE_21_TRAINER_3 = 1300, + EVENT_BEAT_ROUTE_21_TRAINER_4 = 1301, + EVENT_BEAT_ROUTE_21_TRAINER_5 = 1302, + EVENT_BEAT_ROUTE_21_TRAINER_6 = 1303, + EVENT_BEAT_ROUTE_21_TRAINER_7 = 1304, + EVENT_BEAT_ROUTE_21_TRAINER_8 = 1305, + EVENT_BEAT_ROUTE_24_TRAINER_0 = 1346, + EVENT_BEAT_ROUTE_24_TRAINER_1 = 1347, + EVENT_BEAT_ROUTE_24_TRAINER_2 = 1348, + EVENT_BEAT_ROUTE_24_TRAINER_3 = 1349, + EVENT_BEAT_ROUTE_24_TRAINER_4 = 1350, + EVENT_BEAT_ROUTE_24_TRAINER_5 = 1351, + EVENT_BEAT_ROUTE_25_TRAINER_0 = 1361, + EVENT_BEAT_ROUTE_25_TRAINER_1 = 1362, + EVENT_BEAT_ROUTE_25_TRAINER_2 = 1363, + EVENT_BEAT_ROUTE_25_TRAINER_3 = 1364, + EVENT_BEAT_ROUTE_25_TRAINER_4 = 1365, + EVENT_BEAT_ROUTE_25_TRAINER_5 = 1366, + EVENT_BEAT_ROUTE_25_TRAINER_6 = 1367, + EVENT_BEAT_ROUTE_25_TRAINER_7 = 1368, + EVENT_BEAT_ROUTE_25_TRAINER_8 = 1369, + EVENT_BEAT_ROUTE_3_TRAINER_0 = 994, + EVENT_BEAT_ROUTE_3_TRAINER_1 = 995, + EVENT_BEAT_ROUTE_3_TRAINER_2 = 996, + EVENT_BEAT_ROUTE_3_TRAINER_3 = 997, + EVENT_BEAT_ROUTE_3_TRAINER_4 = 998, + EVENT_BEAT_ROUTE_3_TRAINER_5 = 999, + EVENT_BEAT_ROUTE_3_TRAINER_6 = 1000, + EVENT_BEAT_ROUTE_3_TRAINER_7 = 1001, + EVENT_BEAT_ROUTE_4_TRAINER_0 = 1010, + EVENT_BEAT_ROUTE_6_TRAINER_0 = 1041, + EVENT_BEAT_ROUTE_6_TRAINER_1 = 1042, + EVENT_BEAT_ROUTE_6_TRAINER_2 = 1043, + EVENT_BEAT_ROUTE_6_TRAINER_3 = 1044, + EVENT_BEAT_ROUTE_6_TRAINER_4 = 1045, + EVENT_BEAT_ROUTE_6_TRAINER_5 = 1046, + EVENT_BEAT_ROUTE_8_TRAINER_0 = 1073, + EVENT_BEAT_ROUTE_8_TRAINER_1 = 1074, + EVENT_BEAT_ROUTE_8_TRAINER_2 = 1075, + EVENT_BEAT_ROUTE_8_TRAINER_3 = 1076, + EVENT_BEAT_ROUTE_8_TRAINER_4 = 1077, + EVENT_BEAT_ROUTE_8_TRAINER_5 = 1078, + EVENT_BEAT_ROUTE_8_TRAINER_6 = 1079, + EVENT_BEAT_ROUTE_8_TRAINER_7 = 1080, + EVENT_BEAT_ROUTE_8_TRAINER_8 = 1081, + EVENT_BEAT_ROUTE_9_TRAINER_0 = 1089, + EVENT_BEAT_ROUTE_9_TRAINER_1 = 1090, + EVENT_BEAT_ROUTE_9_TRAINER_2 = 1091, + EVENT_BEAT_ROUTE_9_TRAINER_3 = 1092, + EVENT_BEAT_ROUTE_9_TRAINER_4 = 1093, + EVENT_BEAT_ROUTE_9_TRAINER_5 = 1094, + EVENT_BEAT_ROUTE_9_TRAINER_6 = 1095, + EVENT_BEAT_ROUTE_9_TRAINER_7 = 1096, + EVENT_BEAT_ROUTE_9_TRAINER_8 = 1097, + EVENT_BEAT_SABRINA = 865, + EVENT_BEAT_SAFFRON_GYM_TRAINER_0 = 866, + EVENT_BEAT_SAFFRON_GYM_TRAINER_1 = 867, + EVENT_BEAT_SAFFRON_GYM_TRAINER_2 = 868, + EVENT_BEAT_SAFFRON_GYM_TRAINER_3 = 869, + EVENT_BEAT_SAFFRON_GYM_TRAINER_4 = 870, + EVENT_BEAT_SAFFRON_GYM_TRAINER_5 = 871, + EVENT_BEAT_SAFFRON_GYM_TRAINER_6 = 872, + EVENT_BEAT_SILPH_CO_10F_TRAINER_0 = 1905, + EVENT_BEAT_SILPH_CO_10F_TRAINER_1 = 1906, + EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES = 1924, + EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = 1925, + EVENT_BEAT_SILPH_CO_2F_TRAINER_0 = 1778, + EVENT_BEAT_SILPH_CO_2F_TRAINER_1 = 1779, + EVENT_BEAT_SILPH_CO_2F_TRAINER_2 = 1780, + EVENT_BEAT_SILPH_CO_2F_TRAINER_3 = 1781, + EVENT_BEAT_SILPH_CO_3F_TRAINER_0 = 1794, + EVENT_BEAT_SILPH_CO_3F_TRAINER_1 = 1795, + EVENT_BEAT_SILPH_CO_4F_TRAINER_0 = 1810, + EVENT_BEAT_SILPH_CO_4F_TRAINER_1 = 1811, + EVENT_BEAT_SILPH_CO_4F_TRAINER_2 = 1812, + EVENT_BEAT_SILPH_CO_5F_TRAINER_0 = 1826, + EVENT_BEAT_SILPH_CO_5F_TRAINER_1 = 1827, + EVENT_BEAT_SILPH_CO_5F_TRAINER_2 = 1828, + EVENT_BEAT_SILPH_CO_5F_TRAINER_3 = 1829, + EVENT_BEAT_SILPH_CO_6F_TRAINER_0 = 1846, + EVENT_BEAT_SILPH_CO_6F_TRAINER_1 = 1847, + EVENT_BEAT_SILPH_CO_6F_TRAINER_2 = 1848, + EVENT_BEAT_SILPH_CO_7F_TRAINER_0 = 1861, + EVENT_BEAT_SILPH_CO_7F_TRAINER_1 = 1862, + EVENT_BEAT_SILPH_CO_7F_TRAINER_2 = 1863, + EVENT_BEAT_SILPH_CO_7F_TRAINER_3 = 1864, + EVENT_BEAT_SILPH_CO_8F_TRAINER_0 = 1874, + EVENT_BEAT_SILPH_CO_8F_TRAINER_1 = 1875, + EVENT_BEAT_SILPH_CO_8F_TRAINER_2 = 1876, + EVENT_BEAT_SILPH_CO_9F_TRAINER_0 = 1890, + EVENT_BEAT_SILPH_CO_9F_TRAINER_1 = 1891, + EVENT_BEAT_SILPH_CO_9F_TRAINER_2 = 1892, + EVENT_BEAT_SILPH_CO_GIOVANNI = 1935, + EVENT_BEAT_SILPH_CO_RIVAL = 1856, + EVENT_BEAT_SS_ANNE_10_TRAINER_0 = 1553, + EVENT_BEAT_SS_ANNE_10_TRAINER_1 = 1554, + EVENT_BEAT_SS_ANNE_10_TRAINER_2 = 1555, + EVENT_BEAT_SS_ANNE_10_TRAINER_3 = 1556, + EVENT_BEAT_SS_ANNE_10_TRAINER_4 = 1557, + EVENT_BEAT_SS_ANNE_10_TRAINER_5 = 1558, + EVENT_BEAT_SS_ANNE_5_TRAINER_0 = 1476, + EVENT_BEAT_SS_ANNE_5_TRAINER_1 = 1477, + EVENT_BEAT_SS_ANNE_8_TRAINER_0 = 1521, + EVENT_BEAT_SS_ANNE_8_TRAINER_1 = 1522, + EVENT_BEAT_SS_ANNE_8_TRAINER_2 = 1523, + EVENT_BEAT_SS_ANNE_8_TRAINER_3 = 1524, + EVENT_BEAT_SS_ANNE_9_TRAINER_0 = 1537, + EVENT_BEAT_SS_ANNE_9_TRAINER_1 = 1538, + EVENT_BEAT_SS_ANNE_9_TRAINER_2 = 1539, + EVENT_BEAT_SS_ANNE_9_TRAINER_3 = 1540, + EVENT_BEAT_VERMILION_GYM_TRAINER_0 = 354, + EVENT_BEAT_VERMILION_GYM_TRAINER_1 = 355, + EVENT_BEAT_VERMILION_GYM_TRAINER_2 = 356, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0 = 2321, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1 = 2322, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0 = 1337, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1 = 1338, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2 = 1339, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3 = 1340, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4 = 1341, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0 = 1633, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1 = 1634, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2 = 1635, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3 = 1636, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0 = 1378, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1 = 1379, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2 = 1380, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3 = 1381, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4 = 1382, + EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI = 81, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0 = 82, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1 = 83, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2 = 84, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3 = 85, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4 = 86, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5 = 87, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6 = 88, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7 = 89, + EVENT_BEAT_ZAPDOS = 1129, + EVENT_BILL_SAID_USE_CELL_SEPARATOR = 1374, + EVENT_BOUGHT_MAGIKARP = 1023, + EVENT_BOUGHT_MUSEUM_TICKET = 104, + EVENT_CINNABAR_GYM_GATE0_UNLOCKED = 680, + EVENT_CINNABAR_GYM_GATE1_UNLOCKED = 681, + EVENT_CINNABAR_GYM_GATE2_UNLOCKED = 682, + EVENT_CINNABAR_GYM_GATE3_UNLOCKED = 683, + EVENT_CINNABAR_GYM_GATE4_UNLOCKED = 684, + EVENT_CINNABAR_GYM_GATE5_UNLOCKED = 685, + EVENT_CINNABAR_GYM_GATE6_UNLOCKED = 686, + EVENT_COMPLETED_CATCH_TRAINING = 45, + EVENT_COMPLETED_CATCH_TRAINING_AGAIN = 46, + EVENT_DAISY_WALKING = 26, + EVENT_DEFEATED_FIGHTING_DOJO = 848, + EVENT_ENTERED_BLUES_HOUSE = 25, + EVENT_ENTERED_ROCKET_HIDEOUT = 1655, + EVENT_FIGHT_ROUTE12_SNORLAX = 1166, + EVENT_FIGHT_ROUTE16_SNORLAX = 1224, + EVENT_FOLLOWED_OAK_INTO_LAB = 0, + EVENT_FOLLOWED_OAK_INTO_LAB_2 = 32, + EVENT_FOUND_ROCKET_HIDEOUT = 441, + EVENT_GAVE_FOSSIL_TO_LAB = 736, + EVENT_GAVE_GOLD_TEETH = 569, + EVENT_GOT_10_COINS = 442, + EVENT_GOT_20_COINS = 443, + EVENT_GOT_20_COINS_2 = 444, + EVENT_GOT_BICYCLE = 192, + EVENT_GOT_BIKE_VOUCHER = 337, + EVENT_GOT_BULBASAUR_IN_CERULEAN = 168, + EVENT_GOT_COIN_CASE = 480, + EVENT_GOT_DOME_FOSSIL = 1400, + EVENT_GOT_EXP_ALL = 1200, + EVENT_GOT_HELIX_FOSSIL = 1407, + EVENT_GOT_HITMONCHAN = 855, + EVENT_GOT_HITMONLEE = 854, + EVENT_GOT_HM01 = 1504, + EVENT_GOT_HM02 = 1230, + EVENT_GOT_HM03 = 2176, + EVENT_GOT_HM04 = 568, + EVENT_GOT_HM05 = 984, + EVENT_GOT_ITEMFINDER = 1151, + EVENT_GOT_MASTER_BALL = 1933, + EVENT_GOT_NUGGET = 1344, + EVENT_GOT_OAKS_PARCEL = 57, + EVENT_GOT_OLD_AMBER = 105, + EVENT_GOT_POKEBALLS_FROM_OAK = 36, + EVENT_GOT_POKEDEX = 37, + EVENT_GOT_POKE_FLUTE = 296, + EVENT_GOT_POTION_SAMPLE = 960, + EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY = 327, + EVENT_GOT_SS_TICKET = 1372, + EVENT_GOT_STARTER = 34, + EVENT_GOT_TM06 = 600, + EVENT_GOT_TM11 = 190, + EVENT_GOT_TM13 = 396, + EVENT_GOT_TM18 = 399, + EVENT_GOT_TM21 = 424, + EVENT_GOT_TM24 = 358, + EVENT_GOT_TM27 = 80, + EVENT_GOT_TM29 = 944, + EVENT_GOT_TM31 = 832, + EVENT_GOT_TM34 = 118, + EVENT_GOT_TM35 = 727, + EVENT_GOT_TM36 = 1791, + EVENT_GOT_TM38 = 664, + EVENT_GOT_TM39 = 1152, + EVENT_GOT_TM41 = 384, + EVENT_GOT_TM42 = 41, + EVENT_GOT_TM46 = 864, + EVENT_GOT_TM48 = 397, + EVENT_GOT_TM49 = 398, + EVENT_GOT_TOWN_MAP = 24, + EVENT_HALL_OF_FAME_DEX_RATING = 3, + EVENT_INITIAL_CATCH_TRAINING = 47, + EVENT_IN_PURIFIED_ZONE = 263, + EVENT_IN_SAFARI_ZONE = 591, + EVENT_IN_SEAFOAM_ISLANDS = 1280, + EVENT_LAB_HANDING_OVER_FOSSIL_MON = 738, + EVENT_LAB_STILL_REVIVING_FOSSIL = 737, + EVENT_LANCES_ROOM_LOCK_DOOR = 2303, + EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = 1375, + EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER = 338, + EVENT_MANSION_SWITCH_ON = 632, + EVENT_MET_BILL = 1360, + EVENT_MET_BILL_2 = 1373, + EVENT_NUGGET_REWARD_AVAILABLE = 1353, + EVENT_OAK_APPEARED_IN_PALLET = 39, + EVENT_OAK_ASKED_TO_CHOOSE_MON = 33, + EVENT_OAK_GOT_PARCEL = 56, + EVENT_PALLET_AFTER_GETTING_POKEBALLS = 6, + EVENT_PALLET_AFTER_GETTING_POKEBALLS_2 = 38, + EVENT_PASSED_CASCADEBADGE_CHECK = 1328, + EVENT_PASSED_EARTHBADGE_CHECK = 1334, + EVENT_PASSED_MARSHBADGE_CHECK = 1332, + EVENT_PASSED_RAINBOWBADGE_CHECK = 1330, + EVENT_PASSED_SOULBADGE_CHECK = 1331, + EVENT_PASSED_THUNDERBADGE_CHECK = 1329, + EVENT_PASSED_VOLCANOBADGE_CHECK = 1333, + EVENT_PIKACHU_FAN_BOAST = 343, + EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN = 5, + EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT = 274, + EVENT_POKEMON_TOWER_RIVAL_ON_LEFT = 238, + EVENT_RESCUED_MR_FUJI = 1231, + EVENT_RESCUED_MR_FUJI_2 = 279, + EVENT_ROCKET_DROPPED_LIFT_KEY = 1702, + EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED = 1701, + EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = 1699, + EVENT_ROUTE22_RIVAL_WANTS_BATTLE = 1319, + EVENT_RUBBED_CAPTAINS_BACK = 1505, + EVENT_SAFARI_GAME_OVER = 590, + EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE = 1294, + EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE = 1295, + EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE = 2496, + EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE = 2497, + EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = 2504, + EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = 2505, + EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE = 2512, + EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE = 2513, + EVENT_SEEL_FAN_BOAST = 342, + EVENT_SILPH_CO_10_UNLOCKED_DOOR = 1912, + EVENT_SILPH_CO_11_UNLOCKED_DOOR = 1928, + EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = 1789, + EVENT_SILPH_CO_2_UNLOCKED_DOOR2 = 1790, + EVENT_SILPH_CO_3_UNLOCKED_DOOR1 = 1800, + EVENT_SILPH_CO_3_UNLOCKED_DOOR2 = 1801, + EVENT_SILPH_CO_4_UNLOCKED_DOOR1 = 1816, + EVENT_SILPH_CO_4_UNLOCKED_DOOR2 = 1817, + EVENT_SILPH_CO_5_UNLOCKED_DOOR1 = 1832, + EVENT_SILPH_CO_5_UNLOCKED_DOOR2 = 1833, + EVENT_SILPH_CO_5_UNLOCKED_DOOR3 = 1834, + EVENT_SILPH_CO_6_UNLOCKED_DOOR = 1855, + EVENT_SILPH_CO_7_UNLOCKED_DOOR1 = 1868, + EVENT_SILPH_CO_7_UNLOCKED_DOOR2 = 1869, + EVENT_SILPH_CO_7_UNLOCKED_DOOR3 = 1870, + EVENT_SILPH_CO_8_UNLOCKED_DOOR = 1880, + EVENT_SILPH_CO_9_UNLOCKED_DOOR1 = 1896, + EVENT_SILPH_CO_9_UNLOCKED_DOOR2 = 1897, + EVENT_SILPH_CO_9_UNLOCKED_DOOR3 = 1898, + EVENT_SILPH_CO_9_UNLOCKED_DOOR4 = 1899, + EVENT_SILPH_CO_RECEPTIONIST_AT_DESK = 919, + EVENT_SPAWNED_OLD_MAN_1 = 44, + EVENT_SS_ANNE_LEFT = 1506, + EVENT_STARTED_WALKING_OUT_OF_DOCK = 1508, + EVENT_USED_CELL_SEPARATOR_ON_BILL = 1371, + EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = 2327, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = 1336, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = 1343, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = 1632, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = 1638, + EVENT_VIRIDIAN_GYM_OPEN = 40, + EVENT_WALKED_OUT_OF_DOCK = 1509, + EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT = 1507, + }, + count = 2560, + source = "pokeyellow constants/event_constants.asm", +} diff --git a/src/save_convert/data/toggle_objects.lua b/src/save_convert/data/toggle_objects.lua new file mode 100644 index 00000000..639b44ba --- /dev/null +++ b/src/save_convert/data/toggle_objects.lua @@ -0,0 +1,243 @@ +-- wToggleableObjectFlags bit index -> { map id, object_event name, default +-- visible } for the Gen1 save codec (src/save_convert/GenSave.lua). +-- Derived entry by entry from ../pokered/data/maps/toggleable_objects.asm +-- (ToggleableObjectStates: three bytes per entry, blocks laid out in map-id +-- order, so an entry's position in the table IS its wToggleableObjectFlags +-- bit -- constants/toggle_constants.asm numbers the same list), with the +-- default taken from each row's ON/OFF state. Bit set = hidden +-- (engine/overworld/toggleable_objects.asm IsObjectHidden). Object names +-- match data/generated/maps.lua object_event names one for one; the two +-- placeholder bits with no object_event in this port stay as comments so +-- the numbering remains auditable (#763, #857). +return { + byBit = { + [0] = { "PALLET_TOWN", "PALLETTOWN_OAK", false }, + [1] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY", true }, + [2] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN", false }, + [3] = { "PEWTER_CITY", "PEWTERCITY_SUPER_NERD1", true }, + [4] = { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER", true }, + [5] = { "CERULEAN_CITY", "CERULEANCITY_RIVAL", false }, + [6] = { "CERULEAN_CITY", "CERULEANCITY_ROCKET", true }, + [7] = { "CERULEAN_CITY", "CERULEANCITY_GUARD1", false }, + [8] = { "CERULEAN_CITY", "CERULEANCITY_SUPER_NERD3", true }, + [9] = { "CERULEAN_CITY", "CERULEANCITY_GUARD2", true }, + [10] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET1", true }, + [11] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET2", true }, + [12] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET3", true }, + [13] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET4", true }, + [14] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET5", true }, + [15] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET6", true }, + [16] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET7", true }, + [17] = { "SAFFRON_CITY", "SAFFRONCITY_SCIENTIST", false }, + [18] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_M", false }, + [19] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_F", false }, + [20] = { "SAFFRON_CITY", "SAFFRONCITY_GENTLEMAN", false }, + [21] = { "SAFFRON_CITY", "SAFFRONCITY_PIDGEOT", false }, + [22] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKER", false }, + [23] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET8", true }, + [24] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET9", false }, + [25] = { "ROUTE_2", "ROUTE2_MOON_STONE", true }, + [26] = { "ROUTE_2", "ROUTE2_HP_UP", true }, + [27] = { "ROUTE_4", "ROUTE4_TM_WHIRLWIND", true }, + [28] = { "ROUTE_9", "ROUTE9_TM_TELEPORT", true }, + [29] = { "ROUTE_12", "ROUTE12_SNORLAX", true }, + [30] = { "ROUTE_12", "ROUTE12_TM_PAY_DAY", true }, + [31] = { "ROUTE_12", "ROUTE12_IRON", true }, + [32] = { "ROUTE_15", "ROUTE15_TM_RAGE", true }, + [33] = { "ROUTE_16", "ROUTE16_SNORLAX", true }, + [34] = { "ROUTE_22", "ROUTE22_RIVAL1", false }, + [35] = { "ROUTE_22", "ROUTE22_RIVAL2", false }, + [36] = { "ROUTE_24", "ROUTE24_COOLTRAINER_M1", true }, + [37] = { "ROUTE_24", "ROUTE24_TM_THUNDER_WAVE", true }, + [38] = { "ROUTE_25", "ROUTE25_TM_SEISMIC_TOSS", true }, + [39] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY1", true }, + [40] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY2", false }, + [41] = { "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP", true }, + [42] = { "OAKS_LAB", "OAKSLAB_RIVAL", true }, + [43] = { "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL", true }, + [44] = { "OAKS_LAB", "OAKSLAB_SQUIRTLE_POKE_BALL", true }, + [45] = { "OAKS_LAB", "OAKSLAB_BULBASAUR_POKE_BALL", true }, + [46] = { "OAKS_LAB", "OAKSLAB_OAK1", false }, + [47] = { "OAKS_LAB", "OAKSLAB_POKEDEX1", true }, + [48] = { "OAKS_LAB", "OAKSLAB_POKEDEX2", true }, + [49] = { "OAKS_LAB", "OAKSLAB_OAK2", false }, + [50] = { "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI", true }, + [51] = { "VIRIDIAN_GYM", "VIRIDIANGYM_REVIVE", true }, + [52] = { "MUSEUM_1F", "MUSEUM1F_OLD_AMBER", true }, + [53] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_FULL_RESTORE", true }, + [54] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_MAX_ELIXER", true }, + [55] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_NUGGET", true }, + [56] = { "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL", true }, + [57] = { "POKEMON_TOWER_3F", "POKEMONTOWER3F_ESCAPE_ROPE", true }, + [58] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_ELIXER", true }, + [59] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_AWAKENING", true }, + [60] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_HP_UP", true }, + [61] = { "POKEMON_TOWER_5F", "POKEMONTOWER5F_NUGGET", true }, + [62] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_RARE_CANDY", true }, + [63] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_X_ACCURACY", true }, + [64] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET1", true }, + [65] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET2", true }, + [66] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET3", true }, + [67] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI", true }, + [68] = { "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI", false }, + [69] = { "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL", true }, + [70] = { "GAME_CORNER", "GAMECORNER_ROCKET", true }, + [71] = { "WARDENS_HOUSE", "WARDENSHOUSE_RARE_CANDY", true }, + [72] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_ESCAPE_ROPE", true }, + [73] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_CARBOS", true }, + [74] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL", true }, + [75] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", true }, + [76] = { "SILPH_CO_1F", "SILPHCO1F_LINK_RECEPTIONIST", false }, + [77] = { "POWER_PLANT", "POWERPLANT_VOLTORB1", true }, + [78] = { "POWER_PLANT", "POWERPLANT_VOLTORB2", true }, + [79] = { "POWER_PLANT", "POWERPLANT_VOLTORB3", true }, + [80] = { "POWER_PLANT", "POWERPLANT_ELECTRODE1", true }, + [81] = { "POWER_PLANT", "POWERPLANT_VOLTORB4", true }, + [82] = { "POWER_PLANT", "POWERPLANT_VOLTORB5", true }, + [83] = { "POWER_PLANT", "POWERPLANT_ELECTRODE2", true }, + [84] = { "POWER_PLANT", "POWERPLANT_VOLTORB6", true }, + [85] = { "POWER_PLANT", "POWERPLANT_ZAPDOS", true }, + [86] = { "POWER_PLANT", "POWERPLANT_CARBOS", true }, + [87] = { "POWER_PLANT", "POWERPLANT_HP_UP", true }, + [88] = { "POWER_PLANT", "POWERPLANT_RARE_CANDY", true }, + [89] = { "POWER_PLANT", "POWERPLANT_TM_THUNDER", true }, + [90] = { "POWER_PLANT", "POWERPLANT_TM_REFLECT", true }, + [91] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_MOLTRES", true }, + [92] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_SUBMISSION", true }, + [93] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_FULL_HEAL", true }, + [94] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_MEGA_KICK", true }, + [95] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_GUARD_SPEC", true }, + [96] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3", true }, + [97] = { "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON", true }, + [98] = { "BILLS_HOUSE", "BILLSHOUSE_BILL1", false }, + [99] = { "BILLS_HOUSE", "BILLSHOUSE_BILL2", false }, + [100] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_ANTIDOTE", true }, + [101] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POTION", true }, + [102] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POKE_BALL", true }, + [103] = { "MT_MOON_1F", "MTMOON1F_POTION1", true }, + [104] = { "MT_MOON_1F", "MTMOON1F_MOON_STONE", true }, + [105] = { "MT_MOON_1F", "MTMOON1F_RARE_CANDY", true }, + [106] = { "MT_MOON_1F", "MTMOON1F_ESCAPE_ROPE", true }, + [107] = { "MT_MOON_1F", "MTMOON1F_POTION2", true }, + [108] = { "MT_MOON_1F", "MTMOON1F_TM_WATER_GUN", true }, + [109] = { "MT_MOON_B2F", "MTMOONB2F_DOME_FOSSIL", true }, + [110] = { "MT_MOON_B2F", "MTMOONB2F_HELIX_FOSSIL", true }, + [111] = { "MT_MOON_B2F", "MTMOONB2F_HP_UP", true }, + [112] = { "MT_MOON_B2F", "MTMOONB2F_TM_MEGA_PUNCH", true }, + [113] = { "SS_ANNE_2F", "SSANNE2F_RIVAL", false }, + [114] = { "SS_ANNE_1F_ROOMS", "SSANNE1FROOMS_TM_BODY_SLAM", true }, + [115] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_MAX_ETHER", true }, + [116] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_RARE_CANDY", true }, + [117] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_ETHER", true }, + [118] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_TM_REST", true }, + [119] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_MAX_POTION", true }, + [120] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_MAX_REVIVE", true }, + [121] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_TM_EXPLOSION", true }, + [122] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4", true }, + [123] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_ESCAPE_ROPE", true }, + [124] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_HYPER_POTION", true }, + [125] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_MOON_STONE", true }, + [126] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_NUGGET", true }, + [127] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_TM_HORN_DRILL", true }, + [128] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_SUPER_POTION", true }, + [129] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_TM_DOUBLE_EDGE", true }, + [130] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_RARE_CANDY", true }, + [131] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_GIOVANNI", true }, + [132] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_HP_UP", true }, + [133] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_TM_RAZOR_WIND", true }, + [134] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_IRON", true }, + [135] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_SILPH_SCOPE", false }, + [136] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY", false }, + [137] = { "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F", true }, + [138] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST1", true }, + [139] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST2", true }, + [140] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET1", true }, + [141] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET2", true }, + [142] = { "SILPH_CO_3F", "SILPHCO3F_ROCKET", true }, + [143] = { "SILPH_CO_3F", "SILPHCO3F_SCIENTIST", true }, + [144] = { "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION", true }, + [145] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET1", true }, + [146] = { "SILPH_CO_4F", "SILPHCO4F_SCIENTIST", true }, + [147] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET2", true }, + [148] = { "SILPH_CO_4F", "SILPHCO4F_FULL_HEAL", true }, + [149] = { "SILPH_CO_4F", "SILPHCO4F_MAX_REVIVE", true }, + [150] = { "SILPH_CO_4F", "SILPHCO4F_ESCAPE_ROPE", true }, + [151] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET1", true }, + [152] = { "SILPH_CO_5F", "SILPHCO5F_SCIENTIST", true }, + [153] = { "SILPH_CO_5F", "SILPHCO5F_ROCKER", true }, + [154] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET2", true }, + [155] = { "SILPH_CO_5F", "SILPHCO5F_TM_TAKE_DOWN", true }, + [156] = { "SILPH_CO_5F", "SILPHCO5F_PROTEIN", true }, + [157] = { "SILPH_CO_5F", "SILPHCO5F_CARD_KEY", true }, + [158] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET1", true }, + [159] = { "SILPH_CO_6F", "SILPHCO6F_SCIENTIST", true }, + [160] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET2", true }, + [161] = { "SILPH_CO_6F", "SILPHCO6F_HP_UP", true }, + [162] = { "SILPH_CO_6F", "SILPHCO6F_X_ACCURACY", true }, + [163] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET1", true }, + [164] = { "SILPH_CO_7F", "SILPHCO7F_SCIENTIST", true }, + [165] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET2", true }, + [166] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET3", true }, + [167] = { "SILPH_CO_7F", "SILPHCO7F_RIVAL", true }, + [168] = { "SILPH_CO_7F", "SILPHCO7F_CALCIUM", true }, + [169] = { "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE", true }, + -- [170] SILPH_CO_7F (SILPHCO7F_UNUSED): placeholder entry, no object_event in this port + [171] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET1", true }, + [172] = { "SILPH_CO_8F", "SILPHCO8F_SCIENTIST", true }, + [173] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET2", true }, + [174] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET1", true }, + [175] = { "SILPH_CO_9F", "SILPHCO9F_SCIENTIST", true }, + [176] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET2", true }, + [177] = { "SILPH_CO_10F", "SILPHCO10F_ROCKET", true }, + [178] = { "SILPH_CO_10F", "SILPHCO10F_SCIENTIST", true }, + [179] = { "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F", true }, + [180] = { "SILPH_CO_10F", "SILPHCO10F_TM_EARTHQUAKE", true }, + [181] = { "SILPH_CO_10F", "SILPHCO10F_RARE_CANDY", true }, + [182] = { "SILPH_CO_10F", "SILPHCO10F_CARBOS", true }, + [183] = { "SILPH_CO_11F", "SILPHCO11F_GIOVANNI", true }, + [184] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET1", true }, + [185] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET2", true }, + -- [186] UNUSED_MAP_F4 ($02): placeholder entry, no object_event in this port + [187] = { "POKEMON_MANSION_2F", "POKEMONMANSION2F_CALCIUM", true }, + [188] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_MAX_POTION", true }, + [189] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_IRON", true }, + [190] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_RARE_CANDY", true }, + [191] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_FULL_RESTORE", true }, + [192] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_BLIZZARD", true }, + [193] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_SOLARBEAM", true }, + [194] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_SECRET_KEY", true }, + [195] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_FULL_RESTORE", true }, + [196] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_MAX_RESTORE", true }, + [197] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_CARBOS", true }, + [198] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_TM_EGG_BOMB", true }, + [199] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_PROTEIN", true }, + [200] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_TM_SKULL_BASH", true }, + [201] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_POTION", true }, + [202] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_TM_DOUBLE_TEAM", true }, + [203] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_REVIVE", true }, + [204] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_GOLD_TEETH", true }, + [205] = { "SAFARI_ZONE_CENTER", "SAFARIZONECENTER_NUGGET", true }, + [206] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_PP_UP", true }, + [207] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_ULTRA_BALL", true }, + [208] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_FULL_RESTORE", true }, + [209] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MEWTWO", true }, + [210] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_ULTRA_BALL", true }, + [211] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MAX_REVIVE", true }, + [212] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_TM_SKY_ATTACK", true }, + [213] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_RARE_CANDY", true }, + [214] = { "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK", false }, + [215] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1", true }, + [216] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2", true }, + [217] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER1", false }, + [218] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER2", false }, + [219] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER1", false }, + [220] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER2", false }, + [221] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER2", true }, + [222] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER3", true }, + [223] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER5", false }, + [224] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER6", false }, + [225] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1", false }, + [226] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2", false }, + [227] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_ARTICUNO", true }, + }, +} diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 9f293f42..f7d3ffe5 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -68,7 +68,14 @@ function TitleState:sgbPalettes(game) local top = game.stack and game.stack:top() local box = top and top.titleUiBox if box then - z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4]) + -- A DMG-grays zone, not the trueColor opt-out: through the shade-remap + -- shader GRAYS is the identity for the box's four shades, so SGB / + -- ADVANCED / OG modes keep #133's white paper and black ink exactly, + -- while effectiveColors still substitutes the mono and inverted display + -- modes -- a trueColor rect skipped the shader entirely, leaving the + -- main menu and CONTINUE info box a raw white hole over a CLASSIC + -- pea-green title instead of matching it like the START menu does (#870). + z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4]) end return z[3] and z or nil end diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 24ed03a7..32388b82 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -234,6 +234,11 @@ local function launchDownload(url, partAbs, doneAbs) local batRel = "updates/dl.bat" love.filesystem.write(batRel, "@echo off\r\n" + -- start /b hands the child our cwd, the install folder, and the + -- detached cmd.exe held that folder un-movable for the rest of the + -- transfer after the game exited (#727). Every path below is + -- absolute, so park the child in its own directory (the save dir). + .. "cd /d \"%~dp0\"\r\n" .. "curl -fsSL --connect-timeout 15 --max-time 900 -o \"" .. partAbs .. "\" \"" .. url .. "\"\r\n" .. "type nul > \"" .. doneAbs .. "\"\r\n") @@ -271,6 +276,14 @@ local function doDownload() -- stalled or run-away transfer breaks out and lets verification fail cleanly local waited, lastSize, lastChange = 0, -1, 0 while true do + -- A queued quit means the window already closed. Bail so the join in + -- Check.shutdown does not hold the dead window's process (and, on + -- Windows, its folder) open for up to the whole transfer (#727). The + -- quit stays on the channel for the command loop; the detached curl + -- times out on its own and the next launch's doCheck verifies and + -- re-offers whatever landed. + local peeked = cmdCh:peek() + if type(peeked) == "table" and peeked.cmd == "quit" then return end if love.filesystem.getInfo(doneRel) then break end local pinfo = love.filesystem.getInfo(partRel) local cur = (pinfo and pinfo.size) or 0 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ff56ae22..d6fcdb56 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -440,8 +440,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.entities = { self.player } for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end -- Yellow's companion Pikachu trails the player (never in - -- self.entities: it does not block movement, pikachu_follow.asm) - require("src.world.PikachuFollower").onMapEntered(Game, self, opts) + -- self.entities: it does not block movement, pikachu_follow.asm). + -- true = fresh map entry: the follower spawns under the player and + -- walks out of the warp, not beside him (#863) + require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true) -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7); @@ -1925,7 +1927,14 @@ function OverworldState:tryHiddenObject(fx, fy) save.hiddenTaken = save.hiddenTaken or {} if save.hiddenTaken[key] then return false end if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then - Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!"))) + -- hidden_items.asm FoundHiddenItemText: the find is announced first, + -- then GiveItem's .bagFull branch prints _HiddenItemBagFullText and + -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) + local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item + Game.stack:push(TextBox.new(Game, + Strings("%s found\n%s!", save.player.name, name) .. "\f" + .. romText(Game.data, "_HiddenItemBagFullText", + "But, {PLAYER} has\nno more room for\vother items!"))) return true end save.hiddenTaken[key] = true @@ -2570,7 +2579,17 @@ function OverworldState:talkTo(npc) -- 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, Game.data) then - Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!"))) + -- pick_up_item.asm .BagFull prints _NoMoreRoomForItemText, not the + -- Toss-screen _CantCarryMoreText; Yellow announces the find first, + -- then the refusal (#872) + local noRoom = romText(Game.data, "_NoMoreRoomForItemText", + "No more room for\nitems!") + if GameVersion.isYellow() then + local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item + noRoom = Strings("%s found\n%s!", Game.save.player.name, name) + .. "\f" .. noRoom + end + Game.stack:push(TextBox.new(Game, noRoom)) return end Game.save.itemsTaken = Game.save.itemsTaken or {} @@ -2984,7 +3003,12 @@ local function meetTrainerTheme(cls) end -- Run the pre-battle text -> battle -> won text -> flags sequence. -function OverworldState:engageTrainer(npc, onDone, endBattleText) +-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript +-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE +-- the approach walk and then EngageMapTrainer with no further text: the +-- caller already showed the box, so the battle starts without a second +-- one (#869). +function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -3005,7 +3029,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText) or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") - Game.stack:push(TextBox.new(Game, battleText, function() + local function startBattle() -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text -- FIRST and only then runs `call EngageMapTrainer` / `jp -- StartTrainerBattle`, so a trainer challenged on foot gets the sting @@ -3048,7 +3072,12 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText) end end self:pushBattle(battle) - end)) + end + if skipBattleText then + startBattle() + else + Game.stack:push(TextBox.new(Game, battleText, startBattle)) + end end -- Shared GiveItem step for the victory rewards (pokered home/give.asm): @@ -4470,7 +4499,13 @@ function OverworldState:drawWorld() -- ghost NPCs on neighbor maps, y-sorted among themselves table.sort(self.ghosts, function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end) - table.sort(self.entities, function(a, b) return a.py < b.py end) + table.sort(self.entities, function(a, b) + if a.py ~= b.py then return a.py < b.py end + -- a fresh warp spawn parks the follower on the player's own cell + -- until it trails out; the tie must draw it under him, never on + -- top (#863) + return a.pikachuFollower == true and b.pikachuFollower ~= true + end) -- === shared FX draw bodies ========================================== -- Each draws at flat world-canvas offsets; the tilt path wraps the diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b..b7c517f7 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -188,7 +188,7 @@ function PikachuFollower.current(ow) return npc end -function PikachuFollower.onMapEntered(game, ow, opts) +function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad) -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil @@ -200,7 +200,8 @@ function PikachuFollower.onMapEntered(game, ow, opts) -- takes .normal_spawn_state -- map coords rebased, sprite data and -- follow command buffer left alone. Re-list the same instance and let -- rebase() shift its cell; a warp arrives without it and respawns - -- behind the player, the full spawn path of that same routine. + -- under the player, the full spawn path of that same routine (the + -- viaMapLoad spawn below, #863). local keep = opts and opts.keepPikachu if keep then table.insert(ow.npcs, keep) @@ -208,6 +209,12 @@ function PikachuFollower.onMapEntered(game, ow, opts) return end local x, y = spawnCell(ow) + -- a fresh map entry (warp, boot) parks the follower ON the player's + -- cell instead: it stays hidden under him (the draw-sort tie-break in + -- OverworldController) and walks out of the warp behind him as the + -- trail opens up. Mid-map respawns (bike dismount, revive) keep the + -- behind-the-facing cell (#863) + if viaMapLoad then x, y = ow.player.cellX, ow.player.cellY end local npc = makeFollower(game, ow, x, y, ow.player.facing) table.insert(ow.npcs, npc) -- entities is the draw list; passable keeps it out of collision diff --git a/tests/drivers/bag_full_pickup_bug872_test.lua b/tests/drivers/bag_full_pickup_bug872_test.lua new file mode 100644 index 00000000..2d04abbd --- /dev/null +++ b/tests/drivers/bag_full_pickup_bug872_test.lua @@ -0,0 +1,222 @@ +-- Manual check of both bag-full pickup refusals (#872): an item ball must +-- refuse with _NoMoreRoomForItemText (pokered scripts/pick_up_item.asm +-- .BagFull), never the Toss-screen _CantCarryMoreText, and a hidden item +-- announces the find first, then _HiddenItemBagFullText (hidden_items.asm). +-- Run without POKEPORT_SPEED -- the box paging under test is timing-honest. +-- POKEPORT_DRIVER=tests/drivers/bag_full_pickup_bug872_test.lua POKEPORT_IDENTITY=bug872 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Bag = require("src.inventory.Bag") + local GameVersion = require("src.core.GameVersion") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local MAP = "VIRIDIAN_FOREST" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- neither refusal plays a jingle, but the human will want the normal + -- pickup sound as a contrast, so warn when it would be muted + local opts = game.save.options or {} + if (opts.sfxVol or 7) == 0 then + U.log("note: sfxVol is 0, so a successful pickup afterwards will be", + "silent; the refusal boxes themselves are unaffected") + end + + -- a real fresh game, so the save has a player name and clean flags + U.newGame(game) + local save = game.save + + -- Empty the bag, then refill to exactly capacity with ids that are NOT + -- POTION: Bag.add succeeds by quantity for an id already in a slot + -- (src/inventory/Bag.lua), which would mask the bug, and both test + -- targets below hand out POTION. Badges share save.inventory but are + -- not bag slots, so they are left alone. + for _, id in ipairs({ unpack(Bag.order(save)) }) do + Bag.remove(save, id, save.inventory[id] or 1) + end + local ids = {} + for id in pairs(game.data.items) do + if not Bag.isBadge(id) and id ~= "POTION" then ids[#ids + 1] = id end + end + table.sort(ids) + for _, id in ipairs(ids) do + if Bag.slots(save) >= Bag.capacity(game.data) then break end + Bag.add(save, id, 1, game.data) + end + check(("bag is full (%d/%d slots) and holds no POTION") + :format(Bag.slots(save), Bag.capacity(game.data)), + Bag.slots(save) >= Bag.capacity(game.data) + and not save.inventory.POTION) + + -- first target: the Potion item ball. pokered + -- data/maps/objects/ViridianForest.asm:36 puts it at walk cell (12, 29) + -- (object_event 12, 29, SPRITE_POKE_BALL ... POTION), free floor below. + local BALL = { x = 12, y = 29 } + U.teleport(game, MAP, BALL.x, BALL.y + 1, "left") + U.wait(10) + + local function isBall(n) + return n and n.def and n.def.item and n.def.item ~= "0" and n.def.item ~= 0 + end + local ow = game.overworld + local ball = ow:npcAtCell(BALL.x, BALL.y) + if not isBall(ball) then + -- a map edit or mod moved the object: take any item ball on the map + -- and stand on a free walkable neighbour instead + ball = nil + for _, n in ipairs(ow.npcs or {}) do + if isBall(n) then ball = n break end + end + if ball then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = ball.cellX + s[1], ball.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("ball not at (%d, %d); using the one at (%d, %d)") + :format(BALL.x, BALL.y, ball.cellX, ball.cellY)) + -- teleport facing away, the tap below still does the turn + U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left") + U.wait(10) + BALL.x, BALL.y = ball.cellX, ball.cellY + break + end + end + end + end + check("an item ball is loaded on " .. MAP, ball ~= nil) + local ballItem = ball and ball.def.item or "POTION" + + -- turn toward the ball ourselves (a one-frame press only turns when the + -- player faces elsewhere, src/world/Player.lua), then trigger the talk + ow = game.overworld + local dx, dy = BALL.x - ow.player.cellX, BALL.y - ow.player.cellY + local dir = (dy < 0 and "up") or (dy > 0 and "down") + or (dx < 0 and "left") or "right" + U.tap(game, dir) + U.wait(10) + local fx, fy = game.overworld.player:facingCell() + check("player turned to face the ball", + game.overworld:npcAtCell(fx, fy) == ball) + U.tap(game, "a") + U.wait(30) + + local function readPages() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local pages = {} + for _, page in ipairs(top.pages or {}) do + pages[#pages + 1] = table.concat(page, " / ") + end + return pages + end + local function closeBox() + for _ = 1, 8 do + if getmetatable(game.stack:top()) ~= TextBox then break end + U.tap(game, "a") + U.wait(25) + end + end + + local pages = readPages() + check("A on the full-bag ball opened a text box", pages ~= nil) + if pages then + local all = table.concat(pages, " || ") + U.log("ball refusal reads:", all) + check("it is the pickup refusal, not the Toss line", + all:find("No more room", 1, true) ~= nil + and all:find("can't carry", 1, true) == nil) + if GameVersion.isYellow() then + check("Yellow announces the find, then refuses on page 2", + #pages == 2 + and pages[1]:find(ballItem, 1, true) ~= nil + and pages[2]:find("No more room", 1, true) ~= nil) + else + check("Red/Blue refuse in one page with no found line", + #pages == 1 and pages[1]:find("found", 1, true) == nil) + end + U.shot(game, SHOT_DIR .. "/bug872_ball_refusal.png") + end + closeBox() + + -- the refusal must leave the world untouched so the pickup can be + -- retried after tossing something + check("the ball is still standing there", + game.overworld:npcAtCell(BALL.x, BALL.y) == ball) + check("itemsTaken was not marked", + not (save.itemsTaken and ball and save.itemsTaken[ball.id])) + check("the item stayed out of the bag", not save.inventory[ballItem]) + + -- second target: the hidden POTION. pokered + -- data/events/hidden_item_coords.asm:8 puts it at (x=1, y=18) on + -- VIRIDIAN_FOREST; Game.data.field.hiddenItems carries the same spot. + local hidden + local list = (game.data.field.hiddenItems or {})[MAP] or {} + for _, h in ipairs(list) do + if h.x == 1 and h.y == 18 then hidden = h break end + end + hidden = hidden or list[1] + check("a hidden item exists on " .. MAP, hidden ~= nil) + + local stood = false + if hidden then + -- {dx, dy, facing} from the hidden cell to a stand cell that looks + -- back at it; the spot itself is usually an unwalkable tree tile + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = hidden.x + s[1], hidden.y + s[2] + local m = game.overworld.map + if m:isWalkableCell(cx, cy) and not game.overworld:npcAtCell(cx, cy) then + U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left") + U.wait(10) + U.tap(game, s[3]) + U.wait(10) + local hfx, hfy = game.overworld.player:facingCell() + if hfx == hidden.x and hfy == hidden.y then stood = true break end + end + end + end + check("standing against the hidden spot", stood) + + U.tap(game, "a") + U.wait(30) + pages = readPages() + check("A on the full-bag hidden spot opened a text box", pages ~= nil) + if pages then + local all = table.concat(pages, " || ") + U.log("hidden refusal reads:", all) + check("the found line comes first", + #pages == 2 and pages[1]:find("found", 1, true) ~= nil + and (not hidden or pages[1]:find( + (game.data.items[hidden.item] or {}).name or hidden.item, + 1, true) ~= nil)) + check("then the hidden-item bag-full line, not the Toss line", + #pages == 2 + and pages[2]:find("no more room", 1, true) ~= nil + and pages[2]:find("other items", 1, true) ~= nil + and all:find("can't carry", 1, true) == nil) + U.shot(game, SHOT_DIR .. "/bug872_hidden_refusal.png") + end + closeBox() + + local key = hidden and (MAP .. "_" .. hidden.x .. "_" .. hidden.y) + check("the hidden spot can still be prompted again", + not (key and save.hiddenTaken and save.hiddenTaken[key])) + check("the hidden item stayed out of the bag", + not (hidden and save.inventory[hidden.item])) + + U.log("You are still facing the hidden spot with a full bag; pressing A") + U.log("should say the item was found, then that there is no more room for") + U.log("other items, and never the bag screen's \"can't carry\" wording.") + U.log("Toss something and both spots should hand their item over normally.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/faithful_res_mobile_veil_bug864_test.lua b/tests/drivers/faithful_res_mobile_veil_bug864_test.lua new file mode 100644 index 00000000..d176de08 --- /dev/null +++ b/tests/drivers/faithful_res_mobile_veil_bug864_test.lua @@ -0,0 +1,296 @@ +-- Eye check: FAITHFUL RATIO's mobile scale lock keeps the display outside the +-- locked 160x144 viewport black through the pre-battle flash (pokered +-- BattleTransition_FlashScreen_, engine/battle/battle_transitions.asm), the +-- post-battle fade (GBFadeInFromWhite, home/fade.asm) and Oak speech (#864). +-- POKEPORT_DRIVER=tests/drivers/faithful_res_mobile_veil_bug864_test.lua POKEPORT_FORCE_MOBILE=1 POKEPORT_IDENTITY=bug864 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED anywhere: the flash and the fade ARE the frames under test. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Renderer = require("src.render.Renderer") + local FaithfulRes = require("src.core.FaithfulRes") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- FaithfulRes.isMobile reads the env per call, so the desktop build only + -- takes the scale-cap branch when the launcher command set it. Without it + -- every check below tests the ordinary letterbox and proves nothing. + if not check("POKEPORT_FORCE_MOBILE=1 is set (the branch under test)", + os.getenv("POKEPORT_FORCE_MOBILE") == "1") then + U.log("Re-run with POKEPORT_FORCE_MOBILE=1; nothing below is meaningful.") + while true do coroutine.yield() end + end + + -- A phone-shaped window, so the locked viewport (160x144 at the largest + -- whole multiple, here 3x = 480x432) leaves tall bars above and below -- + -- the "dead display" FaithfulRes.lua's contract says must stay black. + -- 480x960 keeps the width an exact 3x so the bars are purely vertical. + if love.window and love.window.setMode then + love.window.setMode(480, 960, { resizable = true, + minwidth = FaithfulRes.MIN_W, + minheight = FaithfulRes.MIN_H }) + end + U.wait(3) + + -- New Game replaces game.save (and Game:applyOptions re-applies its fresh + -- options, which releases the lock), so re-arm before every shot rather + -- than trusting one application to survive the whole run. + local function lock() + game.save.options = game.save.options or {} + game.save.options.faithfulRes = 1 + game.save.options.battleBg = "black" + FaithfulRes.applyOptions(game.save.options) + return FaithfulRes.scaleCap() + end + check("the mobile scale lock engaged (FaithfulRes.scaleCap ~= nil)", + lock() ~= nil) + + local opts = game.save.options + if (opts.musicVol or 0) == 0 or (opts.sfxVol or 0) == 0 then + U.log("WARN music/sfx volume is zero; the flash's battle theme will be silent") + end + + -- Load a captured PNG back as ImageData; love.image cannot read absolute + -- paths, so go through io.open + newFileData. + local function loadShot(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + return ok and img or nil + end + + local function regionMean(img, x, y, w, h) + local sum, n = 0, 0 + local x2 = math.min(x + w, img:getWidth()) - 1 + local y2 = math.min(y + h, img:getHeight()) - 1 + for yy = math.max(y, 0), y2 do + for xx = math.max(x, 0), x2 do + local r, g, b = img:getPixel(xx, yy) + sum = sum + (r + g + b) / 3 + n = n + 1 + end + end + return n > 0 and sum / n or 0, n + end + + -- The locked viewport in framebuffer pixels: the same uiSize * fitScale + -- centring endFrame uses for ox/oy/vpw/vph, which is the rectangle the + -- veil is clamped to under the lock. Screenshots are framebuffer-sized, + -- so no dpi divide. + local function viewBox(img) + local pw, ph = img:getWidth(), img:getHeight() + local uiw, uih = Renderer:uiSize() + local S = Renderer:fitScale() + local bw, bh = uiw * S, uih * S + return math.floor((pw - bw) / 2), math.floor((ph - bh) / 2), bw, bh, pw, ph + end + + -- Mean over every bar strip the window has (top/bottom always here, + -- left/right only if the width is not an exact multiple). Inset by 2px so + -- the viewport's own edge pixels cannot bleed into the bar sample. + local function barMean(img) + local bx, by, bw, bh, pw, ph = viewBox(img) + local sum, n = 0, 0 + local function add(x, y, w, h) + if w < 1 or h < 1 then return end + local m, c = regionMean(img, x, y, w, h) + sum, n = sum + m * c, n + c + end + if by >= 8 then + add(0, 0, pw, by - 2) + add(0, by + bh + 2, pw, ph - (by + bh) - 2) + end + if bx >= 8 then + add(0, by, bx - 2, bh) + add(bx + bw + 2, by, bx - 2, bh) + end + return n > 0 and sum / n or -1, n + end + + local function innerMean(img) + local bx, by, bw, bh = viewBox(img) + return regionMean(img, bx + math.floor(bw / 4), by + math.floor(bh / 4), + math.floor(bw / 2), math.floor(bh / 2)) + end + + -- shot + the two-sided assertion every moment shares: bars dead black, + -- viewport interior at least `bright` (the effect visibly inside the frame) + local function shotAndCheck(name, bright) + check("lock still held at " .. name, lock() ~= nil) + local path = DIR .. "/bug864_" .. name .. ".png" + U.shot(game, path) + local img = loadShot(path) + if not check(name .. " shot decoded", img ~= nil) then return nil end + local bars, n = barMean(img) + local inner = innerMean(img) + U.log((" %s: bar mean %.3f over %d px, viewport interior %.3f") + :format(name, bars, n, inner)) + check(name .. ": window has bars to sample", n > 0) + check(name .. ": bars stay dead black (#864)", n > 0 and bars < 0.05) + check(name .. ": the effect still lights the viewport", inner > bright) + return img + end + + -- ---- (3rd symptom first: it is where a boot starts) New Game ----------- + -- OakSpeech sets letterboxWhite; before #864 that painted the WHOLE phone + -- paper white, leaving the locked frame indistinguishable from its bars. + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(10) + U.tap(game, "a") -- title -> menu + U.wait(5) + U.tap(game, "a") -- NEW GAME (POKEPORT_IDENTITY=bug864 has no save) + local oak + for _ = 1, 300 do + for i = #game.stack.states, 1, -1 do + local s = game.stack.states[i] + if s and s.letterboxWhite then oak = s break end + end + if oak then break end + U.tap(game, "a") + U.wait(2) + end + check("Oak speech reached (a letterboxWhite state is on the stack)", + oak ~= nil) + U.wait(40) -- let Oak's pic and a line of text land inside the frame + shotAndCheck("oakspeech", 0.5) + + -- mash through the rest of the speech into the overworld; the naming + -- screens and the closing shrink-away beat (~103 unskippable frames) eat + -- most of this, so the headroom is generous on purpose + for _ = 1, 900 do + U.tap(game, "a") + U.wait(2) + if game.overworld and game.stack:top() == game.overworld then break end + end + check("New Game landed in the overworld", + game.overworld ~= nil and game.stack:top() == game.overworld) + + -- ---- the pre-battle flash ---------------------------------------------- + -- pokered data/maps/objects/Route1.asm puts its youngsters at (5,24) and + -- (15,13) and the sign at (9,27), so the top of the road is empty; the + -- battle is pushed straight in, the cell is only somewhere to stand. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 6, facing = "down" } + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12) } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the road: any free neighbour serves, the cell is not + -- itself under test + for _, d in ipairs({ {0,1}, {0,-1}, {1,0}, {-1,0} }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("stand cell (%d, %d) blocked, using (%d, %d)") + :format(STAND.x, STAND.y, cx, cy)) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(10) + ow = game.overworld + break + end + end + end + + U.shot(game, DIR .. "/bug864_route1_base.png") -- unlit reference frame + lock() + + -- wild, weaker (L5 vs the L12 lead), not a dungeon map: the 3-bit select + -- (battle_transitions.asm) lands on %000 doublecircle, one of the two + -- wipes that call BattleTransition_FlashScreen first + local battle = BattleState.newWild(game, "RATTATA", 5) + ow:pushBattle(battle) + local trans = game.stack:top() + check("the transition is a flashing wipe (doublecircle)", + trans ~= nil and trans.def ~= nil and trans.def.flash == true) + + -- screenVeil is written during draw and cleared at beginFrame, so at + -- update time it holds the LAST rendered frame's veil; catch the white + -- peak (shade 1, near-full alpha) and shoot the very next frames while + -- the 2-frame palette holds keep it bright + local caught = false + for _ = 1, 400 do + local v = game.renderer and game.renderer.screenVeil + if v and v[1] == 1 and v[2] >= 0.9 then caught = true break end + U.wait(1) + end + check("caught the flash at its white peak", caught) + local flashImg = shotAndCheck("flash", 0.5) + local baseImg = loadShot(DIR .. "/bug864_route1_base.png") + if flashImg and baseImg then + local lit, plain = innerMean(flashImg), innerMean(baseImg) + U.log((" viewport interior %.3f unlit -> %.3f mid-flash") + :format(plain, lit)) + check("the flash visibly brightens the viewport over the base frame", + lit > plain + 0.15) + end + + -- ---- the post-battle fade in from white -------------------------------- + for _ = 1, 600 do + if game.stack:top() == battle then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", + battle.phase == "menu") + + -- run away (down+right lands on RUN from anywhere in the 2x2 grid; the + -- L12 lead outspeeds the L5 wild mon, so the escape always succeeds), + -- then watch for BattleReturn's white veil -- battle over, shade 1, + -- alpha 1 through its hold frames + local sawReturn = false + for _ = 1, 1500 do + local top = game.stack:top() + local v = game.renderer and game.renderer.screenVeil + if top ~= battle and v and v[1] == 1 and v[2] >= 0.9 then + sawReturn = true + break + end + if top == battle then + if battle.phase == "menu" then + U.tap(game, "down") + U.wait(1) + U.tap(game, "right") + U.wait(1) + U.tap(game, "a") + U.wait(2) + else + U.tap(game, "a") + U.wait(3) + end + else + U.wait(1) + end + end + check("caught the post-battle fade in from white", sawReturn) + shotAndCheck("return", 0.8) + + -- ---- over to you -------------------------------------------------------- + U.log("You are back on Route 1 with the picture locked to a 480x432 frame in") + U.log("the middle of a tall window. Open the three shots in " .. DIR .. ":") + U.log("bug864_oakspeech, bug864_flash and bug864_return should each show a lit") + U.log("frame -- paper, white flash, white fade -- with dead-black bars above") + U.log("and below. Before #864 the white spilled over the whole window and the") + U.log("frame had no edge at all. Walking into grass here replays the flash live.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/giovanni_silph11f_bug869_test.lua b/tests/drivers/giovanni_silph11f_bug869_test.lua new file mode 100644 index 00000000..16d70fcd --- /dev/null +++ b/tests/drivers/giovanni_silph11f_bug869_test.lua @@ -0,0 +1,224 @@ +-- Giovanni's Silph Co 11F coordinate trigger speaks BEFORE he walks (#869). +-- pokered scripts/SilphCo11F.asm SilphCo11FDefaultScript: DisplayTextID +-- TEXT_SILPHCO11F_GIOVANNI first, then MoveSprite .GiovanniMovement (3x down) +-- and EngageMapTrainer with no second box. Do not set POKEPORT_SPEED: the +-- box-vs-walk ordering is exactly the moment under test. +-- POKEPORT_DRIVER=tests/drivers/giovanni_silph11f_bug869_test.lua POKEPORT_IDENTITY=bug869 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleTransition = require("src.render.BattleTransition") + local BattleState = require("src.battle.BattleState") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Positions from ../pokered/data/maps/objects/SilphCo11F.asm: Giovanni + -- object_event (6, 9), SILPHCO11F_ROCKET1 (3, 16). Trigger tiles from + -- ../pokered/scripts/SilphCo11F.asm .PlayerCoordsArray: (6, 13) and + -- (7, 12). data/generated/maps.lua stores the same cells 1:1. + local MAP = "SILPH_CO_11F" + local GIO_HOME = { x = 6, y = 9 } + local GIO_STOP = { x = 6, y = 12 } -- home + 3x NPC_MOVEMENT_DOWN + + local failed = 0 + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = failed + 1 end + return ok + end + + -- party strong enough that the human can win the OPP_GIOVANNI#2 fight + -- and watch the unchanged aftermath (victories.lua "Arrgh!!", then the + -- "Blast it all!" speech and the rockets leaving) + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 80), + Pokemon.new(game.data, "SNORLAX", 77), + Pokemon.new(game.data, "CHARIZARD", 70), + } + game.save.player.name = "RED" + + -- (6,13) and (7,13) sit in the card-key doorway of the boss room: block + -- (3,6) stays the closed id 32 until EVENT_SILPH_CO_11_UNLOCKED_DOOR is + -- set (stampClosedDoors, mirroring pokered engine/events/card_key.asm), + -- and a closed door refuses the step this test needs. A real player has + -- opened it before the trigger can fire, so open it here too. + game.save.flags.EVENT_SILPH_CO_11_UNLOCKED_DOOR = true + + check("EVENT_BEAT_SILPH_CO_GIOVANNI starts unset -- trigger is armed", + not game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI) + local opts = game.save.options or {} + if (opts.sfxVol or 0) == 0 then + U.log("sfxVol is 0 -- the evil-trainer sting will be inaudible") + end + if (opts.musicVol or 0) == 0 then + U.log("musicVol is 0 -- the encounter sting and battle theme are muted") + end + + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local function topBox() + local t = game.stack:top() + if getmetatable(t) == TextBox then return t end + return nil + end + + local function boxText(box) + local shown = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return table.concat(shown, " / ") + end + + -- Stand next to (tx, ty) and take one real walking step onto it; onStep + -- hooks fire on a finished step, so a bare teleport onto the tile would + -- prove nothing. `sides` are {dx, dy, facing} in preference order, each + -- checked for walkability so a map edit only degrades to the next side. + local function stepOnto(tx, ty, sides) + U.teleport(game, MAP, tx + sides[1][1], ty + sides[1][2], sides[1][3]) + U.wait(10) + local ow = game.overworld + for _, s in ipairs(sides) do + local cx, cy = tx + s[1], ty + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + if ow.player.cellX ~= cx or ow.player.cellY ~= cy then + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + end + U.hold(game, s[3], 24) + U.wait(10) + return true + end + end + return false + end + + -- Regression on the shared engageTrainer path first: an ordinary trainer + -- with no skipBattleText must still get its normal pre-battle box. + -- ROCKET1 faces up, so talk to him from above. + check("regression: reached ROCKET1's cell", + stepOnto(3, 15, { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } })) + do + local ow = game.overworld + local rocket = findNpc(ow, "SILPHCO11F_ROCKET1") + check("regression: ROCKET1 object loaded", rocket ~= nil) + if rocket then + -- walk down one so we face him from (3, 15) + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) ~= rocket then + -- stepOnto left us adjacent to (3, 15); face the rocket directly + local dx = rocket.cellX - ow.player.cellX + local dy = rocket.cellY - ow.player.cellY + local face = (dy > 0 and "down") or (dy < 0 and "up") + or (dx > 0 and "right") or "left" + U.tap(game, face) + U.wait(10) + end + U.tap(game, "a") + U.wait(30) + local box = topBox() + check("regression: talking to ROCKET1 still opens the pre-battle box", + box ~= nil) + if box then + local t = boxText(box) + U.log("rocket box reads:", t) + check("regression: it is his battle line (\"Stop right there!\")", + t:find("Stop right there", 1, true) ~= nil) + end + end + end + + -- Both trigger tiles must fire with Giovanni still at his desk. The + -- (7, 12) probe is abandoned before the box is dismissed (the teleport + -- rebuilds the map state), so the trigger re-arms for the main run -- + -- vanilla re-arms too, since only a win sets the event flag. + do + check("(7,12) approach: stepped onto the trigger from the right", + stepOnto(7, 12, { { 1, 0, "left" }, { 0, 1, "up" }, + { -1, 0, "right" } })) + local box = topBox() + check("(7,12) approach: intro box opened", box ~= nil) + local gio = findNpc(game.overworld, "SILPHCO11F_GIOVANNI") + check("(7,12) approach: Giovanni is still at his desk (6,9)", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + end + + -- Main run, the route the issue screenshots show: (6,15) facing up, two + -- steps onto (6,13). + U.teleport(game, MAP, 6, 15, "up") + U.wait(10) + U.hold(game, "up", 24) + U.hold(game, "up", 24) + U.wait(15) + if not topBox() then + -- blocked approach fallback: one step onto (6,13) from any free side + stepOnto(6, 13, { { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" } }) + end + + local ow = game.overworld + local gio = findNpc(ow, "SILPHCO11F_GIOVANNI") + check("Giovanni object loaded on " .. MAP, gio ~= nil) + local box = topBox() + check("stepping onto (6,13) opened a text box", box ~= nil) + if box then + local t = boxText(box) + U.log("intro box reads:", t) + check("it is the Giovanni intro (\"So we meet again!\")", + t:find("So we meet again", 1, true) ~= nil) + end + check("the box opened with Giovanni STILL at his desk (6,9) -- the fix", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + U.shot(game, SHOT_DIR .. "/bug869_box_before_walk.png") + U.wait(30) + check("he holds the desk for the whole box, not just its first frame", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + + -- dismiss every page; the walk and the battle must follow with NO + -- further dialogue box in between (EngageMapTrainer runs bare in the + -- original, so engageTrainer is called with skipBattleText here) + for _ = 1, 300 do + if not topBox() then break end + U.tap(game, "a") + U.wait(5) + end + check("intro box dismissed", topBox() == nil) + local sawSecondBox, battleReached = false, false + for _ = 1, 900 do + local t = game.stack:top() + local mt = getmetatable(t) + if mt == TextBox and not sawSecondBox then + sawSecondBox = true + U.log("unexpected box reads:", boxText(t)) + end + if mt == BattleTransition or mt == BattleState then + battleReached = true + break + end + U.wait(1) + end + check("battle wipe started after the box", battleReached) + check("no second dialogue box between the walk and the battle", not sawSecondBox) + check("Giovanni walked the three tiles down to (6,12) first", + gio ~= nil and gio.cellX == GIO_STOP.x and gio.cellY == GIO_STOP.y) + + U.log(failed == 0 and "PASS all machine checks clean" + or ("FAIL " .. failed .. " machine check(s) above")) + + U.log("The battle wipe is running now; take the pad and win the fight.") + U.log("Right looks like what just played: his speech opened while he was") + U.log("behind the desk, then he walked down and the fight began straight") + U.log("away, with the evil-trainer sting and no extra dialogue. After the") + U.log("win you should get \"Arrgh!!\" on the battle screen, the \"Blast it") + U.log("all!\" speech, a fade, and every rocket gone. Wrong is the old bug:") + U.log("he crosses the room in silence and only then talks, point-blank.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/marowak_departed_bug867_test.lua b/tests/drivers/marowak_departed_bug867_test.lua new file mode 100644 index 00000000..ae9bbbc0 --- /dev/null +++ b/tests/drivers/marowak_departed_bug867_test.lua @@ -0,0 +1,188 @@ +-- Manual check of the ghost MAROWAK send-off on POKEMON_TOWER_6F (#867). +-- PokemonTower6FMarowakDepartedText (pokered scripts/PokemonTower6F.asm) is +-- two texts: the CUBONE's-mother line, then PlayCry RESTLESS_SOUL + 30 frames +-- before the calmed line; the port showed only the calmed line and no cry. +-- Never under POKEPORT_SPEED -- the cry-then-text beat is the thing under test. +-- POKEPORT_DRIVER=tests/drivers/marowak_departed_bug867_test.lua POKEPORT_IDENTITY=bug867 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots 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 TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- machine checks ---------------------------------------------------- + -- Both keys come out of the stock extractor (data/generated/text.lua); a + -- rename there would drop story3.lua onto its hand fallbacks, which still + -- displays but is worth knowing about. + local mother = game.data.text._PokemonTower6FGhostWasCubonesMotherText + local calmed = game.data.text._PokemonTower6FSoulWasCalmedText + check("_PokemonTower6FGhostWasCubonesMotherText extracted", + type(mother) == "string" and mother ~= "") + check("_PokemonTower6FSoulWasCalmedText extracted", + type(calmed) == "string" and calmed ~= "") + if type(mother) == "string" then + U.log("first line reads:", (mother:gsub("[\n\011\012]", " / "))) + end + if type(calmed) == "string" then + U.log("second line reads:", (calmed:gsub("[\n\011\012]", " / "))) + end + check("MAROWAK is a real species (RESTLESS_SOUL EQU MAROWAK)", + game.data.pokemon.MAROWAK ~= nil) + + local opts = game.save.options or {} + U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol)) + if opts.sfxVol == 0 then + U.log("sfxVol is 0: raise it in OPTION or the cry cannot be judged") + end + + -- ---- reach the trigger ------------------------------------------------- + -- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords: + -- dbmapcoord 10, 16 (a coord array, not an object). Row 17 is solid wall + -- and (9, 16) is the stairwell warp, so the approach is from (10, 15) + -- facing down, same as tests/drivers/ghost_unveil_bug492_test.lua. + local MAP = "POKEMON_TOWER_6F" + local TRIGGER = { x = 10, y = 16 } + local STAND = { x = 10, y = 15, facing = "down", step = "down" } + + -- one mon, one damaging move: the A-mash below always picks FIGHT slot 1, + -- and a stat move there stalls the run (route.lua learned this the hard way) + local mon = Pokemon.new(game.data, "MEWTWO", 100) + if game.data.moves.PSYCHIC_M then + mon.moves = { { id = "PSYCHIC_M", pp = 99 } } + end + game.save.party = { mon } + game.save.player.name = "RED" + -- the scope buys the unveil so the ghost can be damaged at all (#492) + game.save.inventory.SILPH_SCOPE = 1 + game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil) + + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the approach: stand on any walkable neighbour of the + -- trigger that is not the stairwell warp and step back onto it. + -- {dx, dy, facing} is the trigger-to-stand offset plus the direction + -- that walks back onto the trigger cell. + local sides = { { 0, -1, "down" }, { 1, 0, "left" }, + { -1, 0, "right" }, { 0, 1, "up" } } + for _, s in ipairs(sides) do + local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y), + cx, cy, "stepping", s[3]) + STAND = { x = cx, y = cy, facing = s[3], step = s[3] } + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + + -- walk onto the trigger; MapScripts onStep fires on the completed step + U.hold(game, STAND.step, 20) + U.wait(20) + check("the Be-gone text opened", game.stack:top() ~= game.overworld) + + -- ---- win the battle ---------------------------------------------------- + local sawBattle = false + for _ = 1, 1500 do + -- onFinish sets the flag and queues the departed rows in the same call, + -- so break on the flag BEFORE tapping: a stray A here would eat the + -- CUBONE's-mother box before it is recorded + if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then break end + if getmetatable(game.stack:top()) == BattleState then sawBattle = true end + U.tap(game, "a") + U.wait(4) + end + check("the MAROWAK battle opened", sawBattle) + check("the battle was won (EVENT_BEAT_GHOST_MAROWAK set)", + game.save.flags.EVENT_BEAT_GHOST_MAROWAK == true) + + -- ---- record the send-off boxes ----------------------------------------- + -- Each box is sampled the frame it is first seen: the calmed box carries + -- the armed cry as opts.auto {sound, wait} (src/script/Commands.lua), and + -- TextBox clears .auto once the cry has played, so a late read looks like + -- no cry at all. + local boxes = {} + local lastBox = nil + local budget = 1200 + while budget > 0 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + if top ~= lastBox then + lastBox = top + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + boxes[#boxes + 1] = { + text = table.concat(shown, " / "), + cry = top.auto ~= nil and top.auto.sound ~= nil, + } + U.log(("box %d reads:"):format(#boxes), boxes[#boxes].text) + -- let the typewriter finish before the shot so the capture shows the + -- line; the auto sample above already happened on the open frame + for _ = 1, 240 do + if top.waiting or top.done or game.stack:top() ~= top then break end + U.wait(1) + budget = budget - 1 + end + U.shot(game, DIR .. ("/bug867_box%d.png"):format(#boxes)) + end + U.tap(game, "a") + U.wait(3) + budget = budget - 4 + else + if #boxes >= 2 then break end + U.wait(1) + budget = budget - 1 + end + end + + check("the send-off is two boxes, not one (#867)", #boxes == 2) + check("box 1 is the CUBONE's-mother line", + boxes[1] ~= nil and boxes[1].text:find("CUBONE", 1, true) ~= nil) + check("box 1 opens silent (the asm plays no cry before it)", + boxes[1] ~= nil and not boxes[1].cry) + check("box 2 is the calmed line", + boxes[2] ~= nil and boxes[2].text:find("calmed", 1, true) ~= nil) + check("box 2 opens with the MAROWAK cry armed", + boxes[2] ~= nil and boxes[2].cry == true) + + -- ---- the trigger is spent ---------------------------------------------- + -- step off and back onto (10, 16): with the flag set, onStep must pass + local back = ({ down = "up", up = "down", left = "right", right = "left" }) + [STAND.step] + U.hold(game, back, 20) + U.wait(10) + U.hold(game, STAND.step, 20) + U.wait(20) + check("re-stepping the trigger cell stays quiet", + getmetatable(game.stack:top()) ~= TextBox) + U.shot(game, DIR .. "/bug867_after.png") + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand off ---------------------------------------------------------- + U.log("The pad is yours on 6F with the ghost already sent off. What should") + U.log("have happened: after the win, \"The GHOST was the / restless soul of /") + U.log("CUBONE's mother!\" first, then the MAROWAK cry sounds as the second box") + U.log("opens with \"The mother's soul / was calmed.\" The old bug jumped") + U.log("straight to the calmed line, no mother line and no cry at all.") + U.log("Screenshots: " .. DIR .. "/bug867_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_warp_spawn_bug863_test.lua b/tests/drivers/pikachu_warp_spawn_bug863_test.lua new file mode 100644 index 00000000..2e2bd903 --- /dev/null +++ b/tests/drivers/pikachu_warp_spawn_bug863_test.lua @@ -0,0 +1,164 @@ +-- Manual check that a warp arrival hides Pikachu under the player (#863): +-- pokeyellow spawns on the player's own coords and the follow buffer walks +-- it out, but before the fix it popped in already beside him. +-- Warp cells: pokered data/maps/objects/RedsHouse1F.asm / RedsHouse2F.asm. +-- POKEPORT_DRIVER=tests/drivers/pikachu_warp_spawn_bug863_test.lua POKEPORT_IDENTITY=bug863 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +-- Never add POKEPORT_SPEED; the identity needs an imported Yellow cache. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + -- the follower only spawns behind EVENT_GOT_STARTER with a healthy + -- PIKACHU in the party (PikachuFollower shouldSpawn) + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + game.save.player.name = "bryan" + + -- pokered RedsHouse1F.asm: warp_event 7, 1 -> REDS_HOUSE_2F, and + -- RedsHouse2F.asm: warp_event 7, 1 back down. Read the live map data + -- so a hack or mod that moved the stairs still points us at them. + local function warpTo(fromMap, destMap) + local def = game.data.maps[fromMap] + for _, w in ipairs(def and def.warps or {}) do + if w.destMap == destMap then return w.x, w.y end + end + return nil + end + local wx, wy = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F") + check("REDS_HOUSE_1F has a warp to REDS_HOUSE_2F", wx ~= nil) + wx, wy = wx or 7, wy or 1 + + local follower = function() return PF.current(game.overworld) end + + local function overlapsPlayer() + local npc = follower() + local p = game.overworld and game.overworld.player + return npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY + end + + -- press-and-hold dir until the map flips, releasing the instant it + -- does so no queued step drags the player off the arrival warp cell + local function walkUntilMap(dir, targetMap, maxFrames) + for _ = 1, maxFrames do + local ow = game.overworld + if ow and ow.map and ow.map.id == targetMap then break end + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + game.input.state[dir] = false + U.wait(45) -- warp fade + arrival settle + local ow = game.overworld + return ow and ow.map and ow.map.id == targetMap + end + + -- stand two below the stairs facing up; fall back to any walkable cell + -- below the warp if a mod reshaped the room + local sx, sy = wx, wy + 2 + U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up") + U.wait(10) + local ow = game.overworld + if not ow.map:isWalkableCell(sx, sy) then + for dy = 1, 3 do + if ow.map:isWalkableCell(wx, wy + dy) then + sx, sy = wx, wy + dy + U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up") + U.wait(10) + break + end + end + end + -- U.teleport is itself a fresh map load, so the fixed spawn already + -- parks Pikachu on the player's cell here + check("follower spawned on map load", follower() ~= nil) + check("map-load spawn is on the player's own cell", overlapsPlayer()) + + -- climb the stairs + check("walked up into REDS_HOUSE_2F", + walkUntilMap("up", "REDS_HOUSE_2F", 240)) + check("warp arrival upstairs: Pikachu hidden under the player", + overlapsPlayer()) + U.shot(game, SHOT_DIR .. "/bug863_2f_arrival.png") + + -- the draw sort tie-break (#863): sharing the player's py must list the + -- follower first so he draws over it. The sort runs in draw, and the + -- shot above rendered a frame, so entities order is post-sort here. + do + local npc = follower() + local p = game.overworld.player + if npc and p and npc.py == p.py then + local ni, pi + for i, e in ipairs(game.overworld.entities) do + if e == npc then ni = i elseif e == p then pi = i end + end + check("draw sort puts the hidden follower under the player", + ni ~= nil and pi ~= nil and ni < pi) + end + end + + -- walk off the stairs: the trail should pull Pikachu out one behind + U.hold(game, "down", 20) + U.wait(20) + U.hold(game, "down", 20) + U.wait(30) + do + local npc = follower() + local p = game.overworld.player + check("two steps later Pikachu trails one cell behind", + npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1) + check("and it faces down, walking out of the stairwell", + npc and npc.facing == "down") + end + U.shot(game, SHOT_DIR .. "/bug863_2f_trailing.png") + + -- back down the same stairs: descent must hide it the same way + check("walked back down into REDS_HOUSE_1F", + walkUntilMap("up", "REDS_HOUSE_1F", 240)) + check("warp arrival downstairs: Pikachu hidden under the player", + overlapsPlayer()) + U.shot(game, SHOT_DIR .. "/bug863_1f_return.png") + + -- regression: a connection seam is the keepPikachu path (#427), not a + -- respawn, so the follower must ride across it, not vanish or repark + U.teleport(game, "PALLET_TOWN", 10, 2, "up") + U.wait(10) + check("crossed the Pallet Town north seam into ROUTE_1", + walkUntilMap("up", "ROUTE_1", 300)) + do + local npc = follower() + local p = game.overworld.player + check("follower survived the connection crossing", npc ~= nil) + check("and stayed within trailing range of the player", + npc and p and math.abs(npc.cellX - p.cellX) + + math.abs(npc.cellY - p.cellY) <= 2) + end + U.shot(game, SHOT_DIR .. "/bug863_route1_seam.png") + + local sfx = game.save.options and game.save.options.sfxVol + if sfx == 0 then + U.log("note: sfxVol is 0, Pikachu's steps and voice will be silent") + end + + U.log("The shots above tell the story: on both stair arrivals only the") + U.log("player should be visible, Pikachu is tucked under him until he") + U.log("steps away, then it follows one cell behind facing his way.") + U.log("If a Pikachu sits beside him the moment a warp lands, that is") + U.log("the old bug. The pad is yours; warp around and watch spawns.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/title_menu_palette_bug870_test.lua b/tests/drivers/title_menu_palette_bug870_test.lua new file mode 100644 index 00000000..2575c2a7 --- /dev/null +++ b/tests/drivers/title_menu_palette_bug870_test.lua @@ -0,0 +1,149 @@ +-- Manual check of the title main menu + CONTINUE info box colors (#870): +-- both must follow the COLORS display mode (CLASSIC pea greens) instead of +-- staying a raw white trueColor hole, while gbc keeps #133's white paper / +-- black ink (pokered engine/menus/main_menu.asm RunDefaultPaletteCommand). +-- Palette shading is the moment under test, so POKEPORT_SPEED stays unset. +-- POKEPORT_DRIVER=tests/drivers/title_menu_palette_bug870_test.lua POKEPORT_IDENTITY=bug870 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local P = require("src.render.PaletteFX") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then fails = fails + 1 end + return ok + end + + -- Count near-white pixels in a captured frame. CLASSIC's lightest shade + -- is (155,188,15) and no blend of the four pea greens (or the letterbox) + -- reaches 250+, so any white here can only be an unshaded region -- the + -- exact white hole #870 is about. Reads the PNG back through + -- love.image so the check sees what actually hit the window. + local function whiteCount(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + if not ok or not img then return nil end + local n = 0 + for y = 0, img:getHeight() - 1 do + for x = 0, img:getWidth() - 1 do + local r, g, b = img:getPixel(x, y) + if r > 0.98 and g > 0.98 and b > 0.98 then n = n + 1 end + end + end + return n + end + + -- A real save on disk first: hasSave in TitleState:openMenu checks the + -- save FILE, not the in-memory table, and only then lists CONTINUE. + -- No map coordinates anywhere in this test -- the bug lives on the title + -- screen, before any map. + U.newGame(game) + check("reached the overworld", game.overworld ~= nil) + check("save written so the menu lists CONTINUE", + require("src.core.SaveData").save(game.save)) + + -- flip COLORS the way the options screen does, then power-cycle to the + -- title (returnToTitle skips the intro movie, unlike a cold boot) + game.save.options.colors = "classic" + P.applyOptions(game.save.options) + game:returnToTitle() + U.wait(30) + + local TitleState = require("src.ui.TitleState") + local title = game.stack:top() + check("back on the title screen", getmetatable(title) == TitleState) + + U.tap(game, "start") + U.wait(10) + local menu = game.stack:top() + check("main menu opened and set a titleUiBox", + menu ~= title and menu ~= nil and menu.titleUiBox ~= nil) + + -- The fix itself: the box overlay must be a GRAYS palette zone the shade + -- shader runs on. A colors == false zone would make Renderer:blitCanvas + -- re-blit the rect with NO shader, so effectiveColors never substitutes + -- the mono/inverted modes there -- the pre-#870 white hole. + local zones = title.sgbPalettes and title:sgbPalettes(game) + local boxZone, bare = nil, false + for _, z in ipairs(zones or {}) do + if z.colors == false then bare = true end + if z.colors == P.GRAYS then boxZone = z end + end + check("no trueColor (colors == false) zone over the menu box", not bare) + check("the titleUiBox rides a GRAYS palette zone", boxZone ~= nil) + -- effectiveColors under classic substitutes CLASSIC; the trailing + -- permute is the identity while no shade map is armed, so == holds + check("CLASSIC substitutes the GRAYS box zone", + P.effectiveColors(P.GRAYS) == P.CLASSIC) + + local shot1 = SHOT_DIR .. "/bug870_menu_classic.png" + if U.shot(game, shot1) then + local n = whiteCount(shot1) + U.log("white pixels in the menu shot:", tostring(n)) + check("CLASSIC main menu shot has zero raw-white pixels", + n ~= nil and n == 0) + end + + -- with a save present CONTINUE is first, so the cursor is already on it + U.tap(game, "a") + U.wait(10) + local info = game.stack:top() + check("CONTINUE info box open with its titleUiBox", + info ~= nil and info ~= menu and info.titleUiBox ~= nil + and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7) + + local shot2 = SHOT_DIR .. "/bug870_info_classic.png" + if U.shot(game, shot2) then + local n = whiteCount(shot2) + U.log("white pixels in the info shot:", tostring(n)) + check("CLASSIC CONTINUE info shot has zero raw-white pixels", + n ~= nil and n == 0) + end + + -- #133 regression gate: under gbc the GRAYS zone must pass through the + -- shader unchanged, so the box comes back white paper / black ink while + -- the LOGO zones keep the title colored around it + P.applyOptions({ colors = "gbc" }) + U.wait(5) + check("gbc leaves the GRAYS box zone alone (the #133 white box)", + P.effectiveColors(P.GRAYS) == P.GRAYS) + local shot3 = SHOT_DIR .. "/bug870_info_gbc.png" + if U.shot(game, shot3) then + local n = whiteCount(shot3) + U.log("white pixels in the gbc shot:", tostring(n)) + check("gbc info box paper is white again", n ~= nil and n > 0) + end + + -- the inverted modes must now invert the box with the screen too + P.applyOptions({ colors = "og_inv" }) + U.wait(5) + local inv = P.effectiveColors(P.GRAYS) + check("OG INV inverts the box paper to black", + inv ~= nil and inv[1] ~= nil and inv[1][1] == 0) + U.shot(game, SHOT_DIR .. "/bug870_info_oginv.png") + + -- hand over in the bug's own mode + game.save.options.colors = "classic" + P.applyOptions(game.save.options) + U.wait(2) + + U.log(fails == 0 and "PASS all machine checks" + or ("FAIL " .. fails .. " machine check(s), see above")) + U.log("The CONTINUE info box on screen is in CLASSIC now; its paper should") + U.log("be the same pea green as the title behind it, with dark green ink,") + U.log("just like the in-game START menu. Before #870 this box and the") + U.log("main menu were a pure white rectangle over the green title.") + U.log("B backs out to the menu; OPTION flips COLORS to eyeball the rest.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/launcher_one_column_reach_bug852.lua b/tests/engine/launcher_one_column_reach_bug852.lua new file mode 100644 index 00000000..00e85560 --- /dev/null +++ b/tests/engine/launcher_one_column_reach_bug852.lua @@ -0,0 +1,112 @@ +-- One-column launcher reach (#852) and the safe-area launcher anchor (#810). +-- No pokered cite: the launcher is port-only chrome. +-- +-- #852: minPanelHeight in src/import/LauncherView.lua was a flat 460*s tuned +-- for the two-column layout. A one-column window (portrait phone, squat 4:3 +-- device) stacks title + actions card + slot card + the pinned +-- Play/Reset-rebinds/Touch-Controls block, which needs more room; the flat +-- threshold read "tall enough", so the short-window page scroll never +-- engaged, buildSlotCard was cut by Kit.pushClip against the pinned block, +-- and Kit's clip-bounded hit-testing (src/ui/kit/Kit.lua) left every slot +-- row, the pager and "+ New save slot" drawn-but-inert. The fix makes the +-- threshold column-aware, so those windows scroll instead of clipping. +-- +-- The seam is LauncherView.draw itself: it publishes the page-scroll extent +-- on the importer (imp._pageScroll / imp._pageScrollMax, the values the +-- touch-drag and wheel paths feed), so a headless draw shows whether the +-- scroll engaged without reading any file-local constant. The 480x900 +-- window below is the discriminator: its natural panel space satisfies the +-- old flat threshold (inert, slot card clipped) but not the one-column one. +-- +-- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua; +-- here the complementary end-to-end anchor: Layout.metrics must place the +-- launcher at the corrected safe-area origin, not a DPI-inflated band down +-- the screen. +-- luajit tests/engine/launcher_one_column_reach_bug852.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- The launcher touches two graphics calls the shared stub does not carry +-- (focus-ring joins, the footer's BCG invert shader); both are draw-only, so +-- inert fills are enough for the layout arithmetic under test. +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Layout = require("src.ui.kit.Layout") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +-- A fresh launcher on the Red tab; no cache exists headless, so every +-- version sits in its "ROM required" state, which still lays out the full +-- one-column pile (title, actions card, slot card, pinned block). +local function freshLauncher() + return RomImporter.new(function() end, { launcher = true }) +end + +-- ------------------------------------------------ #852: the scroll engages +-- 480x900 one column: enough room for the old flat 460*s threshold, not for +-- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here +-- and the slot card sat clipped inert against the pinned buttons. +window(480, 900) +local m = Layout.metrics(1200) +eq(m.twoCol, false, "480-wide window lays out one column") +local imp = freshLauncher() +LauncherView.draw(imp) +check((imp._pageScrollMax or 0) > 0, + "one-column window short of the stack engages the page scroll") +eq(imp._pageScroll, 0, "a fresh page starts at the top") + +-- The wheel moves the page (the same offset the touch drag feeds), and the +-- offset clamps to the extent, so the whole stack down to "+ New save slot" +-- and the footer is reachable rather than clipped away. +local extent = imp._pageScrollMax +imp._wheelY = -1 +LauncherView.draw(imp) +eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent), + "one wheel notch scrolls the page down by its step") +imp._pageScroll = 1e6 +LauncherView.draw(imp) +eq(imp._pageScroll, imp._pageScrollMax, + "an offset past the end clamps to the extent, so the bottom is reachable") + +-- The reporter's portrait phone (360x780 units) is shorter still and must +-- also scroll; before the fix its slot list was unreachable. +window(360, 780) +local phone = freshLauncher() +LauncherView.draw(phone) +check((phone._pageScrollMax or 0) > 0, + "portrait-phone one-column window engages the page scroll") + +-- A one-column window tall enough for the whole stack stays inert: the +-- column-aware minimum is a floor, not a permanent scroll. +window(480, 1200) +local tall = freshLauncher() +LauncherView.draw(tall) +eq(tall._pageScrollMax, 0, + "a tall one-column window does not scroll for nothing") + +-- --------------------------------------- #810: launcher anchored in units +-- Layout.metrics anchors the launcher at SafeArea.rect's origin. Feed it +-- the iOS 16 portrait frame that reported the safe rect in framebuffer +-- pixels (3x DPI): the launcher must start at the 44-unit notch inset, not +-- 132 units down with the top of the window black (the #810 report). The +-- rescale itself is pinned in tests/engine/safe_area_units_test.lua. +love.graphics.getDimensions = function() return 375, 812 end +love.graphics.getPixelDimensions = function() return 1125, 2436 end +local oldSafe = love.window.getSafeArea +love.window.getSafeArea = function() return 0, 132, 1125, 2232 end +local ios = Layout.metrics(1200) +eq(ios.top, 44, "launcher anchors at the unit-space notch inset") +eq(ios.h, 744, "launcher gets the full unit-space safe height") +love.window.getSafeArea = oldSafe + +T.finish("launcher one-column reach") diff --git a/tests/engine/options_backup_rollforward_bug828.lua b/tests/engine/options_backup_rollforward_bug828.lua new file mode 100644 index 00000000..87168190 --- /dev/null +++ b/tests/engine/options_backup_rollforward_bug828.lua @@ -0,0 +1,109 @@ +-- #828, the revert half: after the launcher's OG -> WIDE toggle, a play +-- session rewrites options.lua with byte-identical content (play() re-stamps +-- an unchanged lastVersion, SaveData.save flushes the attached table, and +-- SaveSerializer's key-sorted encode makes equal tables equal bytes), so +-- saveOptions' conditional pre-write roll skips and options.lua.bak kept the +-- PRE-change file all session. Android and Steam Deck end sessions with a +-- hard teardown (HostShell.restart restartApp kill / AppImage execv) that can +-- eat the main file, and loadOptions then promoted that stale backup: the +-- reported "launcher-only persists, going in-game reverts". The fix rolls +-- the backup forward to the just-verified bytes after every landed write; +-- this suite pins that at-rest invariant. ROM-free (T2 engine tier), same +-- injected-fs shape as tests/engine/options_write_readback_bug828.lua, where +-- these checks should eventually fold in. +-- luajit tests/engine/options_backup_rollforward_bug828.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") + +local OPTIONS = "options.lua" +local BAK = OPTIONS .. ".bak" +local TMP = OPTIONS .. ".tmp" + +-- In-memory love.filesystem stub, the { getInfo, read, write, remove } shape +-- SaveData.persistFs accepts. `dropping` is mutable so one fs can serve a +-- healthy session and then a write that reports success without landing. +local function memfs() + local files = {} + local fs + fs = { + files = files, + dropping = false, + write = function(path, content) + if fs.dropping then return true end + files[path] = content + return true + end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } + return fs +end + +-- ---- at rest, the backup holds the newest verified bytes + +local fs = memfs() +SaveData.saveOptions({ battleLayout = "wide" }, fs) +check(fs.files[BAK] ~= nil, "the very first verified write already leaves a backup") +eq(fs.files[BAK], fs.files[OPTIONS], + "after a verified write the backup equals the main file (#828 roll-forward)") +check(fs.files[TMP] == nil, "the staged witness is still dropped after verification") + +-- ---- the reported session, write for write +-- Launcher toggles OG -> WIDE, then two rewrites whose bytes match the file +-- on disk: RomImporter:play re-stamping the same lastVersion (#835) and the +-- in-game SaveData.save flush of the attached, unchanged table. Both go +-- through loadOptions first, exactly as the shipping callers do, so the +-- encoder sees identical tables and the conditional pre-roll skips. +local live = memfs() +SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, live) + +local toggled = SaveData.loadOptions(live) +toggled.battleLayout = "wide" +SaveData.saveOptions(toggled, live) +local wideBytes = live.files[OPTIONS] + +local stamped = SaveData.loadOptions(live) +stamped.lastVersion = "red" +SaveData.saveOptions(stamped, live) +eq(live.files[OPTIONS], wideBytes, + "the play() lastVersion re-stamp is a byte-identical rewrite (sorted encode)") +SaveData.saveOptions(SaveData.loadOptions(live), live) +eq(live.files[OPTIONS], wideBytes, "the in-game flush is byte-identical too") + +eq(live.files[BAK], wideBytes, + "identical rewrites still carry the backup forward past the skipped pre-roll") + +-- the hard teardown eats the main file; recovery must answer the toggle +live.files[OPTIONS] = nil +eq(SaveData.loadOptions(live).battleLayout, "wide", + "a lost main file recovers to WIDE, not the pre-toggle OG backup (#828)") +check(live.files[OPTIONS] ~= nil, "and the main file is healed from that copy") + +-- ---- a write that does not land must not poison the backup +-- The roll-forward has to sit AFTER the readback verification: if the bytes +-- never reached disk (the #828 external-storage failure mode) the backup +-- keeps the last state that verifiably did. +local flaky = memfs() +SaveData.saveOptions({ battleLayout = "wide" }, flaky) +local verified = flaky.files[BAK] +flaky.dropping = true +eq(SaveData.saveOptions({ battleLayout = "og" }, flaky), nil, + "the vanished write still reports failure") +flaky.dropping = false +eq(flaky.files[BAK], verified, + "a write that never landed leaves the backup at the last verified bytes") +flaky.files[OPTIONS] = nil +eq(SaveData.loadOptions(flaky).battleLayout, "wide", + "so recovery after the failed write still answers the verified state") + +T.finish("options_backup_rollforward_bug828") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index ccd8508c..59b9b0b7 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -131,6 +131,31 @@ eq(healed and healed.battleLayout, "wide", check(live.files[OPTIONS] ~= "return { battleLayout = ", "the main options file is healed from the copy that parsed") +-- ---- a lost main file must recover to the NEWEST verified write +-- The platforms that lose options.lua do it on the hard teardown out of a +-- game session (HostShell.restart's restartApp kill on Android, execv on a +-- SteamOS AppImage), after rewrites whose bytes matched the file already on +-- disk: play()'s lastVersion stamp and the in-game save flush re-encode the +-- same table, and the key-sorted encoder makes those byte-identical, so the +-- conditional pre-write roll skips them. The backup is therefore rolled +-- forward after every verified write; otherwise recovery handed back the +-- file from BEFORE the launcher's change, which is exactly the reported +-- "set BATTLE LAYOUT to WIDE, go in game, close, and it is OG again" (#828). +local lost = memfs("honest") +SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, lost) +local editedOpts = SaveData.loadOptions(lost) +editedOpts.battleLayout = "wide" +SaveData.saveOptions(editedOpts, lost) -- the launcher's toggle +local replay = SaveData.loadOptions(lost) +replay.lastVersion = "red" -- play() re-stamps the same value +SaveData.saveOptions(replay, lost) -- byte-identical rewrite +SaveData.saveOptions(SaveData.loadOptions(lost), lost) -- in-game save flush, identical too +lost.files[OPTIONS] = nil -- the platform ate the main file +local promoted = SaveData.loadOptions(lost) +eq(promoted.battleLayout, "wide", + "a lost main file recovers to the newest verified write, not the " + .. "pre-change backup (#828)") + local gone = memfs("honest") SaveData.saveOptions({ battleLayout = "wide" }, gone) gone.files[OPTIONS] = nil diff --git a/tests/engine/safe_area_units_test.lua b/tests/engine/safe_area_units_test.lua new file mode 100644 index 00000000..a4387b99 --- /dev/null +++ b/tests/engine/safe_area_units_test.lua @@ -0,0 +1,51 @@ +-- SafeArea.rect unit sanity (#810): the iOS build reported the portrait +-- safe rect in framebuffer PIXELS while love.graphics works in DPI-scaled +-- units, and the old clamp kept the inflated top inset -- the launcher +-- started a band down the screen and left the top of it black. A rect +-- that cannot fit the unit window is converted back to units with +-- per-axis ratios (the axes can disagree, #208). No pokered cite: the +-- launcher is port-only chrome. +-- luajit tests/engine/safe_area_units_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local SafeArea = require("src.core.SafeArea") + +local oldDims = love.graphics.getDimensions +local oldPix = love.graphics.getPixelDimensions +local oldSafe = love.window.getSafeArea + +local function frame(uw, uh, pw, ph, sx, sy, sw, sh) + love.graphics.getDimensions = function() return uw, uh end + love.graphics.getPixelDimensions = function() return pw, ph end + love.window.getSafeArea = function() return sx, sy, sw, sh end + return SafeArea.rect() +end + +-- a pixel-based rect on a 3x portrait phone comes back in units (#810) +local x, y, w, h = frame(375, 812, 1125, 2436, 0, 132, 1125, 2232) +eq(x, 0, "pixel-unit safe x rescales") +eq(y, 44, "pixel-unit top inset rescales to the real notch") +eq(w, 375, "pixel-unit safe width rescales") +eq(h, 744, "pixel-unit safe height rescales") + +-- a correct unit rect passes through untouched +x, y, w, h = frame(375, 812, 1125, 2436, 0, 44, 375, 734) +eq(y, 44, "a unit rect keeps its top inset") +eq(h, 734, "a unit rect keeps its height") + +-- dpi 1: no rescale, the oversized rect still clamps to the window +x, y, w, h = frame(640, 576, 640, 576, 0, 100, 900, 900) +eq(y, 100, "no rescale when units are pixels") +eq(w, 640, "width clamps to the drawable window") +eq(h, 476, "height clamps to the drawable window") + +love.graphics.getDimensions = oldDims +love.graphics.getPixelDimensions = oldPix +love.window.getSafeArea = oldSafe + +T.finish("safe area units") diff --git a/tests/engine/save_convert_toggle_objects.lua b/tests/engine/save_convert_toggle_objects.lua new file mode 100644 index 00000000..219dc43b --- /dev/null +++ b/tests/engine/save_convert_toggle_objects.lua @@ -0,0 +1,187 @@ +-- Gen1 save codec (src/save_convert/GenSave.lua) for wToggleableObjectFlags +-- (ram/wram.asm, flag_array $100): the ShowObject/HideObject persistence the +-- codec used to skip entirely, so an import resurrected both Mt Moon fossils +-- (#857) and reverted Cerulean's GUARD1/GUARD2/ROCKET swap so the officer +-- blocked the robbed-house door again (#763). Bit numbering comes from +-- ../pokered/data/maps/toggleable_objects.asm entry order (bit set = hidden, +-- engine/overworld/toggleable_objects.asm IsObjectHidden), and the offset is +-- re-derived here from the wram walk rather than read out of GenSave.OFFSETS. +-- Also covers the Yellow-only wPikachuHappiness byte (#763, #838): the +-- 0x271C offset is pinned from the pokeyellow symbol file, not a local +-- pokeyellow checkout, so it still wants a confirmation against a real +-- emulator-written Yellow .sav. +-- luajit tests/engine/save_convert_toggle_objects.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local bit = require("bit") +local GenSave = require("src.save_convert.GenSave") +local SaveData = require("src.core.SaveData") + +-- the codec crosswalks need the real dataset; CI has no ROM +local loadPokemon = loadfile("data/generated/pokemon.lua") +if not loadPokemon then + print("save_convert_toggle_objects skipped (needs data/generated/ for the Gen1 save codec)") + os.exit(0) +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local toggles = loadfile("src/save_convert/data/toggle_objects.lua")() +local data = { + pokemon = loadPokemon(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), + toggleObjects = toggles, +} + +-- ------------------------------------------------------------------ +-- offset pins, independent of the codec's own arithmetic: wram.asm places +-- wToggleableObjectFlags 2 bytes (wPlayerCoins) past wPlayerCoins' label, +-- i.e. sav absolute 0x2852; the pokeyellow symbol file places +-- wPikachuHappiness at d46f - wMainDataStart d2f6 = 377, absolute 0x271C +-- ------------------------------------------------------------------ + +local OFF = GenSave.OFFSETS +eq(OFF.toggleObjectFlags, OFF.coins + 2, + "wToggleableObjectFlags sits 2 bytes (wPlayerCoins) past O.coins") +eq(OFF.toggleObjectFlags, 0x2852, "wToggleableObjectFlags is sav byte 0x2852") +eq(OFF.pikachuHappiness, 0x271C, "wPikachuHappiness is sav byte 0x271C") + +-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing +-- below trusts the writer it is checking +local function flagGet(bytes, base, index) + local byte = bytes:byte(base + math.floor(index / 8) + 1) + return bit.band(bit.rshift(byte, index % 8), 1) == 1 +end + +-- ------------------------------------------------------------------ +-- crosswalk <-> maps.lua contract: every named toggle entry must resolve +-- to a real object_event, or encode's itemsTaken/defeatedTrainers fold +-- (which looks the object up by name) silently misses it +-- ------------------------------------------------------------------ + +local entries = 0 +for _, e in pairs(toggles.byBit) do + entries = entries + 1 + local found + for _, obj in ipairs((data.maps[e[1]] or {}).objects or {}) do + if obj.name == e[2] then found = obj break end + end + check(found ~= nil, e[1] .. " has an object_event named " .. e[2]) +end +-- toggleable_objects.asm has 228 rows; two are placeholders with no +-- object_event in this port (SILPHCO7F_UNUSED, the UNUSED_MAP_F4 entry) +eq(entries, 226, "the crosswalk carries every real toggle bit and no more") + +-- the bits under test, straight from the crosswalk's own numbering +eq(toggles.byBit[109][2], "MTMOONB2F_DOME_FOSSIL", "bit 109 is the dome fossil") +eq(toggles.byBit[110][2], "MTMOONB2F_HELIX_FOSSIL", "bit 110 is the helix fossil") +eq(toggles.byBit[7][2], "CERULEANCITY_GUARD1", "bit 7 is the door guard") +eq(toggles.byBit[9][2], "CERULEANCITY_GUARD2", "bit 9 is the roof guard") +eq(toggles.byBit[104][2], "MTMOON1F_MOON_STONE", "bit 104 is the moon stone") + +-- ------------------------------------------------------------------ +-- round trip: templateless export of a save past the fossil pickup +-- (data/scripts/story2.lua hides both balls) and the Cerulean robbery +-- resolution (data/scripts/story5.lua rocketRows shows GUARD1, hides +-- GUARD2/ROCKET), plus a taken overworld item, which vanilla folds into +-- these same bits (engine/events/pick_up_item.asm) +-- ------------------------------------------------------------------ + +local function seedSave() + local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + save.party = { { + species = "SQUIRTLE", level = 6, exp = 200, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 }, + hp = 22, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45, + } } + return save +end + +local set = seedSave() +set.objectToggles = { + MT_MOON_B2F = { + MTMOONB2F_DOME_FOSSIL = false, + MTMOONB2F_HELIX_FOSSIL = false, + }, + CERULEAN_CITY = { + CERULEANCITY_GUARD1 = true, + CERULEANCITY_GUARD2 = false, + }, +} +-- the moon stone rides itemsTaken (src/world/OverworldController.lua +-- force-hides picked items), never objectToggles, so encode must fold it in +set.itemsTaken = { MT_MOON_1F_obj_9 = true } + +local setBytes = GenSave.encode(set, data, nil) +eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save") + +local TOG = OFF.toggleObjectFlags +check(flagGet(setBytes, TOG, 109), "the taken dome fossil is hidden (bit 109)") +check(flagGet(setBytes, TOG, 110), "the taken helix fossil is hidden (bit 110)") +check(flagGet(setBytes, TOG, 9), "the swapped-out roof guard is hidden (bit 9)") +check(not flagGet(setBytes, TOG, 7), + "the officer now beside the door stays visible (bit 7 clear)") +check(flagGet(setBytes, TOG, 104), + "the taken moon stone folds from itemsTaken into bit 104") + +-- untouched entries fall back to their compiled-in defaults, not to zero +check(flagGet(setBytes, TOG, 0), "PALLETTOWN_OAK defaults hidden (bit 0 set)") +check(not flagGet(setBytes, TOG, 1), + "VIRIDIANCITY_OLD_MAN_SLEEPY defaults visible (bit 1 clear)") + +local back = GenSave.decode(setBytes, data) +eq(#(back.warnings or {}), 0, "the export decodes with no warnings") +local reTog = back.objectToggles +check(type(reTog) == "table", "an import populates save.objectToggles") +eq(reTog.MT_MOON_B2F.MTMOONB2F_DOME_FOSSIL, false, + "the dome fossil stays taken across export -> import") +eq(reTog.MT_MOON_B2F.MTMOONB2F_HELIX_FOSSIL, false, + "the helix fossil stays taken across export -> import") +eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD1, true, + "the officer stays beside the door across export -> import") +eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD2, false, + "the roof guard stays gone across export -> import") +eq(reTog.PALLET_TOWN.PALLETTOWN_OAK, false, + "Oak's roaming sprite imports at its hidden default") +eq(reTog.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY, true, + "the sleepy old man imports at his visible default") +eq(reTog.MT_MOON_1F.MTMOON1F_MOON_STONE, false, + "the folded moon stone imports hidden too") + +-- ------------------------------------------------------------------ +-- Yellow starter friendship: gated on the data set's game because the +-- byte is current-map scratch in Red/Blue (see O.pikachuHappiness) +-- ------------------------------------------------------------------ + +check(not flagGet(setBytes, OFF.pikachuHappiness, 0) + and setBytes:byte(OFF.pikachuHappiness + 1) == 0, + "a Red/Blue export leaves the scratch byte at 0x271C zeroed") +eq(back.pikachuHappiness, nil, "a Red/Blue import never invents a happiness") + +local dataYellow = { + pokemon = data.pokemon, moves = data.moves, items = data.items, + maps = data.maps, toggleObjects = toggles, + eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(), + gameVersion = "yellow", +} +local ySave = seedSave() +ySave.pikachuHappiness = 200 +local yBytes = GenSave.encode(ySave, dataYellow, nil) +eq(yBytes:byte(OFF.pikachuHappiness + 1), 200, + "pikachuHappiness = 200 reaches sav byte 0x271C") +local yBack = GenSave.decode(yBytes, dataYellow) +eq(yBack.pikachuHappiness, 200, + "the follower's happiness survives export -> import on Yellow") + +T.finish("save_convert_toggle_objects") diff --git a/tests/launcher_mods_install_zip_test.lua b/tests/launcher_mods_install_zip_test.lua index 0fb9d1d0..e7b117f9 100644 --- a/tests/launcher_mods_install_zip_test.lua +++ b/tests/launcher_mods_install_zip_test.lua @@ -188,6 +188,43 @@ local leftover = 0 for _ in pairs(stagedTemps) do leftover = leftover + 1 end eq(leftover, 0, "fallback cleans staged temp after install") +-- #801: a same-id copy under a different folder name is replaced too, so the +-- update cannot leave a shadow copy for discover()'s first-id-wins race +resetFs() +files["mods/WildsOfKanto-1.5.0/manifest.json"] = + ('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}') + :format(MOD_ID) +files["mods/WildsOfKanto-1.5.0/main.lua"] = "return function() end\n" +files["imports/mods/update.zip"] = "PK\3\4update" +ok, err = LauncherMods.installZip("imports/mods/update.zip", + { replace = true, expectId = MOD_ID }) +check(ok == true, "replace install succeeds over an odd-named copy (" + .. tostring(err) .. ")") +check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil, + "odd-named same-id folder is removed by the replace") +check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, + "replace still lands in mods/") + +-- #834: a manifest-less mods/ tree (interrupted copy debris) must not +-- block a plain re-import as "already installed" +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +files["imports/mods/again.zip"] = "PK\3\4again" +ok, err = LauncherMods.installZip("imports/mods/again.zip") +check(ok == true, "debris tree does not block re-import (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "debris is cleared by the re-import") + +-- a real installed copy still refuses a plain duplicate import +resetFs() +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["imports/mods/dup.zip"] = "PK\3\4dup" +ok, err = LauncherMods.installZip("imports/mods/dup.zip") +check(not ok, "a listed install still refuses a plain duplicate import") +check(tostring(err):find("already installed", 1, true), + "duplicate refusal still names already installed") + -- Restore love.filesystem = savedFs SaveData.portableBaseDir = savedSaveDataPortable diff --git a/tests/launcher_mods_shadow_copy_bug801_834_test.lua b/tests/launcher_mods_shadow_copy_bug801_834_test.lua new file mode 100644 index 00000000..65ee4ba7 --- /dev/null +++ b/tests/launcher_mods_shadow_copy_bug801_834_test.lua @@ -0,0 +1,225 @@ +-- #801 / #834: what the PLAYER sees after an update over a shadow copy. +-- The tier file (launcher_mods_install_zip_test.lua) asserts which folders +-- survive installZip; this one asserts the panel-facing contracts built on +-- top of them: LauncherMods.list() must report the NEW version after a +-- replace even when a same-id copy sits under an archive-named folder that +-- enumerates first (#801, "Updated ... to X" yet the old version kept +-- loading), and uninstall()/re-import must both recover a manifest-less +-- mods/ debris tree left by an interrupted copy (#834, "already +-- installed" with nothing showing in the panel). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("launcher mods shadow copy #801/#834") +local eq = S.eq +local check = S.check + +-- The real-world shape from the #801 report: a hand-unzipped copy kept the +-- archive's folder name. "W" sorts before "o" in the stub's sorted +-- enumeration, so the shadow folder enumerates first -- the exact ordering +-- that made discover()'s first-id-wins dedupe resolve the stale copy. +local MOD_ID = "overworld_wild_spawns" +local SHADOW = "mods/WildsOfKanto-1.5.0" +local NEW_VERSION = "1.7.1" + +local ARCHIVE = { + [MOD_ID .. "/manifest.json"] = + ('{"id":"%s","name":"Wilds of Kanto","version":"%s","entry":"main.lua"}') + :format(MOD_ID, NEW_VERSION), + [MOD_ID .. "/main.lua"] = "return function() end\n", +} + +local files, dirs, arch = {}, {}, {} + +local function resetFs() + for k in pairs(files) do files[k] = nil end + for k in pairs(dirs) do dirs[k] = nil end + for k in pairs(arch) do arch[k] = nil end +end + +local function dirChild(key, name) + if name == nil or name == "" then return key:match("^[^/]+") end + local prefix = name .. "/" + if key:sub(1, #prefix) ~= prefix then return nil end + return key:sub(#prefix + 1):match("^[^/]+") +end + +local function mapInfo(map, name, kind) + if map[name] ~= nil then return { type = kind or "file" } end + for key in pairs(map) do + if dirChild(key, name) then return { type = "directory" } end + end + return nil +end + +local vfs = {} + +function vfs.write(name, data) + files[name] = data + return true +end + +function vfs.read(name) + if arch[name] ~= nil then return arch[name] end + return files[name] +end + +function vfs.remove(name) + files[name] = nil + dirs[name] = nil + return true +end + +function vfs.createDirectory(name) + dirs[name] = true + return true +end + +function vfs.getInfo(name, kind) + local info = mapInfo(arch, name) + or mapInfo(files, name) + or mapInfo(dirs, name, "directory") + if info and kind and info.type ~= kind then return nil end + return info +end + +function vfs.getDirectoryItems(name) + local seen, items = {}, {} + local function add(child) + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + for key in pairs(arch) do add(dirChild(key, name)) end + for key in pairs(files) do add(dirChild(key, name)) end + for key in pairs(dirs) do add(dirChild(key, name)) end + table.sort(items) + return items +end + +function vfs.mount(_, point) + for rel, body in pairs(ARCHIVE) do + arch[point .. "/" .. rel] = body + end + return true +end + +function vfs.unmount() + for k in pairs(arch) do arch[k] = nil end + return true +end + +function vfs.newFileData(data, name) + return { __filedata = true, data = data, name = name } +end + +function vfs.getSaveDirectory() + return "/tmp/pokeport-shadow-copy-test" +end + +function vfs.getSource() + return nil +end + +local savedFs = love.filesystem +local savedCacheFs = package.loaded["src.import.CacheFs"] +local savedLauncherMods = package.loaded["src.mods.LauncherMods"] + +local SaveData = require("src.core.SaveData") +local savedPortableBase = SaveData.portableBaseDir +local savedPortableFs = SaveData.portableFs + +love.filesystem = vfs +package.loaded["src.import.CacheFs"] = nil +package.loaded["src.mods.LauncherMods"] = nil +-- Portable mode off: loadOptions/uninstall must stay on the stub vfs, or the +-- checkout's real save directory would leak into the test. +SaveData.portableBaseDir = function() return nil end +SaveData.portableFs = function() return nil end +local LauncherMods = require("src.mods.LauncherMods") + +local function versionOf(id) + for _, row in ipairs(LauncherMods.list()) do + if row.id == id then return row.version end + end + return nil +end + +-- #801: the shadow copy alone resolves as the mod (sanity for the setup), +-- and after a replace-install the panel row flips to the zip's version. +-- Pre-fix, installZip returned success but only rewrote mods/; the +-- shadow folder enumerated first and list() kept answering 1.5.0 forever. +resetFs() +files[SHADOW .. "/manifest.json"] = + ('{"id":"%s","name":"Wilds of Kanto","version":"1.5.0","entry":"main.lua"}') + :format(MOD_ID) +files[SHADOW .. "/main.lua"] = "return function() end\n" +eq(versionOf(MOD_ID), "1.5.0", "shadow copy resolves before the update") + +files["imports/mods/update.zip"] = "PK\3\4update" +local ok, err = LauncherMods.installZip("imports/mods/update.zip", + { replace = true, expectId = MOD_ID }) +check(ok == true, "replace over a shadow copy succeeds (" .. tostring(err) .. ")") +eq(err, MOD_ID, "replace reports the manifest id") +eq(versionOf(MOD_ID), NEW_VERSION, + "list() reports the zip's version after the replace") +check(files[SHADOW .. "/manifest.json"] == nil, + "shadow folder is gone, so no stale copy can win first-id-wins later") +eq(#LauncherMods.list(), 1, "the update leaves exactly one panel row") + +-- #801: Delete from the panel must take the shadow copy with it, or the +-- next boot resurrects the mod from the odd-named folder. +resetFs() +files[SHADOW .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +ok, err = LauncherMods.uninstall(MOD_ID) +check(ok == true, "uninstall succeeds with a shadow copy present (" + .. tostring(err) .. ")") +check(files[SHADOW .. "/manifest.json"] == nil, + "uninstall removes the same-id shadow folder too") +check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil, + "uninstall removes mods/") +eq(#LauncherMods.list(), 0, "nothing is left for the panel to show") + +-- #834: interrupted-copy debris (mods/ with no manifest.json) is +-- invisible to list() yet used to hard-block every plain re-import with +-- "a mod named '' is already installed". Both recovery paths the +-- player can reach must work: plain re-import, and Delete. +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +eq(#LauncherMods.list(), 0, "debris tree shows no panel row") +files["imports/mods/again.zip"] = "PK\3\4again" +ok, err = LauncherMods.installZip("imports/mods/again.zip") +check(ok == true, "plain re-import over debris succeeds (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "re-import clears the debris file") +eq(versionOf(MOD_ID), NEW_VERSION, "re-import yields a listable mod") + +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +ok, err = LauncherMods.uninstall(MOD_ID) +check(ok == true, "uninstall clears a debris-only tree (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "debris file is gone after uninstall") + +-- guard rail: with a healthy install and NO debris, the duplicate gate +-- still refuses a plain import with the same wording the panel shows +resetFs() +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["imports/mods/dup.zip"] = "PK\3\4dup" +ok, err = LauncherMods.installZip("imports/mods/dup.zip") +check(not ok, "healthy duplicate import is still refused") +check(tostring(err):find("already installed", 1, true), + "refusal keeps the already installed wording") + +-- Restore +love.filesystem = savedFs +SaveData.portableBaseDir = savedPortableBase +SaveData.portableFs = savedPortableFs +package.loaded["src.import.CacheFs"] = savedCacheFs +package.loaded["src.mods.LauncherMods"] = savedLauncherMods + +S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 71f99cda..1ef1c640 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -700,8 +700,14 @@ do end -- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1 --- (blue/red UI ink). A trailing trueColor zone covers the overlay box. +-- (blue/red UI ink). A trailing GRAYS zone covers the overlay box: through +-- the shade-remap shader it is the identity for the box's DMG shades, so +-- pass-through modes keep #133's white paper / black ink, while the mono +-- and inverted display modes still recolor it with the rest of the screen +-- (a trueColor rect skipped the shader and left a raw white hole over a +-- CLASSIC pea-green title, #870). do + local PaletteFX = require("src.render.PaletteFX") local logo2 = { { 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 }, } @@ -730,8 +736,8 @@ do menu.titleUiBox = { 0, 0, 12, 3 } game.stack:push(menu) local withMenu = TitleState.sgbPalettes(title, game) - check(withMenu and #withMenu == 4 and withMenu[4].colors == false, - "title menu adds a trueColor overlay zone") + check(withMenu and #withMenu == 4 and withMenu[4].colors == PaletteFX.GRAYS, + "title menu adds a DMG-grays overlay zone (#870)") check(withMenu[4].x == 0 and withMenu[4].y == 0 and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8, "menu overlay covers the CONTINUE/NEW GAME box") @@ -739,8 +745,8 @@ do game.stack:pop() game.stack:push({ titleUiBox = { 4, 7, 19, 16 } }) local withCont = TitleState.sgbPalettes(title, game) - check(withCont and #withCont == 4 and withCont[4].colors == false, - "continue-info overlay adds a trueColor zone") + check(withCont and #withCont == 4 and withCont[4].colors == PaletteFX.GRAYS, + "continue-info overlay adds a DMG-grays zone (#870)") check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8 and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8, "continue overlay matches DisplayContinueGameInfo's box") diff --git a/tests/rom_importer_cursor_bug781_test.lua b/tests/rom_importer_cursor_bug781_test.lua new file mode 100644 index 00000000..8768b627 --- /dev/null +++ b/tests/rom_importer_cursor_bug781_test.lua @@ -0,0 +1,71 @@ +-- #781: Linux launcher mouse-dead behind the pad cursor. Reproduces the +-- X11 multi-monitor failure mode (polled love.mouse.getPosition frozen on +-- desktop-virtual coords, so the motion yield in _updatePadCursor never +-- fires) and asserts a host-forwarded mousepressed reclaims the pointer. +-- Self-contained: `luajit tests/rom_importer_cursor_bug781_test.lua`. +-- Should eventually merge into tests/rom_importer_cursor_test.lua (dofile'd +-- by tests/run_tests.lua). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer pad cursor #781") +local eq = S.eq + +local RomImporter = require("src.import.RomImporter") + +-- Bare importer with just the pad-cursor state new() would build; isNX +-- false keeps _updatePadCursor on the desktop path (polled motion yield), +-- _flex nil keeps the right-stick branch out of LauncherView. +local function makeImporter() + return setmetatable({ + android = false, + isNX = false, + _flex = nil, + _padCursor = { x = 320, y = 260 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _padInited = true, + }, RomImporter) +end + +-- Failure mode: SDL's polled mouse state stuck on coordinates outside the +-- window (primary display away from desktop 0,0). Successive samples are +-- identical, so the motion yield sees zero delta and never releases the +-- pad cursor no matter how much the real mouse moves. +local ri = makeImporter() +love.mouse.getPosition = function() return 2960, 4130 end +ri._padCursorActive = true +ri:_updatePadCursor(1 / 60) -- seeds _lastMouseX/_lastMouseY +ri:_updatePadCursor(1 / 60) +ri:_updatePadCursor(1 / 60) +eq(ri._padCursorActive, true, + "frozen polled coords starve the motion yield (the #781 trap)") + +-- The fix: the host-forwarded real press must win the pointer back, same +-- contract as PadCursor.yieldToPointer in the overlay hosts. This is the +-- half that un-gates LauncherView.update's click minting. +ri:mousepressed(10, 10, 1) +eq(ri._padCursorActive, false, + "mousepressed reclaims the pointer even when the yield is starved (#781)") + +-- A reclaimed pointer must stay reclaimed: the next pad-cursor tick with +-- still-frozen polled coords may not re-arm it by itself. +ri:_updatePadCursor(1 / 60) +eq(ri._padCursorActive, false, + "an idle pad tick does not re-steal the pointer after reclaim") + +-- Regression guard for the healthy desktop path: when polled coords do +-- move (window-relative, single monitor), the existing motion yield still +-- releases the pad cursor without needing a click. +local ri2 = makeImporter() +local px = 100 +love.mouse.getPosition = function() return px, 100 end +ri2._padCursorActive = true +ri2:_updatePadCursor(1 / 60) +px = 140 +ri2:_updatePadCursor(1 / 60) +eq(ri2._padCursorActive, false, + "real mouse motion still yields the pad cursor on sane polled coords") + +S.finish() diff --git a/tests/rom_importer_cursor_test.lua b/tests/rom_importer_cursor_test.lua index 2f8a41f3..47eee9ea 100644 --- a/tests/rom_importer_cursor_test.lua +++ b/tests/rom_importer_cursor_test.lua @@ -45,4 +45,12 @@ ri:play("red") eq(booted, "red", "unsupported system cursors still allow boot") eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone") +-- #781: a host-forwarded real mouse press must win the pointer back from +-- the pad cursor. While it is active LauncherView.update refuses to mint +-- mouse clicks, so a stuck motion yield (X11 multi-monitor polled coords) +-- left the Linux launcher mouse-dead until this reclaim existed. +ri._padCursorActive = true +ri:mousepressed(10, 10, 1) +eq(ri._padCursorActive, false, "mouse press yields the pad cursor (#781)") + S.finish() diff --git a/tests/save_convert_yellow_bug838_test.lua b/tests/save_convert_yellow_bug838_test.lua new file mode 100644 index 00000000..3e96eb1c --- /dev/null +++ b/tests/save_convert_yellow_bug838_test.lua @@ -0,0 +1,208 @@ +-- Yellow save export/import checks for #838: the codec used to run the +-- Red/Blue tables unmodified for Yellow, so (1) event flags went through +-- pokered's bit numbering even though pokeyellow renumbers wEventFlags, +-- and (2) wPikachuHappiness (pokeyellow d46f, absolute 0x271C in SRAM) +-- was never encoded or decoded. Yellow offsets are verified against the +-- pokeyellow symbol file -- no local pokeyellow checkout exists, so +-- ../pokered can only vouch for the shared R/B layout, which pokeyellow's +-- sram.asm matches byte for byte. Needs data/generated/, same as +-- tests/save_convert_tests.lua (its natural eventual home). +-- +-- Run: luajit tests/save_convert_yellow_bug838_test.lua + +package.path = "./?.lua;" .. package.path +_G.love = require("tests.love_stub") + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") + +local checks, failures = 0, 0 +local function check(cond, msg) + checks = checks + 1 + if not cond then + failures = failures + 1 + print("FAIL: " .. msg) + end +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local redFlags = loadfile("src/save_convert/data/event_flags.lua")() +local yellowFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")() + +-- Red/Blue and Yellow crosswalk sets over the same generated tables; the +-- only differences the codec keys off are the event-flag numbering and the +-- gameVersion tag (SaveConvert.ensureData stamps the same shape, #838). +local shared = { + pokemon = loadfile("data/generated/pokemon.lua")(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), +} +local redData = { + pokemon = shared.pokemon, moves = shared.moves, items = shared.items, + maps = shared.maps, eventFlags = redFlags, +} +local yellowData = { + pokemon = shared.pokemon, moves = shared.moves, items = shared.items, + maps = shared.maps, eventFlags = yellowFlags, gameVersion = "yellow", +} + +local OFF = GenSave.OFFSETS + +-- ------------------------------------------------------------------ +-- the Yellow event-flag table itself: pokeyellow's renumbering, not a +-- copy of the Red table under a new filename +-- ------------------------------------------------------------------ + +check(yellowFlags.count == 2560, + "yellow table covers the full 2560-bit wEventFlags array") +check(type(yellowFlags.byName) == "table" and type(yellowFlags.byBit) == "table", + "yellow table has the byName/byBit shape the codec reads") + +-- shared names on DIFFERENT bits: Yellow inserts events ahead of them +check(redFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406 + and yellowFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400, + "EVENT_GOT_DOME_FOSSIL sits on red bit 1406 vs yellow bit 1400") +check(redFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1402 + and yellowFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1403, + "the Mt Moon 3 trainer block shifts +1 in yellow (Jessie & James insert)") +check(redFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1924 + and yellowFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1925, + "the Silph Co 11F trainer block shifts +1 in yellow") + +-- yellow-only names the port's Yellow scripts set (data/scripts/ +-- yellow_jessie_james.lua and the catch-training tutorial): absent from +-- the Red table, so exporting through it silently dropped them +check(yellowFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402 + and redFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == nil, + "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES is yellow bit 1402, unknown to red") +check(yellowFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == 45 + and redFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == nil, + "EVENT_COMPLETED_CATCH_TRAINING is yellow bit 45, unknown to red") +check(yellowFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY ~= nil + and redFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY == nil, + "the Officer Jenny Squirtle event exists only in the yellow table") + +-- byBit/byName agree on the renumbered entries +check(yellowFlags.byBit[1400] == "EVENT_GOT_DOME_FOSSIL" + and yellowFlags.byBit[1402] == "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES", + "yellow byBit resolves the renumbered bits back to their names") + +-- ------------------------------------------------------------------ +-- wPikachuHappiness offset: d46f - wMainDataStart d2f6 = 377 past +-- sMainData, absolute 0x271C (per the pokeyellow symbol file) +-- ------------------------------------------------------------------ + +check(OFF.pikachuHappiness == 10012, + "OFFSETS.pikachuHappiness is absolute 0x271C (got " + .. tostring(OFF.pikachuHappiness) .. ")") +check(OFF.pikachuHappiness == OFF.mainData + 377, + "pikachuHappiness sits 377 bytes past sMainData (wram d46f - d2f6)") +-- the byte is INSIDE the checksummed main-data window, so writing it +-- without recomputing the checksum would brick the save on a cartridge +check(OFF.pikachuHappiness >= OFF.checksumStart + and OFF.pikachuHappiness < OFF.checksumEnd, + "pikachuHappiness lies inside the main checksum window") + +-- ------------------------------------------------------------------ +-- encode/decode gate: yellow data writes and reads the byte, R/B data +-- leaves it alone (in Red/Blue it is current-map scratch) +-- ------------------------------------------------------------------ + +local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +save.pikachuHappiness = 200 +-- the follower seeds happiness at 90 (src/world/PikachuFollower.lua), so +-- 200 can only come from this table -- no default could fake the check + +local yBytes = GenSave.encode(save, yellowData, nil) +check(#yBytes == GenSave.SAVE_SIZE, "yellow encode produces exactly 32768 bytes") +check(yBytes:byte(OFF.pikachuHappiness + 1) == 200, + "yellow encode writes pikachuHappiness to 0x271C (got " + .. yBytes:byte(OFF.pikachuHappiness + 1) .. ")") +check(GenSave.mainChecksumValid(yBytes), + "yellow encode still emits a valid main-data checksum") +local yDec = GenSave.decode(yBytes, yellowData) +check(yDec.pikachuHappiness == 200, + "yellow decode reads pikachuHappiness back (got " + .. tostring(yDec.pikachuHappiness) .. ")") + +-- a yellow save whose table never held the field falls back to the +-- follower's seed value instead of exporting friendship 0 +local noHap = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +noHap.pikachuHappiness = nil +local seedBytes = GenSave.encode(noHap, yellowData, nil) +check(seedBytes:byte(OFF.pikachuHappiness + 1) == 90, + "a missing pikachuHappiness exports as the follower seed 90, not 0") + +-- R/B output unchanged: same save through the red data set leaves the +-- scratch byte zero-filled and decode never invents the field +local rBytes = GenSave.encode(save, redData, nil) +check(rBytes:byte(OFF.pikachuHappiness + 1) == 0, + "red/blue encode leaves the 0x271C scratch byte zero-filled") +check(GenSave.decode(rBytes, redData).pikachuHappiness == nil, + "red/blue decode does not fabricate a pikachuHappiness field") + +-- ------------------------------------------------------------------ +-- event flags land on pokeyellow bits. Independent LSB-first flag_array +-- read (pokered home FlagAction convention: byte N/8, bit N%8) so the +-- assertions cannot inherit a codec bit-order bug. +-- ------------------------------------------------------------------ + +local bit = require("bit") +local function flagBit(bytes, index) + local b = bytes:byte(OFF.eventFlags + math.floor(index / 8) + 1) + return bit.band(bit.rshift(b, index % 8), 1) == 1 +end + +local fsave = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) +fsave.flags = { + EVENT_GOT_DOME_FOSSIL = true, + EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true, + EVENT_COMPLETED_CATCH_TRAINING = true, +} + +local yfBytes = GenSave.encode(fsave, yellowData, nil) +check(flagBit(yfBytes, 1400) and not flagBit(yfBytes, 1406), + "yellow export puts EVENT_GOT_DOME_FOSSIL on bit 1400, not red's 1406") +check(flagBit(yfBytes, 1402), + "yellow export carries EVENT_BEAT_MT_MOON_3_JESSIE_JAMES on bit 1402") +check(flagBit(yfBytes, 45), + "yellow export carries EVENT_COMPLETED_CATCH_TRAINING on bit 45") +local yfDec = GenSave.decode(yfBytes, yellowData) +check(yfDec.flags.EVENT_GOT_DOME_FOSSIL + and yfDec.flags.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES + and yfDec.flags.EVENT_COMPLETED_CATCH_TRAINING, + "yellow-numbered flags round-trip through decode") + +-- the pre-fix failure mode, pinned so it can never quietly return: the +-- red table lands the fossil on the wrong yellow bit and drops the +-- yellow-only names entirely +local rfBytes = GenSave.encode(fsave, redData, nil) +check(flagBit(rfBytes, 1406) and not flagBit(rfBytes, 1400), + "the red table writes the fossil on 1406, which yellow reads as another event") +check(not flagBit(rfBytes, 1402) and not flagBit(rfBytes, 45), + "the red table silently drops both yellow-only flags") + +-- ------------------------------------------------------------------ +-- SaveConvert.ensureData substitutes the yellow flag table (and stamps +-- gameVersion) when the caller names yellow; the versionless set still +-- resolves red numbering, per-version cached separately (#420 pattern) +-- ------------------------------------------------------------------ + +local yData, yErr = SaveConvert.loadData("yellow") +check(yData ~= nil, "SaveConvert.loadData('yellow') resolves (" .. tostring(yErr) .. ")") +check(yData and yData.gameVersion == "yellow", + "loadData('yellow') stamps gameVersion for the codec's byte gate") +check(yData and yData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400 + and yData.eventFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402, + "loadData('yellow') serves the pokeyellow flag numbering") +local dData = SaveConvert.loadData() +check(dData and dData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406 + and dData.gameVersion == nil, + "versionless loadData still serves the red numbering, untagged") + +print(string.format("save convert yellow #838: %d/%d checks passed", + checks - failures, checks)) +if failures > 0 then os.exit(1) end From b9e8b00af0ce3560235fe0c0b4d757d475b09415 Mon Sep 17 00:00:00 2001 From: AverageConsumer <35539970+AverageConsumer@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:42:37 +0200 Subject: [PATCH 09/12] feat(mods): add screen render visibility hook --- docs/modding.md | 7 ++ docs/rfcs/0002-screen-render-visible.md | 54 ++++++++++ src/core/Game.lua | 8 +- src/core/StateStack.lua | 16 ++- tests/modkit/cases/screen_render_visible.lua | 101 +++++++++++++++++++ 5 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 docs/rfcs/0002-screen-render-visible.md create mode 100644 tests/modkit/cases/screen_render_visible.lua diff --git a/docs/modding.md b/docs/modding.md index 69035af3..31721d9c 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -226,5 +226,12 @@ for driving a second physical display. This is what lets a mod lay the two passes out as two stacked Game Boy screens, or push one onto a second screen, without the engine knowing the layout. +`screen.render_visible` receives `(next, state)` while the main screen is being +composed. Return `false` to omit that state from drawing, opacity selection and +palette-zone ownership. The state remains on the stack and keeps its normal +update and input ownership, so a mod can mirror a native menu on another +display without reimplementing it. The default is `true`. Treat the wrapper as +a pure predicate: the renderer may ask it more than once per frame. + Developer mode also arms the mod loader's dev tripwire, which flags mods that reach outside their permission set. diff --git a/docs/rfcs/0002-screen-render-visible.md b/docs/rfcs/0002-screen-render-visible.md new file mode 100644 index 00000000..da546bde --- /dev/null +++ b/docs/rfcs/0002-screen-render-visible.md @@ -0,0 +1,54 @@ +# RFC 0002 — Let mods hide an active screen state from the main render + +## Status + +Proposed. Engine: `StateStack.lua`, `Game.lua`. Tests: +`screen_render_visible.lua`. + +## Motivation + +A mod can render a native menu on a companion display through +`render.compose`, but it cannot remove that menu from the main display without +also popping it. Popping transfers update and input ownership and forces the +mod to reimplement native menu behavior. + +## The decision it extends + +No prior D-number. Extends the render-hook plan in `docs/modding.md` and the +state-stack rendering contract in `docs/architecture.md`. + +## The exact API delta + +Backward-compatible, additive-only. + +### `screen.render_visible` + +New hook called with `(state) -> boolean` through the public wrapper signature +`(next, state)`. Its vanilla result is `true`. + +Returning `false` excludes the state from the main draw, from opaque-base +selection and from palette-zone ownership. It does not remove the state or +change update, input, push or pop behavior. The call sites are +`StateStack:visibleBase`, `StateStack:draw` and the equivalent draw and palette +walks in `Game:draw`. + +The hook is guarded by `Runtime.wantsHook`, so the no-subscriber path allocates +nothing. It is a pure render predicate and may be evaluated more than once per +frame. + +## Migration note for existing mods + +**Nothing.** With no subscriber every state remains visible, and the existing +state-stack, event and hook behavior is unchanged. + +## Parity tests + +- **No-mod:** the topmost opaque state still owns drawing and palette zones, + and `Runtime.wantsHook("screen.render_visible")` stays false. +- **Mod-API:** a fixture mod registers through `mod.hooks:wrap`, hides one + opaque state and proves the state beneath draws and owns the palette while + the hidden state remains topmost and continues updating. + +## Deprecation etiquette + +Nothing deprecated. This is one additive hook with a `true` vanilla default. diff --git a/src/core/Game.lua b/src/core/Game.lua index 11d86c5a..06856b91 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -16,6 +16,10 @@ local Screens = require("src.ui.Screens") local Game = {} +local function renderVisible(stack, state) + return state and (not stack.renderVisible or stack:renderVisible(state)) +end + -- dev-mode gate for the F5/backtick hotkeys; false keeps every src/dev -- module unloaded, so a player boot never touches a byte of dev code local devMode = os.getenv("POKEPORT_DEV") == "1" or _G.POKEPORT_DEV_MODE == true @@ -460,7 +464,7 @@ function Game:draw() local state = self.stack.states[i] local wideState = state and state.isWideBattleLayout and state:isWideBattleLayout() - if state and state.draw then + if renderVisible(self.stack, state) and state.draw then if classicOffset ~= 0 and not wideState then love.graphics.push() love.graphics.translate(classicOffset, 0) @@ -484,7 +488,7 @@ function Game:draw() local zones, worldZones, zoneOwner for i = #self.stack.states, 1, -1 do local s = self.stack.states[i] - if s.sgbPalettes then + if renderVisible(self.stack, s) and s.sgbPalettes then zones = s:sgbPalettes(self) zoneOwner = s break diff --git a/src/core/StateStack.lua b/src/core/StateStack.lua index 898fd3dc..3a1995a7 100644 --- a/src/core/StateStack.lua +++ b/src/core/StateStack.lua @@ -39,17 +39,29 @@ function StateStack:update(dt) if top and top.update then top:update(dt) end end +local function visibleByDefault() return true end + +-- A mod may mirror a state elsewhere and hide only its main-screen render. +-- The state stays on the stack, so update and input ownership do not move. +function StateStack:renderVisible(state) + if not state then return false end + if not Runtime.wantsHook("screen.render_visible") then return true end + return Runtime.call("screen.render_visible", visibleByDefault, state) ~= false +end + -- index of the lowest state drawn this frame (highest opaque, else 1) function StateStack:visibleBase() for i = #self.states, 1, -1 do - if self.states[i].isOpaque then return i end + local state = self.states[i] + if self:renderVisible(state) and state.isOpaque then return i end end return 1 end function StateStack:draw() for i = self:visibleBase(), #self.states do - if self.states[i].draw then self.states[i]:draw() end + local state = self.states[i] + if self:renderVisible(state) and state.draw then state:draw() end end end diff --git a/tests/modkit/cases/screen_render_visible.lua b/tests/modkit/cases/screen_render_visible.lua new file mode 100644 index 00000000..eb8d246a --- /dev/null +++ b/tests/modkit/cases/screen_render_visible.lua @@ -0,0 +1,101 @@ +-- screen.render_visible through the public mod API: a mirrored native screen +-- may leave the main render without leaving the active state stack. + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Game = require("src.core.Game") +local Runtime = require("src.mods.Runtime") +local StateStack = require("src.core.StateStack") +local Renderer = require("src.render.Renderer") +local TouchControls = require("src.core.TouchControls") + +local FIXTURE = { + ["mods/fix_screen_mirror/manifest.json"] = [[{ + "id": "fix_screen_mirror", + "name": "Fixture Screen Mirror", + "version": "1.0.0", + "entry": "main.lua", + "api": 2 + }]], + ["mods/fix_screen_mirror/main.lua"] = [[ + local mod = ... + mod.hooks:wrap("screen.render_visible", function(nextFn, state) + if state.screenId == "BagMenu" then return false end + return nextFn(state) + end) + ]], +} + +local savedSetUISize, savedBegin, savedEnd, savedTouch = + Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw +local presentedZones +Renderer.setUISize = function() end +Renderer.beginFrame = function() end +Renderer.endFrame = function(_, zones) + presentedZones = zones + return {} +end +TouchControls.draw = function() end + +local function scene() + local stack = setmetatable({}, { __index = StateStack }) + stack:init() + local base = { + isOpaque = true, + draws = 0, + draw = function(self) self.draws = self.draws + 1 end, + sgbPalettes = function() return "base zones" end, + } + local menu = { + screenId = "BagMenu", + isOpaque = true, + draws = 0, + updates = 0, + draw = function(self) self.draws = self.draws + 1 end, + update = function(self) self.updates = self.updates + 1 end, + sgbPalettes = function() return "menu zones" end, + } + stack:push(base) + stack:push(menu) + return { stack = stack, overworld = base, save = { options = {} } }, + base, menu +end + +-- no-mod parity +do + local run = T.sdk.loadNone({}) + local game, base, menu = scene() + T.eq(Runtime.wantsHook("screen.render_visible"), false, + "no subscriber leaves the render hook cold") + Game.draw(game) + T.eq(base.draws, 0, "the opaque menu still covers the state beneath") + T.eq(menu.draws, 1, "the opaque menu still draws") + T.eq(presentedZones, "menu zones", "the visible menu still owns palettes") + run.release() +end + +-- subscribed path, registered by a real fixture mod +do + local run = T.sdk.loadMods({ "mods/fix_screen_mirror" }, + { fs = T.sdk.memfs(FIXTURE) }) + T.eq(#run.errors, 0, + "the fixture mod loads clean (" .. tostring(run.errors[1]) .. ")") + local game, base, menu = scene() + Game.draw(game) + T.eq(base.draws, 1, "the state beneath the hidden menu draws") + T.eq(menu.draws, 0, "the mirrored menu is omitted from the main draw") + T.eq(presentedZones, "base zones", + "a hidden state cannot own the main-screen palette") + T.check(game.stack:top() == menu, + "the hidden menu remains the active top state") + game.stack:update(1 / 60) + T.eq(menu.updates, 1, "the hidden menu keeps its update ownership") + run.release() +end + +Renderer.setUISize, Renderer.beginFrame, Renderer.endFrame, + TouchControls.draw = savedSetUISize, savedBegin, savedEnd, savedTouch + +T.finish("screen_render_visible") From dbc48f377acdf41fc9b0fd04394f76a2ed755cbb Mon Sep 17 00:00:00 2001 From: Marcelo Machado Date: Thu, 6 Aug 2026 00:15:07 -0300 Subject: [PATCH 10/12] feat(trainerCard): enhance player portrait handling with trueColor support --- src/ui/TrainerCard.lua | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/ui/TrainerCard.lua b/src/ui/TrainerCard.lua index 3ea51ae8..43f0fdba 100644 --- a/src/ui/TrainerCard.lua +++ b/src/ui/TrainerCard.lua @@ -67,8 +67,15 @@ function TrainerCard.new(game, opts) end end self.circle = tryImage("assets/generated/trainer_card/circle_tile.png") - self.pic = tryImage(require("src.pokemon.Sprites").playerPath( - game.data, "front", { kind = "trainer_card" })) + + -- Capture both return values from playerPath: path and trueColor flag. + -- The trueColor flag is set by the player.sprite hook when a mod injects + -- a custom portrait that should bypass the MEWMON palette pipeline. + local picPath, picTrueColor = require("src.pokemon.Sprites").playerPath( + game.data, "front", { kind = "trainer_card" }) + self.pic = tryImage(picPath) + self.picTrueColor = self.pic and picTrueColor or false + return self end @@ -117,7 +124,18 @@ function TrainerCard:draw() -- top card (rows 0-7): NAME / MONEY / TIME, pic upper-right self:frameBox(0, 0, 20, 8) if self.pic then + love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(self.pic, 104, 4) + -- True-colour portraits (e.g. mod-injected custom characters) carry their + -- own colours and must not be re-mapped by the MEWMON zone shader. + -- markTrueColor appends a colors=false zone that the Renderer splices at + -- the end of the zone list, causing it to re-blit just this rect without + -- the palette shader on top of the already-colourised frame. + -- This matches the pattern used by OakSpeech, HallOfFame and SummaryMenu. + if self.picTrueColor then + local w, h = self.pic:getDimensions() + require("src.render.PaletteFX").markTrueColor(104, 4, w, h) + end end love.graphics.setColor(0, 0, 0, 1) Font.draw(Strings("NAME/%s", save.player.name or "RED"), 16, 16) From cb6cfb5556134f03f094d2697d785f6269529560 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 6 Aug 2026 06:36:12 -0400 Subject: [PATCH 11/12] CLOSES #883, CLOSES #887, CLOSES #894 --- README.md | 13 ++ docs/new-features.md | 8 +- main.lua | 35 +++- src/core/HostShell.lua | 36 +++- src/core/LaunchOptions.lua | 16 +- src/core/Platform.lua | 17 ++ src/import/RomImporter.lua | 45 ++--- src/inventory/ItemEffects.lua | 10 +- src/ui/BagMenu.lua | 8 +- tests/engine/evo_stone_cancel_bug883_test.lua | 166 ++++++++++++++++++ tests/parity_ai_switch_rate.lua | 123 +++++++++++++ tests/parity_picker_pointer_grab.lua | 28 ++- tests/run_tests.lua | 10 ++ 13 files changed, 476 insertions(+), 39 deletions(-) create mode 100644 tests/engine/evo_stone_cancel_bug883_test.lua create mode 100644 tests/parity_ai_switch_rate.lua diff --git a/README.md b/README.md index c1d4d08e..cd2a3bc6 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,19 @@ even on a different computer, as long as the same folder comes along. already written to either location is touched automatically, so copy files over yourself if you want to carry existing progress across the switch. +## Launch Options + +By default the app opens the launcher so you can pick a game. Launch options +skip it and start one game directly, which is what you want for a one-click +entry: a desktop shortcut per game, a Steam entry, or a handheld frontend. + +| Option | Effect | +| --- | --- | +| `--game=red` | boot Red, skipping the launcher (`blue` and `yellow` too, or just `r` / `b` / `y`) | +| `--slot=2` | load that save slot; takes a slot number or a slot id | +| `--launcher` | open the launcher anyway, so you can edit a shortcut you already made | + + ## iOS Every release ships `gen1recomp-*-ios.ipa`. Sideload it with AltStore diff --git a/docs/new-features.md b/docs/new-features.md index 77552928..3a19f226 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -625,10 +625,12 @@ layout. Both ask twice. ## Launch options: boot straight into a game -`love . --game red` skips the launcher and starts that game; `--slot ` picks the save slot to load, and `--launcher` forces the launcher -anyway. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that can -only pass environment variables. This is for one-click entries: a desktop +anyway. Spell these with an `=`: LÖVE reads the command line first and takes a +bare word as a path to a game, so `--game red` fails looking for a folder +called `red`. `POKEPORT_GAME` / `POKEPORT_SLOT` do the same for shortcuts that +can only pass environment variables. This is for one-click entries: a desktop shortcut per game, a Steam entry, or a handheld frontend. Asking for a game whose ROM has not been imported opens the launcher on that game's tab rather than failing. diff --git a/main.lua b/main.lua index ab3c8623..cb57bc37 100644 --- a/main.lua +++ b/main.lua @@ -30,6 +30,21 @@ end local Game, EditorApp, Importer, TouchEditor +-- #887: quit-to-launcher state, shared by love.load and love.quit (both need +-- it, so it is declared here rather than next to love.quit). +-- * launchedIntoGame -- a --game / POKEPORT_GAME shortcut booted this +-- session straight into a game, so there is no launcher behind it and a +-- window close must exit. Restarting instead re-read the same shortcut +-- and came right back into the game, and the next close did it again: +-- the app could not be closed at all (macOS feels this worst, where the +-- red X, Cmd+Q and the Dock's Quit are all the same quit event). +-- * RELAUNCH_MARKER -- written in the save dir just before the #785 +-- restart, so the fresh boot ignores any boot-straight-into-a-game +-- option exactly once and keeps #785's promise of landing in the +-- launcher, whatever put the game on screen this time. +local launchedIntoGame = false +local RELAUNCH_MARKER = "relaunch_to_launcher.txt" + local autopilot -- optional scripted-input dev tool (tests/autopilot.lua) local driverCo -- optional frame-driver (POKEPORT_DRIVER=file.lua): a -- coroutine that receives `Game` and yields once per @@ -334,10 +349,19 @@ function love.load(args) -- EmulationStation needs: one click into the game the player wants, with no -- menu in between. A game that is not imported falls through to the -- launcher on its tab rather than booting into nothing. + -- A window close that restarted us into the launcher (#785) leaves the + -- marker behind: consume it and stay on the launcher, or the shortcut below + -- would boot the same game again and that close would restart again, + -- forever (#887). Consumed on read, so the very next launch is normal. + local relaunched = love.filesystem.getInfo(RELAUNCH_MARKER) ~= nil + if relaunched then pcall(love.filesystem.remove, RELAUNCH_MARKER) end + local launchGame, launchSlot = LaunchOptions.resolve(arg) - if launchGame and not LaunchOptions.forceLauncher(arg) then + if launchGame and not relaunched and not LaunchOptions.forceLauncher(arg) then if RomImporter.isReady(launchGame) then if launchSlot then LaunchOptions.selectSlot(launchGame, launchSlot) end + -- No launcher behind this session: love.quit must exit, not restart. + launchedIntoGame = true bootGame(launchGame) return end @@ -793,8 +817,15 @@ function love.quit() -- restart path must be no worse than that, not quietly better. local scripted = os.getenv("POKEPORT_AUTOPILOT") or os.getenv("POKEPORT_DRIVER") or os.getenv("POKEPORT_IMPORT_ONLY") == "1" or os.getenv("POKEPORT_IMPORT_ROM") - if Game and not Importer and not quitToLauncher and not scripted then + -- #887: a shortcut session (--game / POKEPORT_GAME) has no launcher to go + -- back to and the restart would re-read the shortcut, so it exits instead. + if Game and not Importer and not quitToLauncher and not scripted + and not launchedIntoGame then quitToLauncher = true + -- Tell the fresh boot to ignore any boot-straight-into-a-game option this + -- once, so the restart really does land in the launcher (#887). A failed + -- write only costs that suppression, so it must never block the restart. + pcall(love.filesystem.write, RELAUNCH_MARKER, "1") require("src.core.HostShell").restart() return true -- abort this quit; the restart lands back in the launcher end diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index d7a9d0f2..448db31e 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -62,8 +62,35 @@ function HostShell.hideHostConsole() return consoleHidden end +-- #254 was fixed inside the launcher and nowhere else: a native dialog opened +-- while a mouse button is still down blocks the whole loop in io.popen, so SDL +-- never processes the button-up and never drops the pointer capture it took +-- for the press (on X11 an XGrabPointer with owner_events). The grab outlives +-- the click, every pointer event over the child dialog is still routed to our +-- window, and the dialog draws and keyboard-navigates but ignores the mouse. +-- src/import/RomImporter.lua owns the launcher's copy; hoisting it here means +-- every host spawn inherits it, including one a mod reaches through HostShell. +-- Pump until nothing is held so SDL sees the release first; bounded, so a +-- stuck button costs a moment and never the game. pump() drains OS events +-- into LOVE's queue and dispatches nothing, so there is no reentry. Worker +-- threads load neither love.mouse nor love.event, so the guard below makes +-- this a no-op off the main thread. +function HostShell.releasePointerGrab() + if not (love and love.mouse and love.mouse.isDown and love.event + and love.event.pump and love.timer) then + return + end + local deadline = love.timer.getTime() + 1 + while love.mouse.isDown(1, 2, 3) do + love.event.pump() + if love.timer.getTime() > deadline then break end + love.timer.sleep(0.005) + end +end + -- Wraps io.popen with the AppImage env fix applied and lua errors swallowed function HostShell.popen(command, mode) + HostShell.releasePointerGrab() local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r") if not ok or not pipe then return nil end return pipe @@ -154,8 +181,15 @@ local function haveBridge() if not (love and love.system and type(love.system.httpDownload) == "function") then return false end + -- The OS allowlist is deliberate: the bridge is a per-port native addition, + -- not part of LOVE, so a build that exports the name on a platform we never + -- wired one for is a name collision, not a transport. UWP is listed because + -- Xbox has no curl and no way to spawn one (Platform.canSpawnProcess is + -- false there), so the bridge is its only possible transport (#876). Its + -- LOVE backend does not export it today and this still returns false, but + -- the gate is no longer the thing in the way. local osName = love.system.getOS and love.system.getOS() - return osName == "Android" or osName == "iOS" + return osName == "Android" or osName == "iOS" or osName == "UWP" end -- Is any transport available at all? Callers gate on this, never on curl. diff --git a/src/core/LaunchOptions.lua b/src/core/LaunchOptions.lua index 101785ac..216e26df 100644 --- a/src/core/LaunchOptions.lua +++ b/src/core/LaunchOptions.lua @@ -1,10 +1,16 @@ -- Launch options: boot straight into a game, skipping the launcher. -- --- love . --game red -- boot Red --- love . --game yellow --slot 2 -- boot Yellow on save slot 2 --- love . --game red --launcher -- open the launcher anyway (a shortcut --- the player wants to edit) --- POKEPORT_GAME=blue love . -- same, for launchers that only pass env +-- love . --game=red -- boot Red +-- love . --game=yellow --slot=2 -- boot Yellow on save slot 2 +-- love . --game=red --launcher -- open the launcher anyway (a shortcut +-- the player wants to edit) +-- POKEPORT_GAME=blue love . -- same, for launchers that only pass env +-- +-- The "--flag value" spelling parses here (argValue reads argv[i + 1]), but it +-- does not survive LOVE: boot.lua takes the first bare argument as a path to a +-- game to run, so `--game red` dies with "Cannot load game at path .../red" +-- before love.load is ever called, fused or not. Only the "=" spelling is +-- reachable, so that is the one the docs quote. -- -- This exists for the click-once cases: a desktop shortcut per game, a Steam -- entry, an EmulationStation/Playnite entry, a handheld frontend. Those all diff --git a/src/core/Platform.lua b/src/core/Platform.lua index 29e030ea..2d1adc4a 100644 --- a/src/core/Platform.lua +++ b/src/core/Platform.lua @@ -12,6 +12,8 @@ local function compute() local mobile = osName == "Android" or osName == "iOS" local nativePicker = love and love.system and type(love.system.pickFile) == "function" + local nativeHttp = love and love.system + and type(love.system.httpDownload) == "function" return { os = osName, nx = nx, @@ -24,6 +26,17 @@ local function compute() or (nativePicker and "native-picker") or "desktop", networkValidated = not nx and not uwp, + -- networkValidated is the self-updater's gate and stays a per-platform + -- policy call: a console package cannot replace itself on disk, so that + -- answer never depends on whether a transport exists. Fetching a mod + -- index or a mod zip is the narrower question, and #876 showed the two + -- had been conflated, so Xbox lost the mod catalog for the updater's + -- reason. Desktop answers it with curl through HostShell; the mobile and + -- console ports answer it with the native love.system.httpDownload bridge + -- (#597). The UWP LOVE backend does not export that bridge yet, so this + -- still resolves false on Xbox and the launcher still says so, but the + -- day the backend grows one, nothing here or in RomImporter has to change. + canFetchRemote = (not nx and not uwp) or nativeHttp, } end @@ -52,6 +65,10 @@ function Platform.networkValidated() return Platform.detect().networkValidated end +function Platform.canFetchRemote() + return Platform.detect().canFetchRemote +end + -- Tests may swap love.system between cases. function Platform._resetForTests() cached = nil diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 50cae927..0b4570e0 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -315,27 +315,16 @@ end -- keyboard-navigates (keyboard focus is a separate grab) but ignores the -- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how -- long the click was held, which is why the same build picks one ROM fine and --- then hangs the mouse on the next. So pump until no button is held, letting --- SDL see the release and let go first; bounded, so a stuck button costs a --- moment and never the launcher. pump() only drains OS events into LOVE's --- queue -- it dispatches nothing -- so there is no reentry into mousepressed --- and the release is still delivered normally on the next frame. -local function releasePointerGrab() - if not (love.mouse and love.mouse.isDown and love.event and love.event.pump - and love.timer) then - return - end - local deadline = love.timer.getTime() + 1 - while love.mouse.isDown(1, 2, 3) do - love.event.pump() - if love.timer.getTime() > deadline then break end - love.timer.sleep(0.005) - end -end +-- then hangs the mouse on the next. +-- +-- The release itself now lives in HostShell.releasePointerGrab, called from +-- HostShell.popen, so every host spawn inherits it and not just the three +-- pickers here. It stays a single release point on purpose: this file used +-- to run its own copy first, and each copy carries its own one-second bound, +-- so keeping both made a stuck button cost two seconds instead of one. local function commandOutput(command) if not Platform.canSpawnProcess() then return nil end - releasePointerGrab() local pipe = HostShell.popen(command) if not pipe then return nil end local result = pipe:read("*a") @@ -2917,9 +2906,14 @@ end -- Update button: when a newer release is known, confirm then install; when -- already current, force-refresh the 6h cache and report / offer update. function RomImporter:_modGithubAction(id, action) - if not Platform.networkValidated() then + -- canFetchRemote, not networkValidated: the self-updater's gate used to + -- stand in for this one, which cost Xbox the whole mod catalog rather than + -- just the self-update it actually cannot do (#876). Say what still works + -- while we are here, since the native picker is live on every platform that + -- lands in this branch. + if not Platform.canFetchRemote() then self.modNotice = { ok = false, - text = "Remote mod download is unavailable on this platform." } + text = "Remote mod download is unavailable on this platform. Install a mod .zip from storage instead." } return end local ModUpdate = require("src.mods.ModUpdate") @@ -3223,9 +3217,18 @@ end -- never ran. The fetch now starts here and completes across later frames in -- _pumpFindFetch; the loader overlay is up for the whole flight. function RomImporter:_refreshFind(force) - if not Platform.networkValidated() then + -- The notice is the fix, not the gate (#876). This branch used to return an + -- empty listing silently, and because the player had by then added a source, + -- the panel skipped its "No mod index added" card and rendered the merged + -- listing empty state instead: a valid feed reported as "This index lists no + -- mods yet." Every other failure on this panel surfaces through findNotice, + -- and this one has to as well, or adding an index looks like it worked and + -- the index looks empty. + if not Platform.canFetchRemote() then self.findLoaded = true self.findIndex = { mods = {}, categories = {} } + self.findNotice = { ok = false, + text = "Mod indexes cannot be fetched on this platform. Install a mod .zip from storage instead." } return end local ModIndex = require("src.mods.ModIndex") diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 81a5362a..63f74228 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -51,6 +51,10 @@ local STONES = { local VITAMINS = { HP_UP = "hp", PROTEIN = "attack", IRON = "defense", CARBOS = "speed", CALCIUM = "special" } +-- REPEL / SUPER_REPEL / MAX_REPEL all funnel through ItemUseRepelCommon, +-- which refuses mid-battle before writing wRepelRemainingSteps (#894) +local REPELS = { REPEL = true, SUPER_REPEL = true, MAX_REPEL = true } + ItemEffects.BALLS = BALLS function ItemEffects.isBall(id) return BALLS[id] or false end @@ -144,9 +148,11 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) local name = itemDef and itemDef.name or itemId -- ItemUseVitamin / ItemUsePPUp / ItemUseEvoStone / ItemUseCoinCase / - -- ItemUseTMHM all refuse mid-battle (jp nz, ItemUseNotTime) + -- ItemUseTMHM / ItemUseRepelCommon all refuse mid-battle + -- (jp nz, ItemUseNotTime) if battle and (VITAMINS[itemId] or STONES[itemId] or itemId == "PP_UP" or itemId == "RARE_CANDY" or itemId == "COIN_CASE" + or REPELS[itemId] or (itemDef and itemDef.machine)) then return "failed", { notTime(data, save) } end @@ -518,7 +524,7 @@ function ItemEffects.use(data, save, itemId, target, battle, moveIndex, ow) return "failed", { romText(data, "_CoinCaseNumCoinsText", "Coin count:\n%d", save.coins or 0) } end - if itemId == "REPEL" or itemId == "SUPER_REPEL" or itemId == "MAX_REPEL" then + if REPELS[itemId] then local steps = itemId == "REPEL" and 100 or itemId == "SUPER_REPEL" and 200 or 250 save.repelSteps = steps return "consumed", { Strings("%s used\n%s!", save.player.name, name) } diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index b0c5c738..650b364d 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -267,7 +267,13 @@ local function useOn(game, battle, id, target, list, moveIndex, picker) if extra and extra.evolveTo then list:close() local Evolution = require("src.pokemon.Evolution") - Evolution.evolve(game, target, extra.evolveTo) + -- item_effects.asm ItemUseEvoStone sets wForceEvolution before + -- TryEvolvingMon, so a stone evolution's B press is read and + -- discarded (EvolutionState.lua's cancelable check). via = "ITEM" + -- is what makes that non-cancelable here, same as the RARE_CANDY + -- call below; without it the stone (already consumed above) could + -- be cancelled out from under the player (#883) + Evolution.evolve(game, target, extra.evolveTo, nil, "ITEM") return end -- RARE CANDY: after the level text, the stat window, any level-up diff --git a/tests/engine/evo_stone_cancel_bug883_test.lua b/tests/engine/evo_stone_cancel_bug883_test.lua new file mode 100644 index 00000000..6a04f155 --- /dev/null +++ b/tests/engine/evo_stone_cancel_bug883_test.lua @@ -0,0 +1,166 @@ +-- A stone evolution started from the bag must not be cancelable (#883). +-- +-- engine/items/item_effects.asm ItemUseEvoStone sets wForceEvolution before +-- `call TryEvolvingMon`, and engine/movie/evolution.asm +-- Evolution_CheckForCancel reads the joypad but throws the B press away while +-- that flag is set (#290). So the B abort is a level-up/rare-candy behavior +-- only: a stone is removed from the bag the moment it is used, and an +-- evolution the player can cancel out of would eat the stone for nothing. +-- +-- src/ui/EvolutionState.lua encodes the flag as `via`: cancelable is +-- (via ~= "TRADE" and via ~= "ITEM"). The bag's stone branch omitted the +-- argument entirely, so `via` arrived nil and the movie accepted B. The +-- assertion here is on the value that reaches the screen, which is the only +-- thing standing between the two behaviors. +-- +-- ROM-free: the fixture dataset plus a registry-supplied EvolutionState, so +-- the real Screens.push resolution runs and no sprite is ever loaded. +-- luajit tests/engine/evo_stone_cancel_bug883_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- Lazily required inside the use branches; seeding package.loaded first keeps +-- the suite silent and free of a real Font atlas. +package.loaded["src.core.Sound"] = { + play = function() end, + playCry = function() end, +} +package.loaded["src.render.TextBox"] = { + new = function(_, text, done) return { textBox = true, text = text, done = done } end, +} +-- BagMenu and PartyMenu bind TextBox at require time, so they load against the +-- stub; Screens caches its factory per id and must be told to forget. +package.loaded["src.ui.BagMenu"] = nil +package.loaded["src.ui.PartyMenu"] = nil +local BagMenu = require("src.ui.BagMenu") +local PartyMenu = require("src.ui.PartyMenu") +local Screens = require("src.ui.Screens") +Screens.invalidate() + +local Fixtures = require("tests.modkit.fixtures") +local Bag = require("src.inventory.Bag") +local Pokemon = require("src.pokemon.Pokemon") +local EvolutionState = require("src.ui.EvolutionState") + +local Data = Fixtures.fresh() +-- The fixture item table carries no stone, and ItemEffects keys its stone +-- branch on the id; BagMenu only reads name/keyItem off the def. +Data.items.THUNDER_STONE = { + id = "THUNDER_STONE", index = 33, name = "THUNDERSTONE", price = 2100, + tossable = true, +} +-- and no fixture species evolves, so give A the stone evolution the branch +-- looks for (evo.method == "ITEM" and evo.item == the stone used). +Data.pokemon.FIXMON_A.evolutions = { + { method = "ITEM", item = "THUNDER_STONE", species = "FIXMON_B" }, +} + +-- The seam: Screens resolves an id through game.data.screens before falling +-- back to the builtin module, which is the same path a mod-replaced screen +-- takes. Recording the factory here catches exactly what Evolution.evolve +-- forwards, with no monkeypatching of Screens itself. +local pushed +Data.screens = Data.screens or {} +Data.screens.EvolutionState = function(game, mon, newSpecies, onDone, via) + pushed = { game = game, mon = mon, newSpecies = newSpecies, + onDone = onDone, via = via } + return { evoRecorder = true } +end +Screens.invalidate() + +local function freshGame() + local mon = Pokemon.new(Data, "FIXMON_A", 20) + local game = { + data = Data, + save = { + party = { mon }, + player = { name = "RED", id = 1 }, + inventory = {}, + options = {}, + flags = {}, + money = 0, + }, + } + game.stack = { + states = {}, + push = function(self, s) table.insert(self.states, s) end, + pop = function(self) return table.remove(self.states) end, + top = function(self) return self.states[#self.states] end, + } + -- one button edge per update, the way Input reports a fixed step + game.input = { pressed = nil } + function game.input:wasPressed(b) return self.pressed == b end + Bag.add(game.save, "THUNDER_STONE", 1) + return game, mon +end + +local function isPicker(s) return getmetatable(s) == PartyMenu end + +local function rowFor(list, id) + for i, r in ipairs(list.items) do + if r.value == id then return i end + end + return nil +end + +-- Open the bag, put the cursor on the stone, choose it, take USE off the +-- USE/TOSS box, then press A on the party picker. +local function useStone(game) + local list = BagMenu.new(game, {}) + game.stack:push(list) + local row = rowFor(list, "THUNDER_STONE") + if not row then return nil, "no THUNDER_STONE row in the bag" end + list.index = row + list.onChoose(list.items[row], list) + local sub = game.stack:top() + if sub and sub.items and sub.items[1] and sub.items[1].onSelect then + game.stack:pop() -- the USE/TOSS Menu pops itself on select + sub.items[1].onSelect() + end + local picker = game.stack:top() + if not isPicker(picker) then return nil, "party picker never opened" end + game.input.pressed = "a" + picker:update(1 / 60) + game.input.pressed = nil + return list +end + +do + local game, mon = freshGame() + local list, why = useStone(game) + if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then + if check(pushed ~= nil, "the stone use pushed the evolution screen") then + eq(pushed.newSpecies, "FIXMON_B", "and it is the stone's evolution") + eq(pushed.mon, mon, "for the mon the stone was used on") + eq(pushed.via, "ITEM", + "the evolution runs as via = \"ITEM\" (wForceEvolution), which is " + .. "what makes it non-cancelable (#883)") + end + eq(game.save.inventory.THUNDER_STONE, nil, + "the stone is already gone by then, so a cancel would cost it for " + .. "nothing") + end +end + +-- The value only matters because of what EvolutionState does with it, so +-- assert that half against the real constructor rather than trusting the +-- comment. new() loads sprites through pcall and plays music through the +-- stubbed Sound, so it is safe headless. +do + local game = freshGame() + local mon = game.save.party[1] + local stoneEvo = EvolutionState.new(game, mon, "FIXMON_B", nil, "ITEM") + check(stoneEvo.cancelable == false, + "EvolutionState refuses B for a stone evolution (evolution.asm " + .. "Evolution_CheckForCancel with wForceEvolution set)") + local levelEvo = EvolutionState.new(game, mon, "FIXMON_B", nil, "LEVEL") + check(levelEvo.cancelable == true, + "and still honours B for a level-up evolution, so the fix did not " + .. "silently disable the cancel everywhere (#290, #213)") +end + +T.finish() diff --git a/tests/parity_ai_switch_rate.lua b/tests/parity_ai_switch_rate.lua new file mode 100644 index 00000000..d792a3e7 --- /dev/null +++ b/tests/parity_ai_switch_rate.lua @@ -0,0 +1,123 @@ +-- Parity test: the per-class trainer switch rolls (#890). +-- +-- Reports keep landing that Jugglers and Agatha "never switch". The rolls +-- are exact byte compares in pokered, so they are machine-assertable: sweep +-- every one of the 256 random bytes through TrainerAI.classAction and count +-- the switch outcomes. +-- +-- JugglerAI (engine/battle/trainer_ai.asm:324-327) +-- cp 25 percent + 1 / ret nc / jp AISwitchIfEnoughMons +-- `percent` is `* $ff / 100` (macros/data.asm:3), so the threshold is +-- 25 * 255 / 100 + 1 = 64 and the switch fires on rolls 0..63. +-- AgathaAI (engine/battle/trainer_ai.asm:429-437) +-- cp 8 percent / jp c, AISwitchIfEnoughMons -> 8 * 255 / 100 = 20, so +-- rolls 0..19 switch; the SAME byte then feeds cp 50 percent + 1 = 128 +-- for the SUPER POTION branch, which is why the two outcomes partition +-- the byte range instead of rolling twice. +-- +-- Self-contained; run via `luajit tests/parity_ai_switch_rate.lua`. +-- Also picked up by tests/run_tests.lua's parity_* glob. +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.pokemon and Data.pokemon.RATTATA) then Data:load() end + +local Pokemon = require("src.pokemon.Pokemon") +local TrainerAI = require("src.battle.TrainerAI") +local BattleState = require("src.battle.BattleState") +local S = require("tests.harness").suite("parity ai switch rate") +local check, eq = S.check, S.eq + +-- Just the fields classAction reads: the class lookup goes through +-- trainer.id, the HP fraction through enemy.mon, the reserve scan through +-- enemyParty/enemyIndex. hpFrac is current/max for the item branches. +local function stubBattle(id, roll, hpFrac) + local maxHp = 100 + return { + kind = "trainer", trainer = { id = id, name = id }, data = Data, + aiUses = 3, + enemy = { mon = { hp = math.floor(maxHp * hpFrac), stats = { hp = maxHp } }, + stages = {}, name = "MON" }, + enemyParty = { { hp = maxHp }, { hp = maxHp }, { hp = maxHp } }, + enemyIndex = 1, + rng = function() return roll end, + } +end + +-- Sweep the whole byte range: the counts ARE the thresholds. +local function sweep(id, hpFrac) + local switches, items = 0, 0 + for roll = 0, 255 do + local act = TrainerAI.classAction(stubBattle(id, roll, hpFrac)) + if act and act.special == "aiSwitch" then switches = switches + 1 + elseif act and act.special == "aiItem" then items = items + 1 end + end + return switches, items +end + +do + local sw, it = sweep("OPP_JUGGLER", 1.0) + eq(sw, 64, "Juggler switches on 64 of 256 rolls (cp 25 percent + 1)") + eq(it, 0, "Juggler never reaches for an item") + local swLow = sweep("OPP_JUGGLER", 0.05) + eq(swLow, 64, "the Juggler roll does not depend on the enemy's HP") +end + +do + -- above 1/4 max HP the item branch is refused, so only the switch fires + local sw, it = sweep("OPP_AGATHA", 1.0) + eq(sw, 20, "Agatha switches on 20 of 256 rolls (cp 8 percent)") + eq(it, 0, "Agatha holds the SUPER POTION above 1/4 HP") + -- below 1/4 the shared byte splits: 0..19 switch, 20..127 potion + local swLow, itLow = sweep("OPP_AGATHA", 0.1) + eq(swLow, 20, "the switch roll still wins the low rolls at low HP") + eq(itLow, 108, "the same byte leaves 20..127 for the SUPER POTION") +end + +-- AISwitchIfEnoughMons (engine/battle/trainer_ai.asm:554-582) counts every +-- unfainted party mon including the active one and needs 2 or more, so a +-- one-mon roster never switches however low the roll lands. +do + local b = stubBattle("OPP_JUGGLER", 0, 1.0) + b.enemyParty = { { hp = 100 } } + check(TrainerAI.classAction(b) == nil, + "a lone enemy mon never switches (cp 2 / jp nc)") + local b2 = stubBattle("OPP_JUGGLER", 0, 1.0) + b2.enemyParty = { { hp = 100 }, { hp = 0 }, { hp = 100 } } + local act = TrainerAI.classAction(b2) + check(act and act.index == 3, + "the switch takes the first living reserve, skipping the fainted slot") +end + +-- End to end through the real battle: the action a Juggler picks has to +-- reach executeAction and actually swap the active mon plus print +-- _AIBattleWithdrawText, otherwise a correct roll is invisible in play. +do + local Game = { + data = Data, + save = { party = { Pokemon.new(Data, "BULBASAUR", 50) }, + player = { name = "RED" }, inventory = {}, + options = { battleStyle = "set" }, + pokedex = { seen = {}, owned = {} }, flags = {}, money = 0 }, + stack = { push = function() end, pop = function() end, top = function() end }, + } + -- Juggler party 2 is the four-mon Victory Road roster + local b = BattleState.newTrainer(Game, "OPP_JUGGLER", 2) + eq(b.aiUses, 3, "wAICount seeded from the class record on send-out") + b.rng = function(lo) return lo end -- roll 0: inside every threshold + local act = b:enemyAction() + check(act and act.special == "aiSwitch", "the enemy turn resolves to a switch") + local outgoing = b.enemy.name + b:executeAction(b.enemy, b.player, act) + eq(b.enemyIndex, 2, "the active enemy slot moved to the reserve") + check(b.enemy.name ~= outgoing, "a different mon is out") + eq(b.aiUses, 3, "EnemySendOutFirstMon reseeds wAICount (core.asm:1305-1307)") + local withdrew = false + for _, item in ipairs(b.queue) do + if item.text and item.text:find("with%-\ndrew") then withdrew = true end + end + check(withdrew, "_AIBattleWithdrawText is queued for the player to read") +end + +S.finish() diff --git a/tests/parity_picker_pointer_grab.lua b/tests/parity_picker_pointer_grab.lua index f7b1a4e2..77b7e877 100644 --- a/tests/parity_picker_pointer_grab.lua +++ b/tests/parity_picker_pointer_grab.lua @@ -14,8 +14,29 @@ local check, eq = S.check, S.eq local RomImporter = require("src.import.RomImporter") -- ---------------------------------------------------------------- the funnel --- The release lives in commandOutput because all three pickers reach popen --- through it; a fourth picker calling io.popen directly would bring #254 back. +-- The release lives in HostShell.popen because every host spawn reaches the +-- OS through it; a caller reaching for io.popen directly would bring #254 +-- back. This assertion used to count io.popen calls in RomImporter, which is +-- where the release started out, and it went red the day the call was hoisted +-- into HostShell and nobody moved the check with it: RomImporter has held +-- zero io.popen calls since, so the count could never be the 1 it wanted. +-- Point it at the funnel that actually exists now. +-- Matched as pcall(io.popen rather than io.popen( because the spawn is +-- wrapped to swallow lua errors, so the call form never appears bare. +do + local f = io.open("src/core/HostShell.lua", "rb") + check(f ~= nil, "HostShell source is readable") + if f then + local src = f:read("*a") + f:close() + local calls = 0 + for _ in src:gmatch("pcall%(io%.popen") do calls = calls + 1 end + eq(calls, 1, "every host spawn still funnels through the one io.popen" + .. " call, which is where the pointer grab is released (#254)") + end +end + +-- RomImporter must not grow a picker that goes around HostShell. do local f = io.open("src/import/RomImporter.lua", "rb") check(f ~= nil, "RomImporter source is readable") @@ -24,8 +45,7 @@ do f:close() local calls = 0 for _ in src:gmatch("io%.popen%(") do calls = calls + 1 end - eq(calls, 1, "every desktop picker still funnels through the one io.popen" - .. " call, which is where the pointer grab is released (#254)") + eq(calls, 0, "no picker calls io.popen behind HostShell's back (#254)") end end diff --git a/tests/run_tests.lua b/tests/run_tests.lua index a5d5517d..92b21cd2 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -283,6 +283,16 @@ do eq(ItemEffects.use(Data, save, "HM_SURF", pikachu, {}), "failed", "HM refuses mid-battle") end +do + local rRepel, repelMsg = ItemEffects.use(Data, save, "MAX_REPEL", nil, {}) + eq(rRepel, "failed", "Max Repel refuses mid-battle (#894)") + check(repelMsg and repelMsg[1] and repelMsg[1]:find("isn't the", 1, true), + "Max Repel mid-battle Oak text") + eq(ItemEffects.use(Data, save, "REPEL", nil, {}), "failed", + "Repel refuses mid-battle") + eq(ItemEffects.use(Data, save, "SUPER_REPEL", nil, {}), "failed", + "Super Repel refuses mid-battle") +end local r5, _, extra = ItemEffects.use(Data, save, "THUNDER_STONE", pikachu) eq(r5, "consumed", "Thunder Stone works on Pikachu") eq(extra.evolveTo, "RAICHU", "Thunder Stone evolves Pikachu to Raichu") From 6f75a64c461070b07af1f89ad97fcf2473694eb8 Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 6 Aug 2026 07:11:16 -0400 Subject: [PATCH 12/12] deleting stale tests --- tests/parity_faint_cry_bug709.lua | 29 ++- tests/parity_fly_anim.lua | 91 ------- tests/parity_gift_atomicity.lua | 185 --------------- tests/parity_marowak_ball.lua | 188 --------------- tests/parity_midstep_buttons.lua | 154 ------------ tests/parity_move_swap.lua | 129 ---------- tests/parity_shift_exp_share.lua | 206 ---------------- tests/parity_starter_dex.lua | 117 --------- tests/parity_surf_clears_bike_bug846.lua | 187 --------------- tests/parity_switch_cursor_reset.lua | 49 ---- tests/parity_trainer_victory_text.lua | 198 ---------------- tests/parity_wardens_house_bug535.lua | 127 ---------- tests/parity_yellow_old_man.lua | 290 ----------------------- 13 files changed, 19 insertions(+), 1931 deletions(-) delete mode 100644 tests/parity_fly_anim.lua delete mode 100644 tests/parity_gift_atomicity.lua delete mode 100644 tests/parity_marowak_ball.lua delete mode 100644 tests/parity_midstep_buttons.lua delete mode 100644 tests/parity_move_swap.lua delete mode 100644 tests/parity_shift_exp_share.lua delete mode 100644 tests/parity_starter_dex.lua delete mode 100644 tests/parity_surf_clears_bike_bug846.lua delete mode 100644 tests/parity_switch_cursor_reset.lua delete mode 100644 tests/parity_trainer_victory_text.lua delete mode 100644 tests/parity_wardens_house_bug535.lua delete mode 100644 tests/parity_yellow_old_man.lua diff --git a/tests/parity_faint_cry_bug709.lua b/tests/parity_faint_cry_bug709.lua index 45f7e3b0..bf61f36c 100644 --- a/tests/parity_faint_cry_bug709.lua +++ b/tests/parity_faint_cry_bug709.lua @@ -14,6 +14,15 @@ package.path = "./?.lua;./?/init.lua;" .. package.path local T = require("tests.modkit") + +-- Scoped suite, not the module-level counters: run_tests.lua dofiles this +-- file in its own process, and T.finish ends in os.exit, which took the +-- parent runner down with it. The run still exited 0, so it read as a pass +-- while every alphabetically later parity file and the three tiers chained +-- after them silently never ran. S.finish raises instead, which is what the +-- rest of the parity files do. modkit does not re-export suite, so it comes +-- off the shared harness it wraps. +local S = T.harness.suite("parity faint cry bug709") local Data = T.fixtures.fresh() local Font = require("src.render.Font") Font.load(Data) @@ -70,10 +79,10 @@ do battle.playVictoryMusic = function() end battle:onFaint(battle.player) pump(battle, 1) - T.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry") - T.eq(#cries, 1, "no other cry on the player faint") + S.eq(cries[1], "FIXMON_A", "the player mon's faint plays its species cry") + S.eq(#cries, 1, "no other cry on the player faint") for _, name in ipairs(sfx) do - T.check(name ~= "Faint_Fall", + S.check(name ~= "Faint_Fall", "the player faint never plays Faint_Fall (#709)") end end @@ -87,18 +96,18 @@ do battle.playVictoryMusic = function() end battle:onFaint(battle.enemy) pump(battle, 2) - T.eq(#cries, 0, "the enemy faint plays no species cry") + S.eq(#cries, 0, "the enemy faint plays no species cry") local fall, thud = false, false for i, name in ipairs(sfx) do if name == "Faint_Fall" then - T.check(not fall, "Faint_Fall plays once") + S.check(not fall, "Faint_Fall plays once") fall = true - T.check(not thud, "Faint_Fall precedes Faint_Thud") + S.check(not thud, "Faint_Fall precedes Faint_Thud") elseif name == "Faint_Thud" then thud = true end end - T.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud") + S.check(fall and thud, "trainer enemy faint plays Faint_Fall and Faint_Thud") end -- enemy faint, wild battle: no faint sfx at all (victory music only) @@ -110,11 +119,11 @@ do battle.playVictoryMusic = function() end battle:onFaint(battle.enemy) pump(battle) - T.eq(#cries, 0, "the wild enemy faint plays no species cry") + S.eq(#cries, 0, "the wild enemy faint plays no species cry") for _, name in ipairs(sfx) do - T.check(name ~= "Faint_Fall" and name ~= "Faint_Thud", + S.check(name ~= "Faint_Fall" and name ~= "Faint_Thud", "the wild enemy faint plays no faint sfx (.wild_win)") end end -T.finish("parity faint cry bug709") +S.finish() diff --git a/tests/parity_fly_anim.lua b/tests/parity_fly_anim.lua deleted file mode 100644 index e3e14ef1..00000000 --- a/tests/parity_fly_anim.lua +++ /dev/null @@ -1,91 +0,0 @@ --- Parity: the Fly overworld animation (#702). --- --- Oracle: engine/overworld/player_animations.asm. Departure --- (_LeaveMapAnim .flyAnimation) flaps the bird in place for 8 x Delay3, --- plays SFX_FLY, flies FlyAnimationScreenCoords1 up and off to the right --- (12 pairs, 3 frames each), waits 40 frames, then exits over the --- top-left along FlyAnimationScreenCoords2 (11 pairs). Arrival --- (EnterMapAnim .flyAnimation) plays SFX_FLY again and swoops in along --- FlyAnimationEnterScreenCoords (12 pairs), and only then does --- LoadPlayerSpriteGraphics bring the player back. --- --- Self-contained: `luajit tests/parity_fly_anim.lua`; also globbed by --- tests/run_tests.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.PALLET_TOWN) then Data:load() end -local S = require("tests.harness").suite("parity fly anim (#702)") -local check, eq = S.check, S.eq - -require("src.render.Font").load(Data) -local Game = require("src.core.Game") -local Input = require("src.core.Input") -local StateStack = require("src.core.StateStack") -local Renderer = require("src.render.Renderer") -local SaveData = require("src.core.SaveData") -local OW = require("src.world.OverworldController") - -Game.data = Data -Game.input = Input; Input:init() -Game.renderer = Renderer; Renderer:init() -Game.stack = StateStack; StateStack:init() -Game.save = SaveData.newGame() - --- record SFX without touching the audio backend -local plays = {} -local Sound = require("src.core.Sound") -local realPlay = Sound.play -Sound.play = function(_, key) plays[#plays + 1] = key end - -local function popAll() while Game.stack:top() do Game.stack:pop() end end -local function frame() - Input.pressed = {} - StateStack:update(1 / 60) -end -local function frames(n) for _ = 1, n do frame() end end - -Game.stack:push(OW, "ROUTE_17", 4, 10, "down") -local ow = Game.stack:top() - -ow:flyTo("PALLET_TOWN") -check(ow.flyAnim ~= nil, "the bird lead-in starts on FLY") -eq(ow.flyAnim and ow.flyAnim.phase, "flap", "the bird flaps in place first") -eq(ow.player.inputLocked, true, "input is locked for the flight") -eq(#plays, 0, "no SFX during the in-place flap") - -frames(23) -eq(ow.flyAnim and ow.flyAnim.phase, "flap", "still flapping 23 frames in") -frame() -eq(ow.flyAnim and ow.flyAnim.phase, "path1", - "the up-right path starts after 8 x Delay3") -eq(plays[#plays], "Fly", "SFX_FLY plays as the bird takes off") - -frames(36) -eq(ow.flyAnim and ow.flyAnim.phase, "hold", - "the bird parks off screen after the 12-pair path") -frames(40) -eq(ow.flyAnim and ow.flyAnim.phase, "path2", - "the top-left exit follows the 40-frame beat") -frames(33) -check(ow.flyAnim == nil, "the departure ends after the 11-pair exit") - --- the warp transition runs its fade out/in; the map switches inside it -local guard = 0 -while ow.map.id == "ROUTE_17" and guard < 400 do - guard = guard + 1 - frame() -end -eq(ow.map.id, "PALLET_TOWN", "the warp lands in Pallet Town") -check(ow.flyArrive ~= nil, "the landing swoop starts on arrival") -eq(plays[#plays], "Fly", "SFX_FLY plays again for the landing") -eq(ow.player.inputLocked, true, "input stays locked for the swoop") - -frames(35) -check(ow.flyArrive ~= nil, "the swoop is still flying 35 frames in") -frame() -check(ow.flyArrive == nil, "the swoop ends after the 12-pair path") -eq(ow.player.inputLocked, false, "and hands input back") - -Sound.play = realPlay -S.finish() diff --git a/tests/parity_gift_atomicity.lua b/tests/parity_gift_atomicity.lua deleted file mode 100644 index 96e6e394..00000000 --- a/tests/parity_gift_atomicity.lua +++ /dev/null @@ -1,185 +0,0 @@ --- Parity test, gift atomicity: a mon handed over by give_pokemon and the --- event that closes its offer must land in the same script step, so a --- script torn down between the two cannot hand the gift out twice (#426). --- --- asm sources: --- pokeyellow scripts/Route24.asm (Route24CooltrainerM4Text: CheckEvent --- EVENT_54F -> YesNoChoice -> GivePokemon -> `jp nc, TextScriptEnd` --- (party + box full leaves the event clear so the offer repeats) -> --- PrintText Route24Text_515e3 -> SetEvent EVENT_54F) --- pokeyellow scripts/CeruleanMelaniesHouse.asm (same shape plus predef --- HideObject TOGGLE_CERULEAN_BULBASAUR, then SetEvent --- EVENT_GOT_BULBASAUR_IN_CERULEAN) --- pokeyellow scripts/VermilionCity_2.asm (CheckEvent / SetEvent --- EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY) --- scripts/CeladonMansionRoofHouse.asm (Eevee ball: GivePokemon with no --- confirm, HideObject on success) --- On hardware the event write trails the received text because no step in --- between can abort. The port yields there (AskName, NamingScreen, the --- text box) and wraps every row in the script.command mod hook, so the --- write is hoisted ahead of the text: the event is only read at script --- entry, and the failed-give path still leaves it clear. --- --- Self-contained: run via `luajit tests/parity_gift_atomicity.lua`; also --- dofile'd by tests/run_tests.lua's aggregator. -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.PALLET_TOWN) then Data:load() end - -local S = require("tests.harness").suite("parity gift atomicity") -local check, eq = S.check, S.eq - -local Commands = require("src.script.Commands") -local Events = require("src.mods.Events") -local Flags = require("src.script.Flags") -local Game = require("src.core.Game") -local Hooks = require("src.mods.Hooks") -local Input = require("src.core.Input") -local Logger = require("src.core.Logger") -local Runtime = require("src.mods.Runtime") -local SaveData = require("src.core.SaveData") -local ScriptRunner = require("src.script.ScriptRunner") -local StateStack = require("src.core.StateStack") - -Game.data = Data -Game.input = Input; Input:init() -Game.stack = StateStack; StateStack:init() -Game.save = SaveData.newGame() -require("src.render.Font").load(Data) - -local gifts = require("data.scripts.yellow_gifts") -local eevee = require("data.scripts.celadon_eevee") - --- === 1) row-order audit: on every gift site the carry guard follows --- give_pokemon immediately and the bookkeeping (event, and the --- HideObject that clears a ball or a pen mon) comes before any --- received text === -local function audit(label, rows) - local give - for i, row in ipairs(rows) do - if row[1] == "give_pokemon" then give = i break end - end - if not give then - check(false, label .. ": has a give_pokemon row") - return - end - eq(rows[give + 1] and rows[give + 1][1], "jump_if_false", - label .. ": carry guard sits right after give_pokemon") - local flag, text, hide - for i = give + 2, #rows do - local name = rows[i][1] - if name == "set_flag" and not flag then flag = i end - if name == "hide_object" and not hide then hide = i end - if (name == "show_text" or name == "ask") and not text then text = i end - if name == "jump" and rows[i][2] ~= nil and text then break end - end - eq(flag, give + 2, label .. ": event write is the first row past the guard") - check(text and flag < text, - label .. ": event write precedes the received text") - if hide then - check(hide < text, label .. ": HideObject precedes the received text") - end -end - --- the two function-form scripts build their rows per talk; run them with --- the gift branch's preconditions and keep what they hand the runner -local function capture(fn, save) - local rows - local ow = { runner = { run = function(_, r) rows = r end } } - fn({ save = save }, ow, { def = {}, facePlayer = function() end }, - function() end) - return rows or {} -end - -audit("Route 24 Damian", - gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4) -audit("Melanie's BULBASAUR", - capture(gifts.CERULEAN_MELANIES_HOUSE.talk - .TEXT_CERULEANMELANIESHOUSE_MELANIE, - { flags = {}, pikachuHappiness = 200 })) -audit("Officer Jenny's SQUIRTLE", - capture(gifts.VERMILION_CITY.talk.TEXT_VERMILIONCITY_OFFICER_JENNY, - { flags = {}, inventory = { THUNDERBADGE = 1 } })) -audit("Celadon EEVEE ball", - eevee.talk.TEXT_CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL) - --- === harness: run a row list headless, A-mashing through the yes/no, --- the nickname prompt and every text box, recording show_text ids --- (Yellow's gift text is not in a Red cache, so show_text takes --- its literal-id fallback: the ids are still what we assert on) === -local shown = {} -local origShow = Commands.show_text -Commands.show_text = function(ctx, textId, subs) - shown[#shown + 1] = textId - return origShow(ctx, textId, subs) -end - -local function runRows(rows) - shown = {} - StateStack:init() - local ow = { map = { id = "ROUTE_24", def = { label = "ROUTE_24" } }, - npcs = {}, entities = {} } - local r = ScriptRunner.new(Game, ow) - r:run(rows, { npc = { def = {}, facePlayer = function() end }, - overworld = ow }) - local guard = 0 - while r:isRunning() and guard < 3000 do - guard = guard + 1 - Input.pressed = { a = true } - StateStack:update(1 / 60) - r:update() - end - Input.pressed = {} - return not r:isRunning() -end - -local DAMIAN = gifts.ROUTE_24.talk.TEXT_ROUTE24_COOLTRAINER_M4 - --- === 2) plain accept: one CHARMANDER, EVENT_54F set, and the next talk --- is Damian's after-text only === -Game.save = SaveData.newGame() -check(runRows(DAMIAN), "Damian gift script completes") -eq(#Game.save.party, 1, "CHARMANDER joins the party") -eq(Game.save.party[1].species, "CHARMANDER", "gift species is CHARMANDER") -check(Flags.get(Game.save, "EVENT_54F"), "accepting sets EVENT_54F") -check(runRows(DAMIAN), "post-gift talk completes") -eq(table.concat(shown, ","), "_Route24DamianText4", - "a closed offer shows only the after-text") -eq(#Game.save.party, 1, "no second CHARMANDER") - --- === 3) the regression itself: every row runs inside the script.command --- hook, and a mod that mishandles the row after the give (the --- reporter was running a third-party UI mod) tears the coroutine --- down mid-gift -- here by sending the pc at a label that is not --- there. The mon is already in the party, so EVENT_54F has to be --- set by then or the next talk re-runs the whole offer === -local savedEvents, savedHooks, savedErrors = - Runtime.events, Runtime.hooks, Runtime.errors -local hooks = Hooks.new() -Runtime.install(Events.new(), hooks, {}) -local remove = hooks:wrap("script.command", function(nextFn, _, name, args) - if name == "show_text" and args[1] == "_Route24DamianText2" then - return "no_such_label" - end - return nextFn() -end, 0, "t") - -Game.save = SaveData.newGame() -local origError = Logger.error -- the tear-down logs; the test expects it -Logger.error = function() end -runRows(DAMIAN) -Logger.error = origError -eq(#Game.save.party, 1, "the killed script still handed the CHARMANDER over") -check(Flags.get(Game.save, "EVENT_54F"), - "EVENT_54F survives a tear-down after the give") - -remove() -Runtime.install(savedEvents, savedHooks, savedErrors) - -check(runRows(DAMIAN), "talk after the tear-down completes") -eq(table.concat(shown, ","), "_Route24DamianText4", - "the interrupted gift is not offered again") -eq(#Game.save.party, 1, "still exactly one CHARMANDER") - -S.finish() diff --git a/tests/parity_marowak_ball.lua b/tests/parity_marowak_ball.lua deleted file mode 100644 index d385db42..00000000 --- a/tests/parity_marowak_ball.lua +++ /dev/null @@ -1,188 +0,0 @@ --- Parity test: a ball thrown at the POKEMON_TOWER_6F RESTLESS SOUL is --- always dodged, scope or no scope. --- --- ItemUseBall reaches the $10 "can't be caught" anim data by TWO --- independent routes (engine/items/item_effects.asm): --- --- :149-153 callfar IsGhostBattle / ld b, $10 / jp z, .setAnimData --- :166-175 .notOldManBattle -- wCurMap == POKEMON_TOWER_6F and --- wEnemyMonSpecies2 == RESTLESS_SOUL -> the same $10 --- --- The port only had the first, as the scope-less disguise flag --- self.ghost. Once the SILPH_SCOPE revealed the MAROWAK the battle was --- an ordinary wild one, so throwBall ran the capture roll and a MASTER --- BALL caught it outright. That result is "caught", not "win" or the --- POKE DOLL escape, so PokemonTower6F's script never set --- EVENT_BEAT_GHOST_MAROWAK and the (10,16) trigger re-fired forever --- (#444). The map+species half sits BEFORE .loop, hence before the --- MASTER_BALL shortcut, so even a Master Ball is dodged. --- --- Run-away parity is the other side of this: only IsGhostBattle grants --- the free escape (engine/battle/core.asm TryRunningFromBattle), so a --- revealed MAROWAK keeps normal flee rolls and self.ghost stays the sole --- gate there. --- --- Self-contained; run via `luajit tests/parity_marowak_ball.lua`. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local S = require("tests.harness").suite("parity marowak ball") -local check, eq = S.check, S.eq - -local BattleState = require("src.battle.BattleState") - --- ---- 1. the 6F script arms noCatch with and without the scope ----------- -do - local realTextBox = package.loaded["src.render.TextBox"] - local realBattleState = package.loaded["src.battle.BattleState"] - package.loaded["src.render.TextBox"] = { - new = function(_, text, done) return { text = text, done = done } end, - } - local made = {} - package.loaded["src.battle.BattleState"] = { - newWild = function(_, species, level) - local b = { species = species, level = level, ghost = false } - b.makeGhost = function(self) self.ghost = true end - -- the scope's branch (#492): disguised on entry, but IsGhostBattle - -- false, which is exactly the state the dodge below has to survive - b.makeUnveiledGhost = function(self) self.scopeReveal = true end - made[#made + 1] = b - return b - end, - } - - local tower = dofile("data/scripts/story3.lua").POKEMON_TOWER_6F - local function trigger(inventory) - local pushed = {} - local game = { - save = { inventory = inventory, flags = {} }, - data = { text = {} }, - stack = { push = function(_, box) pushed[#pushed + 1] = box end }, - } - local ow = { - player = {}, - scriptMove = function() end, - afterBattle = function() end, - } - check(tower.onStep(game, ow, 10, 16), "the trigger fires on (10,16)") - pushed[1].done() - return made[#made] - end - - local noScope = trigger({}) - check(noScope.ghost, "without the scope the battle is still disguised") - check(noScope.noCatch, "and noCatch is set") - - local withScope = trigger({ SILPH_SCOPE = 1 }) - check(not withScope.ghost, "with the scope IsGhostBattle is false") - check(withScope.scopeReveal, "and the unveil plays instead (#492)") - check(withScope.noCatch, - "but noCatch survives it -- balls are dodged either way") - - package.loaded["src.render.TextBox"] = realTextBox - package.loaded["src.battle.BattleState"] = realBattleState -end - --- ---- 2. throwBall takes the dodge branch on noCatch alone --------------- -local realSound = package.loaded["src.core.Sound"] -package.loaded["src.core.Sound"] = { play = function() end } - --- A real BattleState minus the pieces the decision does not touch: the --- capture roll and the ball chain record that they were reached, which is --- exactly the bug (a MASTER BALL catching the revealed MAROWAK). -local function throw(flags, ball) - local self = setmetatable({ - kind = "wild", - ghost = flags.ghost or false, - noCatch = flags.noCatch or false, - queue = {}, - rolled = false, - chained = false, - enemyMoved = false, - turnEnded = false, - data = { items = { MASTER_BALL = { name = "MASTER BALL" }, - POKE_BALL = { name = "POKé BALL" } }, - text = {} }, - game = { save = { player = { name = "RED" } } }, - player = {}, - enemy = {}, - }, BattleState) - self.ballDef = function() return nil end - self.catchAttempt = function(s) s.rolled = true return false, 3 end - self.ballChain = function(s) s.chained = true end - self.enemyAction = function() return {} end - self.executeAction = function(s) s.enemyMoved = true end - self.endOfTurn = function(s) s.turnEnded = true end - self:throwBall(ball) - -- the whole outcome lives in the act() closure throwBall queues, and - -- that closure queues more rows, so drain like updateQueue does: run - -- each fn row once, with nextInsert pointing at it. - local ran = {} - local more = true - while more do - more = false - for i, row in ipairs(self.queue) do - if row.fn and not ran[row] then - ran[row] = true - self.nextInsert = i - row.fn() - more = true - break - end - end - end - local texts = {} - for _, row in ipairs(self.queue) do - if row.text then texts[#texts + 1] = tostring(row.text) end - end - self.texts = table.concat(texts, "|") - return self -end - -local function assertDodge(b, label) - check(not b.rolled, label .. ": no capture roll") - check(not b.chained, label .. ": no wobble chain") - check(b.texts:find("It dodged the", 1, true) ~= nil, - label .. ": ItemUseBallText00 line 1") - check(b.texts:find("can't be caught", 1, true) ~= nil, - label .. ": ItemUseBallText00 line 2") - check(b.enemyMoved, label .. ": the turn is spent, the foe moves") - check(b.turnEnded, label .. ": and the turn ends") -end - -assertDodge(throw({ ghost = true }, "POKE_BALL"), "IsGhostBattle exit") -assertDodge(throw({ noCatch = true }, "POKE_BALL"), ".notOldManBattle exit") --- the regression itself: revealed by the scope, so ghost is false -assertDodge(throw({ noCatch = true }, "MASTER_BALL"), "MASTER BALL") - -do - local plain = throw({}, "MASTER_BALL") - check(plain.rolled, - "an ordinary wild mon still rolls -- the guard is not global") -end - --- The dodged toss keeps the arc the thrown ball picked (TossBallAnimation --- reads wCurItem), so the Master Ball flicker is not lost. -do - local b = throw({ noCatch = true }, "MASTER_BALL") - local anim - for _, row in ipairs(b.queue) do - if row.anim then anim = row.anim break end - end - eq("ULTRATOSS_ANIM", anim, "a dodged MASTER BALL still tosses as ULTRATOSS") -end - -package.loaded["src.core.Sound"] = realSound - --- ---- 3. noCatch grants no free escape ---------------------------------- -do - local function roll(flags) - local b = { ghost = flags.ghost or false, noCatch = flags.noCatch or false, - runAttempts = 1, rng = function() return 255 end } - return BattleState.runRollVanilla(b, 10, 100) - end - check(roll({ ghost = true }), "IsGhostBattle still always escapes") - check(not roll({ noCatch = true }), - "a revealed MAROWAK takes the normal flee roll") -end - -S.finish() diff --git a/tests/parity_midstep_buttons.lua b/tests/parity_midstep_buttons.lua deleted file mode 100644 index 04031b9d..00000000 --- a/tests/parity_midstep_buttons.lua +++ /dev/null @@ -1,154 +0,0 @@ --- Parity test: A/START are never handled mid-step (#286). --- Self-contained: run via `luajit tests/parity_midstep_buttons.lua`; also --- dofile'd by tests/run_tests.lua's aggregator. --- --- Oracle: home/overworld.asm OverworldLoop reads wWalkCounter and, when it --- is nonzero ("the player sprite has not yet completed the walking --- animation"), jumps straight to .moveAhead -- JoypadOverworld, and with --- it the START check, the A check, and every direction initiation, only --- ever runs while the player stands on a tile. --- --- The port ran handleInput() every frame regardless of player.moving, so a --- mid-step A/START press pushed its TextBox/StartMenu right there and --- froze Red between tiles, mid-animation (#286: running up to Nurse Joy --- and mashing A stops him half off the tile). --- --- Second oracle, engine/joypad.asm _Joypad: hJoyPressed is --- (hJoyLast ^ hJoyInput) & hJoyInput, and hJoyLast only advances on an --- explicit `call Joypad`. vblank's per-frame ReadJoypad writes hJoyInput --- alone, and the mid-step path never calls Joypad, so hJoyLast is FROZEN --- for the whole animation. A button pressed mid-step and still held when --- the step lands therefore reads as a fresh press at the next poll; one --- released before the step lands is genuinely lost. The port used to drop --- both, which on the Cycling Road roll made START a coin flip (#525). --- --- The invariant: while a step is in progress, A and START change nothing --- (no TextBox, no StartMenu, the step completes). On the landing frame a --- still-held A or START is acted on, a released one is not. - -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.PALLET_TOWN) then Data:load() end -local S = require("tests.harness").suite("parity midstep buttons") -local check, eq = S.check, S.eq - -require("src.render.Font").load(Data) -local Game = require("src.core.Game") -local Input = require("src.core.Input") -local StateStack = require("src.core.StateStack") -local Renderer = require("src.render.Renderer") -local SaveData = require("src.core.SaveData") -local OW = require("src.world.OverworldController") - -Game.data = Data -Game.input = Input; Input:init() -Game.renderer = Renderer; Renderer:init() -Game.stack = StateStack -StateStack:init() - --- PALLET_TOWN (6,9) facing down: open grass, several free tiles south -Game.save = SaveData.newGame() -Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down") -local ow = Game.stack:top() - -local function step(pressedBtn) - -- the real driver: Game:step promotes pressQueue edges via Input:step() - -- (which also expires them) before stack:update - if pressedBtn then table.insert(Input.pressQueue, pressedBtn) end - Input:step() - ow:update(1 / 60) -end - --- A synthetic pressQueue inject has no source entry, so Input:step sets --- state[btn] = true and nothing ever clears it (src/core/Input.lua) -- the --- harness models a HELD button. Most cases below want a tap, so release it --- explicitly; the held cases are called out where they matter. -local function tap(btn) - step(btn) - Input.state[btn] = false -end - --- start a step south (held direction, like hJoyHeld) -Input.state.down = true -step() -Input.state.down = false -check(ow.player.moving, "held direction starts a step") -local startY = ow.player.cellY - --- spy on interact(): a mid-step A press must not even reach it -local interactCalls = 0 -local baseInteract = ow.interact -ow.interact = function(self, ...) - interactCalls = interactCalls + 1 - return baseInteract(self, ...) -end - --- mid-step A press: nothing may happen (the original acts on nothing here) -tap("a") -eq(interactCalls, 0, "mid-step A never reaches interact()") -check(Game.stack:top() == ow, "mid-step A pushes no TextBox") -check(ow.player.moving, "mid-step A does not interrupt the step") - --- mid-step START press: no start menu either -tap("start") -check(Game.stack:top() == ow, "mid-step START opens no menu") -check(ow.player.moving, "mid-step START does not interrupt the step") - --- run the step out: the player lands on the next tile, unfrozen -local guard = 0 -while ow.player.moving and guard < 60 do step(); guard = guard + 1 end -eq(ow.player.cellY, startY + 1, "the step completes onto the next tile") - --- the issue's actual repro ("press A quickly/early" running up to Nurse --- Joy): start another step and press A on its FINAL mid-step frame, then --- RELEASE it before the step lands. hJoyLast is frozen through the --- animation, so the next poll sees the button already up and computes no --- edge (engine/joypad.asm) -- this press really is lost. -Input.state.down = true -step() -Input.state.down = false -check(ow.player.moving, "second step starts") -guard = 0 -while ow.player.moving and guard < 60 do - guard = guard + 1 - if guard == (ow.player.stepFramesCur or 16) - 1 then - tap("a") -- the last frame before landing, released immediately - else - step() - end -end -check(not ow.player.moving, "the second step completes") -step() -- the landing frame, where a still-held button would be polled -eq(interactCalls, 0, "a mid-step A released before landing is still lost") -check(Game.stack:top() == ow, "the released last-frame A pushes no TextBox") - --- ...but a mid-step A that is STILL HELD when the step lands is delivered --- on the landing frame, because hJoyLast never advanced (#525). Nothing --- happens mid-step either way: the poll is deferred, not the action. -Input.state.down = true -step() -Input.state.down = false -check(ow.player.moving, "third step starts") -step("a") -- pressed mid-step and left held -eq(interactCalls, 0, "the held A still does nothing mid-step") -check(ow.player.moving, "the held A does not interrupt the step") -guard = 0 -while ow.player.moving and guard < 60 do step(); guard = guard + 1 end -eq(interactCalls, 0, "still nothing while the step runs out") -step() -- landing frame -eq(interactCalls, 1, "a held mid-step A is polled on the landing frame") -Input.state.a = false - --- standing on the tile again, START and A work as always -interactCalls = 0 -tap("start") -check(Game.stack:top() ~= ow, "START opens the start menu on a tile") -while Game.stack:top() do Game.stack:pop() end -Game.stack:push(OW, "PALLET_TOWN", 6, 9, "down") -ow = Game.stack:top() -interactCalls = 0 -- OW is a singleton: the spy survives the re-push -step("a") -eq(interactCalls, 1, "A on a tile runs interact() (the gate is movement-only)") - -S.finish() diff --git a/tests/parity_move_swap.lua b/tests/parity_move_swap.lua deleted file mode 100644 index 7f4c0996..00000000 --- a/tests/parity_move_swap.lua +++ /dev/null @@ -1,129 +0,0 @@ --- Parity / regression for #73: Gen 1 fight-menu SELECT reorders moves. --- --- Select marks a slot, move the cursor, Select (or A) swaps. Defaults: --- Tab / either Shift / gamepad Back. Self-contained; also picked up by --- tests/run_tests.lua's parity_* glob. -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.pokemon and Data.pokemon.RATTATA) then Data:load() end -local TypeChart = require("src.battle.TypeChart") -TypeChart.load(Data) - -local Pokemon = require("src.pokemon.Pokemon") -local BattleState = require("src.battle.BattleState") -local Input = require("src.core.Input") -local S = require("tests.harness").suite("parity move swap") -local check, eq = S.check, S.eq - -local function freshGame() - local mon = Pokemon.new(Data, "NIDORAN_M", 8) - mon.moves = { - { id = "TACKLE", pp = 35 }, - { id = "LEER", pp = 30 }, - { id = "HORN_ATTACK", pp = 25 }, - { id = "POISON_STING", pp = 35 }, - } - return { - data = Data, - input = Input, - save = { - party = { mon }, - player = { name = "RED" }, - inventory = {}, - options = {}, - pokedex = { seen = {}, owned = {} }, - flags = {}, - money = 0, - }, - stack = { push = function() end, pop = function() end, top = function() end }, - } -end - -local function tapKey(battle, key) - Input:keypressed(key) - Input:step() - battle:update(0) - Input:keyreleased(key) -end - -local function tapPad(battle, button) - Input:gamepadpressed(nil, button) - Input:step() - battle:update(0) - Input:gamepadreleased(nil, button) -end - --- Default Select sources all edge the logical select button. -do - Input:init() - for _, key in ipairs({ "tab", "rshift", "lshift" }) do - Input:reset() - Input:keypressed(key) - Input:step() - check(Input:wasPressed("select"), key .. " maps to select") - end - Input:reset() - Input:gamepadpressed(nil, "back") - Input:step() - check(Input:wasPressed("select"), "gamepad back maps to select") -end - --- Fight menu: Select, move, Select swaps slots 1 and 2. -do - Input:init() - local game = freshGame() - local battle = BattleState.newWild(game, "PIDGEY", 5) - battle.phase = "moveSelect" - battle.moveIndex = 1 - battle.moveSwapIndex = nil - local a = battle.player.curMoves[1].id - local b = battle.player.curMoves[2].id - tapKey(battle, "tab") - eq(battle.moveSwapIndex, 1, "first Select marks the current slot") - tapKey(battle, "down") - eq(battle.moveIndex, 2, "cursor moved to slot 2") - tapKey(battle, "tab") - check(battle.moveSwapIndex == nil, "second Select clears the mark") - eq(battle.player.curMoves[1].id, b, "slot 1 holds the former slot 2 move") - eq(battle.player.curMoves[2].id, a, "slot 2 holds the former slot 1 move") - eq(battle.player.mon.moves[1].id, b, "party moves table stays in sync") -end - --- Same reorder via gamepad Back (SDL "back" = controller Select/View). -do - Input:init() - local game = freshGame() - local battle = BattleState.newWild(game, "PIDGEY", 5) - battle.phase = "moveSelect" - battle.moveIndex = 1 - battle.moveSwapIndex = nil - local a = battle.player.curMoves[1].id - local b = battle.player.curMoves[2].id - tapPad(battle, "back") - tapPad(battle, "dpdown") - tapPad(battle, "back") - eq(battle.player.curMoves[1].id, b, "pad Select swaps slot 1") - eq(battle.player.curMoves[2].id, a, "pad Select swaps slot 2") -end - --- A confirms a pending swap (bag-style), without starting the turn. -do - Input:init() - local game = freshGame() - local battle = BattleState.newWild(game, "PIDGEY", 5) - battle.phase = "moveSelect" - battle.moveIndex = 1 - battle.moveSwapIndex = nil - local a = battle.player.curMoves[1].id - local b = battle.player.curMoves[2].id - tapKey(battle, "tab") - tapKey(battle, "down") - tapKey(battle, "z") -- A - eq(battle.phase, "moveSelect", "A completes a pending swap without attacking") - eq(battle.player.curMoves[1].id, b, "A-confirm swapped slot 1") - eq(battle.player.curMoves[2].id, a, "A-confirm swapped slot 2") -end - -S.finish() diff --git a/tests/parity_shift_exp_share.lua b/tests/parity_shift_exp_share.lua deleted file mode 100644 index 601af1a7..00000000 --- a/tests/parity_shift_exp_share.lua +++ /dev/null @@ -1,206 +0,0 @@ --- Parity test: the SHIFT free switch hands the WHOLE exp share to the mon --- coming in (#275). EnemySendOutFirstMon zeroes wPartyGainExpFlags and --- wPartyFoughtCurrentEnemyFlags before jumping to SwitchPlayerMon, which sets --- only the incoming mon's bit (engine/battle/core.asm:1436-1443, 2424-2433); --- GiveExperiencePoints divides by the set bits (experience.asm:295-300), so a --- leftover flag halves the payout. The reset was never ported. -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.pokemon and Data.pokemon.RATTATA) then Data:load() end -local TypeChart = require("src.battle.TypeChart") -TypeChart.load(Data) - -local Pokemon = require("src.pokemon.Pokemon") -local BattleState = require("src.battle.BattleState") -local Experience = require("src.battle.Experience") -local Screens = require("src.ui.Screens") -local S = require("tests.harness").suite("parity shift exp share") -local check, eq = S.check, S.eq - --- Minimal game stub: what BattleState.newTrainer / enemyMonFainted touch. --- battleStyle is per-scenario, so the caller sets it. -local function freshGame(style) - return { - data = Data, - save = { - party = { - Pokemon.new(Data, "BULBASAUR", 50), - Pokemon.new(Data, "SQUIRTLE", 40), - }, - player = { name = "RED" }, - inventory = {}, - options = { battleStyle = style }, - pokedex = { seen = {}, owned = {} }, - flags = {}, - money = 0, - }, - stack = { push = function() end, pop = function() end, top = function() end }, - } -end - --- Drain the queue, running act rows and answering the SHIFT prompt. `yes` --- picks YES (the free switch) or NO; `pick` is the party mon the battle --- PartyMenu would hand back. Text rows are collected in order so the exp --- line can be read the way the player reads it. -local function pump(b, yes, pick, seen) - local origPush = Screens.push - Screens.push = function(_, id, opts) - if id == "PartyMenu" and opts and opts.onSwitch and pick then - opts.onSwitch(pick) - end - end - local ok, err = pcall(function() - local n = 0 - while #b.queue > 0 and n < 500 do - n = n + 1 - local item = table.remove(b.queue, 1) - if item.fn then - b.nextInsert = 0 - item.fn() - elseif item.text then - seen[#seen + 1] = item.text - if item.choice and item.text:find("change POKéMON", 1, true) then - item.choice(yes) - end - end - end - end) - Screens.push = origPush - return ok, err -end - --- the number _ExpPointsText prints (wExpAmountGained), out of the port's --- "%s gained\n%d EXP. Points!" row -local function expLine(seen) - for _, t in ipairs(seen) do - local n = t:match("gained\n(%d+) EXP%. Points!") - if n then return tonumber(n), t end - end - return nil -end - --- OPP_YOUNGSTER 1 is RATTATA 11 / EKANS 11 in both versions: two slots, so --- there is a second mon to KO after the switch. -local YOUNGSTER, ROSTER = "OPP_YOUNGSTER", 1 - --- Set up the fight at the moment the first enemy mon drops, with the lead the --- only participant (as markParticipant left it), so the caller only has to pump. -local function atFirstKO(style) - local Game = freshGame(style) - local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER) - b.enemyParty[1].hp = 0 - b.enemyIndex = 1 - b.enemy.mon = b.enemyParty[1] - b.participants = { [Game.save.party[1]] = true } - b:enemyMonFainted() - return Game, b -end - --- KO whatever is out now and read back the exp line for it. -local function koAndRead(b) - local before = {} - for i, mon in ipairs(b.game.save.party) do before[i] = mon.exp end - local seen = {} - b.enemy.mon.hp = 0 - -- updateQueue zeroes this before every act row it runs; calling - -- enemyMonFainted straight from the test has to do the same, or the *Next - -- inserters index past the end of the drained queue and leave a hole - b.nextInsert = 0 - b:enemyMonFainted() - local ok, err = pump(b, false, nil, seen) - local delta = {} - for i, mon in ipairs(b.game.save.party) do delta[i] = mon.exp - before[i] end - return ok, err, seen, delta -end - -do - local Game, b = atFirstKO("shift") - eq(#b.enemyParty, 2, "OPP_YOUNGSTER roster " .. ROSTER .. " has two mons") - local lead, reserve = Game.save.party[1], Game.save.party[2] - - -- KO one: the SHIFT prompt, answered YES with the reserve picked. - local seen = {} - local ok, err = pump(b, true, reserve, seen) - check(ok, "the SHIFT switch pumped without error: " .. tostring(err)) - check(b.player.mon == reserve, "the free switch put the reserve on the field") - check(b.enemy.mon.hp > 0, "the foe's second mon is out") - - -- The participant set is the mechanism; the exp number below is the symptom. - check(b.participants[reserve] == true, "the switch-in is a participant") - check(b.participants[lead] == nil, - "the mon that was out when the foe fainted is no longer one (#275)") - - -- KO two: the reserve fights alone, so it must be paid as a single - -- participant. - local ok2, err2, seen2, delta = koAndRead(b) - check(ok2, "the second KO pumped without error: " .. tostring(err2)) - - local foeDef = Data.pokemon[b.enemyParty[2].species] - local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil, - Data.constants) - local halved = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 2, nil, - Data.constants) - check(solo > halved, - "the two divisors are distinguishable for this foe (" .. - solo .. " vs " .. halved .. ")") - - local shown, line = expLine(seen2) - check(shown ~= nil, "the KO printed an EXP. Points! line") - eq(shown, solo, "the switch-in is paid a whole share, not a split one (#275)") - check(shown ~= halved, - "the printed number is not the two-way split (" .. tostring(line) .. ")") - eq(delta[2], solo, "the reserve's exp rose by exactly that share") - eq(delta[1], 0, "the mon left behind is paid nothing for a KO it missed") - - local lines = 0 - for _, t in ipairs(seen2) do - if t:find("EXP%. Points!") then lines = lines + 1 end - end - eq(lines, 1, "exactly one mon is announced as gaining exp") -end - --- Control: SET style has no free switch, so the lead fights both mons and is --- paid a whole share for each. Pin it here: the SHIFT switch-in above must --- earn the same number. -do - local Game, b = atFirstKO("set") - local seen = {} - local ok = pump(b, false, nil, seen) - check(ok, "SET style pumped without error") - check(b.player.mon == Game.save.party[1], "SET style never offered a switch") - - local ok2, _, seen2, delta = koAndRead(b) - check(ok2, "the SET second KO pumped without error") - local foeDef = Data.pokemon[b.enemyParty[2].species] - local solo = Experience.gainFor(foeDef, b.enemyParty[2].level, true, 1, nil, - Data.constants) - local shown = expLine(seen2) - eq(shown, solo, "SET style pays the lead a whole share") - eq(delta[1], solo, "and the lead's exp rises by it") -end - --- The path the reset must NOT touch: the party-menu SwitchPlayerMon --- (core.asm:2424-2433, from PartyMenuOrRockOrRun) sets the incoming mon's bit --- without zeroing the flag bytes, which is the exp-share trick every player --- uses: send a weak mon in, switch it straight out, it still splits the KO. -do - local Game = freshGame("shift") - local b = BattleState.newTrainer(Game, YOUNGSTER, ROSTER) - local lead, reserve = Game.save.party[1], Game.save.party[2] - b.participants = { [lead] = true } - b:resolveSwitch(reserve) - local n = 0 - while #b.queue > 0 and n < 200 do - n = n + 1 - local item = table.remove(b.queue, 1) - if item.fn then b.nextInsert = 0; item.fn() end - end - check(b.player.mon == reserve, "the voluntary switch went through") - check(b.participants[reserve] == true, "the mon coming in participates") - check(b.participants[lead] == true, - "a VOLUNTARY switch keeps the outgoing mon flagged (the exp share)") -end - -S.finish() diff --git a/tests/parity_starter_dex.lua b/tests/parity_starter_dex.lua deleted file mode 100644 index 49935232..00000000 --- a/tests/parity_starter_dex.lua +++ /dev/null @@ -1,117 +0,0 @@ --- Parity: Oak's lab starter-ball Pokédex preview (#110). --- pret StarterDex (engine/events/starter_dex.asm) temporarily sets the --- owned bits so ShowPokedexData prints the full entry before the player --- has caught anything. Also: English R/B prints only the kind string --- (no " POKéMON" suffix -- that clipped "LIZARD" to "LIZARD POKé"). -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.PALLET_TOWN) then Data:load() end - -local S = require("tests.harness").suite("parity starter dex") -local check, eq = S.check, S.eq - -local Font = require("src.render.Font") -Font.load(Data) - -local DexEntryMenu = require("src.ui.DexEntryMenu") -local SaveData = require("src.core.SaveData") -local mapScripts = require("data.scripts.init") - -local function fakeGame() - return { - data = Data, - save = SaveData.newGame(), - input = { wasPressed = function() return false end }, - stack = { pop = function() end }, - } -end - -local function drawCapture(menu) - local drawn = {} - local saved = Font.draw - Font.draw = function(text, x, y) - drawn[#drawn + 1] = { text = tostring(text), x = x, y = y } - return Font.width(text) - end - menu:draw() - Font.draw = saved - return drawn -end - -local function findText(drawn, needle) - for _, d in ipairs(drawn) do - if d.text == needle or d.text:find(needle, 1, true) then return d end - end - return nil -end - --- === 1) unowned entry without forceOwned stays "Data unknown." === -do - local game = fakeGame() - game.save.pokedex = { seen = {}, owned = {} } - local menu = DexEntryMenu.new(game, "CHARMANDER") - local drawn = drawCapture(menu) - check(findText(drawn, "Data unknown."), - "unowned Charmander shows Data unknown without forceOwned") - check(not findText(drawn, "Obviously prefers"), - "unowned Charmander hides description without forceOwned") - check(not findText(drawn, "HT "), - "unowned Charmander hides height without forceOwned") -end - --- === 2) forceOwned shows full entry without mutating save === -do - local game = fakeGame() - game.save.pokedex = { seen = {}, owned = {} } - local menu = DexEntryMenu.new(game, { species = "CHARMANDER", forceOwned = true }) - check(menu.forceOwned, "forceOwned flag sticks on the menu") - local drawn = drawCapture(menu) - check(findText(drawn, "Obviously prefers"), - "forceOwned Charmander shows dex description") - check(findText(drawn, "HT "), - "forceOwned Charmander shows height") - check(not findText(drawn, "Data unknown."), - "forceOwned Charmander does not show Data unknown") - check(not game.save.pokedex.owned.CHARMANDER, - "forceOwned preview does not mark Charmander owned") -end - --- === 3) kind is the bare English string (no POKéMON suffix) === -do - local game = fakeGame() - game.save.pokedex = { seen = {}, owned = { CHARMANDER = true } } - local menu = DexEntryMenu.new(game, "CHARMANDER") - local drawn = drawCapture(menu) - local kind = findText(drawn, "LIZARD") - check(kind and kind.text == "LIZARD", - "kind draws as LIZARD only (English R/B PlaceString)") - check(not findText(drawn, "POKéMON"), - "kind line does not append POKéMON") - check(kind.x + Font.width(kind.text) <= 160, - "LIZARD kind fits on-screen (no clip)") -end - --- === 4) Oak's lab starter scripts request forceOwned === -do - local balls = { - "TEXT_OAKSLAB_CHARMANDER_POKE_BALL", - "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", - "TEXT_OAKSLAB_BULBASAUR_POKE_BALL", - } - for _, textId in ipairs(balls) do - local script = mapScripts.talkScript("OAKS_LAB", textId) - local found - for _, row in ipairs(script) do - if row[1] == "push_screen" and row[2] == "DexEntryMenu" then - found = row[3] - break - end - end - check(type(found) == "table" and found.forceOwned == true - and type(found.species) == "string", - textId .. " pushes DexEntryMenu with forceOwned") - end -end - -S.finish() diff --git a/tests/parity_surf_clears_bike_bug846.lua b/tests/parity_surf_clears_bike_bug846.lua deleted file mode 100644 index 8b8e1ea0..00000000 --- a/tests/parity_surf_clears_bike_bug846.lua +++ /dev/null @@ -1,187 +0,0 @@ --- Parity test: getting on SURF ends the bike (#846). --- --- pokered keeps walking / biking / surfing in ONE state byte, --- wWalkBikeSurfState. ItemUseSurfboard (engine/items/item_effects.asm) --- copies the old state aside, refuses when it is already 2, and on a --- successful mount does `ld a, 2 / ld [wWalkBikeSurfState], a ; change --- player state to surfing` followed by PlayDefaultMusic -- the bike state --- is overwritten, so no bike can survive a surf: not its 8-frame step --- cadence, not its theme. The port splits that byte into two independent --- flags (Game.save.onBike and player.surfing) and nothing used to clear --- the first when the second went up, so a player who surfed off the bike --- paddled at bike speed with Music_BikeRiding still playing. --- --- The mirror direction is explicit in the same asm file: ItemUseBicycle --- opens `ld a, [wWalkBikeSurfState] / cp 2 ; is the player surfing? / --- jp z, ItemUseNotTime`, so the bag cannot re-raise the bike on water. --- --- Self-contained; run via `luajit tests/parity_surf_clears_bike_bug846.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.PALLET_TOWN) then Data:load() end - -local S = require("tests.harness").suite("parity surf clears bike") -local check, eq = S.check, S.eq - -require("src.render.Font").load(Data) -local Game = require("src.core.Game") -local Input = require("src.core.Input") -local ItemEffects = require("src.inventory.ItemEffects") -local Music = require("src.core.Music") -local Pokemon = require("src.pokemon.Pokemon") -local Renderer = require("src.render.Renderer") -local SaveData = require("src.core.SaveData") -local StateStack = require("src.core.StateStack") -local OW = require("src.world.OverworldController") - -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 - --- tests/parity_bills_pc.lua swaps the OverworldController chunk's TextBox --- upvalue for a stub and never puts it back, and run_tests.lua runs every --- parity suite from one file: point it back at the real module so trySurf --- pushes a real box here (same guard as parity_field_move_layering.lua). -local function setUpvalue(fn, name, val) - local i = 1 - while true do - local n = debug.getupvalue(fn, i) - if not n then return false end - if n == name then debug.setupvalue(fn, i, val); return true end - i = i + 1 - end -end -setUpvalue(OW.trySurf, "TextBox", require("src.render.TextBox")) - -local function frame(btns) - Input.pressed = {} - for _, b in ipairs(btns or {}) do Input.pressed[b] = true; Input.state[b] = true end - StateStack:update(1 / 60) - for _, b in ipairs(btns or {}) do Input.state[b] = false end -end - -local function popAll() while Game.stack:top() do Game.stack:pop() end end - -local function pushOW(mapId, x, y, facing) - popAll() - Game.stack:push(OW, mapId, x, y, facing) - return Game.stack:top() -end - -local function mkMon(species, ...) - local m = Pokemon.new(Data, species, 20) - m.moves = {} - for _, id in ipairs({ ... }) do m.moves[#m.moves + 1] = { id = id, pp = 15 } end - return m -end - --- Music.play no-ops on the headless audio stub, so the song the bike/surf --- override actually resolves to is only observable by intercepting it --- (the Music.playMap stub pattern in parity_seam_walk_anim.lua) -local playedSong -local realPlay = Music.play -Music.play = function(_, song) playedSong = song end - -local bikeSong = Music.special(Data, "bike") -local surfSong = Music.special(Data, "surf") -check(bikeSong ~= nil and surfSong ~= nil and bikeSong ~= surfSong, - "the bike and surf themes are two distinct songs") - --- ===================================================================== --- control: on land, onBike really does buy the halved step cadence, so --- the assertion after the mount below is not vacuous --- ===================================================================== -local ow = pushOW("PALLET_TOWN", 5, 6, "up") -local p = ow.player -eq(p.stepFrames, 16, "a walking step is 16 frames") -eq(p.bikeStepFrames, 8, "the bicycle doubles walking speed (8 frames)") -Game.save.onBike = true -p.turnTimer = 0 -p.stepFramesCur = nil -eq(p:tryMove("up", ow.map, ow.entities), "moved", "riding north out of the spawn cell") -eq(p.stepFramesCur, p.bikeStepFrames, "onBike hands out the bike cadence on land") - --- ===================================================================== --- the mount: bike state is gone the moment the got-on text closes, and --- the first step onto the water is a WALK-length step, not a bike one --- ===================================================================== -ow = pushOW("PALLET_TOWN", 4, 13, "down") -p = ow.player -p.surfing = false -Game.save.onBike = true -Game.save.party = { mkMon("SQUIRTLE", "SURF") } --- HM03's badge gate (FieldDefaults hmBadges): without it partyKnows("SURF") --- refuses and trySurf never prints anything -Game.save.inventory.SOULBADGE = true -check(ow:partyKnows("SURF") ~= nil, "the party can use SURF here") -check(ow.map:isWaterCell(4, 14), "Pallet's south shore faces water at (4,14)") - --- what is playing when the mount starts: the bike override over the --- outdoor Pallet theme -Music.playMap(Data, "PALLET_TOWN", true, false) -eq(playedSong, bikeSong, "the bike theme plays while riding through Pallet") - -p.stepFramesCur = nil -ow:trySurf(4, 14, nil) -local box = Game.stack:top() -check(box ~= nil and box.pages ~= nil, "SURF prints _SurfingGotOnText") -local guard = 0 -while Game.stack:top() == box and guard < 400 do - guard = guard + 1 - frame({ "a" }) -end -check(Game.stack:top() ~= box, "the got-on text closes") - -eq(p.surfing, true, "the mount raises the surf state") -eq(Game.save.onBike, false, - "ItemUseSurfboard writes surfing OVER the bike state, so the bike ends (#846)") -eq(playedSong, surfSong, - "PlayDefaultMusic after the mount picks the surf theme, not the bike theme") - --- the blink carries the mount forward onto the water; let that scripted --- step land (it is queued through scriptMove, which drives the entity --- directly and never touches Player:tryMove) -guard = 0 -while (Game.stack:top() ~= ow or p.moving or #ow.scriptMoves > 0) and guard < 240 do - guard = guard + 1 - frame({}) -end -eq(Game.stack:top(), ow, "the mount ends back on the map") -eq(p.cellY, 14, "the mount steps forward onto the water") - --- the symptom in #846: the first paddled step the player takes. It runs --- through Player:tryMove, which reads Game.save.onBike for its step --- length -- a stale bike flag paddles at 8 frames per cell. -check(ow.map:isWaterCell(4, 15), "the next cell south is water too") -p.turnTimer = 0 -p.stepFramesCur = nil -eq(p:tryMove("down", ow.map, ow.entities), "moved", "paddling south from (4,14)") -eq(p.stepFramesCur, p.stepFrames, - "the paddled step uses the walk cadence, the exact symptom in #846") -check(p.stepFramesCur ~= p.bikeStepFrames, "no bike cadence survives onto the water") - --- ===================================================================== --- the mirror hole: the bag cannot put the bike back on under a surfer --- (ItemUseBicycle's `cp 2` -> jp z, ItemUseNotTime), or the bug returns --- by another route --- ===================================================================== -local save = SaveData.newGame() -local surfingOw = { player = { surfing = true } } -local result, msgs = ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, surfingOw) -eq(result, "failed", "the BICYCLE is refused while surfing") -check(result ~= "bicycle", "a surfing BICYCLE never reaches the mount path") -check(msgs and msgs[1] and msgs[1]:find("isn't the", 1, true) ~= nil, - "the surfing BICYCLE refusal uses the OAK 'not the time' text") - -local landOw = { player = { surfing = false } } -eq((ItemEffects.use(Data, save, "BICYCLE", nil, false, nil, landOw)), "bicycle", - "the BICYCLE still mounts normally on land") - -Music.play = realPlay -popAll() -S.finish() diff --git a/tests/parity_switch_cursor_reset.lua b/tests/parity_switch_cursor_reset.lua deleted file mode 100644 index ba3a1f2e..00000000 --- a/tests/parity_switch_cursor_reset.lua +++ /dev/null @@ -1,49 +0,0 @@ --- Parity: a player send-out zeroes both battle cursors (#737). SendOutMon --- (engine/battle/core.asm:1733-1735) clears wBattleAndStartSavedMenuItem and, --- with the same hli/hl pair, wPlayerMoveListIndex behind it (wram.asm:242-244), --- so the menu reopens on FIGHT and the move list on the first slot. -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 then Data:load() end -local TypeChart = require("src.battle.TypeChart") -TypeChart.load(Data) -local Pokemon = require("src.pokemon.Pokemon") -local SaveData = require("src.core.SaveData") -local BattleState = require("src.battle.BattleState") -local S = require("tests.harness").suite("parity switch cursor reset") -local eq = S.eq - -local pressed = {} -local save = SaveData.newGame() -save.party = { - Pokemon.new(Data, "BULBASAUR", 10), - Pokemon.new(Data, "PIDGEY", 10), -} -local game = { - data = Data, - save = save, - input = { - wasPressed = function(_, key) return pressed[key] == true end, - isDown = function(_, key) return pressed[key] == true end, - }, - stack = { push = function() end, pop = function() end, top = function() end }, -} -local battle = BattleState.newWild(game, "RATTATA", 3) -battle.phase = "menu" -battle.menuIndex = 4 -battle.moveIndex = 3 - -battle:resolveSwitch(save.party[2]) -for i = 1, 4000 do - if battle.phase == "menu" then break end - pressed.a = (i % 4 == 0) - battle:update(1 / 60) - pressed.a = nil -end - -eq(battle.moveIndex, 1, "the move cursor is back on the first slot") -eq(battle.menuIndex, 1, "the battle menu is back on FIGHT") - -S.finish() diff --git a/tests/parity_trainer_victory_text.lua b/tests/parity_trainer_victory_text.lua deleted file mode 100644 index 45cf8446..00000000 --- a/tests/parity_trainer_victory_text.lua +++ /dev/null @@ -1,198 +0,0 @@ --- Parity: the beaten trainer's own loss line prints ON the battle screen, --- between the pic scrolling back in and the prize money (#282). --- TrainerBattleVictory (engine/battle/core.asm:915-949) runs TrainerDefeatedText, --- ScrollTrainerPicAfterBattle, PrintEndBattleText, then MoneyForWinningText. --- The scroll (scroll_draw_trainer_pic.asm:1-31) rewrites tilemap columns only, --- so the pokeball row ClearSprites emptied does not come back with the pic. -package.path = "./?.lua;./?/init.lua;" .. package.path -if not _G.love then _G.love = require("tests.love_stub") end -local S = require("tests.harness").suite("parity trainer victory text") -local check, eq = S.check, S.eq - -local Data = require("src.core.Data") -if not Data.maps then Data:load() end -local Font = require("src.render.Font") -Font.load(Data) - -local BattleState = require("src.battle.BattleState") -local Pokemon = require("src.pokemon.Pokemon") -local SaveData = require("src.core.SaveData") -local Sound = require("src.core.Sound") -local Music = require("src.core.Music") - -Sound.playCry = function() end -Sound.play = function() end -Sound.playMove = function() end -Sound.playMoveCry = function() end -Sound.stopLoop = function() end -Music.playBattle = function() end -Music.play = function() end - -local press = {} -local function makeGame(party) - local save = SaveData.newGame() - save.party = party - local stack = { states = {} } - function stack:push(state) self.states[#self.states + 1] = state end - function stack:pop() return table.remove(self.states) end - function stack:top() return self.states[#self.states] end - -- isDown as well as wasPressed: battle text collapses PrintLetterDelay - -- while A or B is held, and the typing path reads it every frame - return { data = Data, save = save, stack = stack, - input = { wasPressed = function(_, b) return press[b] == true end, - isDown = function(_, b) return press[b] == true end } } -end - --- A held: updateQueue only reads the button once a page is typed out, so an --- early press is ignored and the queue drains at a player's pace. -local function step(battle) - press.a = true - battle:update(1 / 60) - press.a = false -end - --- Fight a YOUNGSTER, wipe its party, and record every message row in the order --- it reached the screen plus what the foe's pic slot was doing at the time. -local function fightAndWin(endBattleText) - local game = makeGame({ Pokemon.new(Data, "BULBASAUR", 60) }) - local battle = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1) - battle.endBattleText = endBattleText - local result, resultAt - battle.onFinish = function(r) result = r end - battle:enter() - for _ = 1, 500 do - step(battle) - if battle.phase == "menu" then break end - end - - -- the KO itself, through the real faint path (onFaint queues the slide, - -- the faint text and the enemyMonFainted act) - for _, mon in ipairs(battle.enemyParty) do mon.hp = 0 end - battle.enemy.mon.hp = 0 - battle.phase = "messages" - battle.nextInsert = 0 - battle:onFaint(battle.enemy) - - local pages, foeOffAt, foeShownAt = {}, {}, {} - local ballRowsSeen, frame, foeMax, foeSteps = 0, 0, 0, 0 - local realRow = battle.drawBallRow - battle.drawBallRow = function() ballRowsSeen = ballRowsSeen + 1 end - local lastOff = battle:picOffset("foe") - for f = 1, 2000 do - frame = f - step(battle) - local cur = battle.current - local text = cur and cur.text - if text and pages[#pages] ~= text then - pages[#pages + 1] = text - foeOffAt[text] = battle:picOffset("foe") - foeShownAt[text] = battle.showEnemyTrainer and true or false - end - local off = battle:picOffset("foe") - if off > foeMax then foeMax = off end - -- count only the inward frames; the jump from 0 to 64 is the program - -- being armed off-screen, not a step of the scroll - if off < lastOff then foeSteps = foeSteps + 1 end - lastOff = off - -- drawHUDs is the only place a ball row can come from; sample it while - -- the beaten trainer is back on screen - if battle.showEnemyTrainer then pcall(battle.drawHUDs, battle, 0) end - if result then resultAt = f break end - end - battle.drawBallRow = realRow - return { - battle = battle, pages = pages, result = result, resultAt = resultAt, - foeOffAt = foeOffAt, foeShownAt = foeShownAt, ballRowsSeen = ballRowsSeen, - frames = frame, foeMax = foeMax, foeSteps = foeSteps, - } -end - -local function indexOf(pages, fragment) - for i, p in ipairs(pages) do - if p:find(fragment, 1, true) then return i end - end - return nil -end - --- ------------------------------------------------- the full victory order -local LOSS = "What a total\nwaste of time!" -local run = fightAndWin(LOSS) - -eq(run.result, "win", "the battle resolves as a win") -local defeated = indexOf(run.pages, "defeated") -local loss = indexOf(run.pages, "waste of time") -local money = indexOf(run.pages, "for winning") -check(defeated ~= nil, "TrainerDefeatedText prints (\"RED defeated YOUNGSTER!\")") -check(loss ~= nil, - "the trainer's own EndBattleText prints INSIDE the battle (#282)") -check(money ~= nil, "MoneyForWinningText prints") -check(defeated and loss and defeated < loss, - "the defeat line comes before the trainer's loss line") -check(loss and money and loss < money, - "PrintEndBattleText comes before MoneyForWinningText (core.asm:942-949)") -check(money == #run.pages, - "the prize money is the LAST thing on the battle screen") - --- the pic is back, at rest, with no ball row beside it -if loss then - local text = run.pages[loss] - eq(run.foeShownAt[text], true, - "the beaten trainer's pic is on screen for his loss line") - eq(run.foeOffAt[text], 16, - "the pic has come to rest two tiles right of the battle slot " - .. "(_ScrollTrainerPicAfterBattle ends at hlcoord 14,0)") -end -eq(run.ballRowsSeen, 0, - "no pokeball row comes back with the pic (ClearSprites emptied that OAM; " - .. "_ScrollTrainerPicAfterBattle only rewrites tilemap columns)") -eq(run.battle.introBalls, nil, "the DrawAllPokeballs window stays closed") - --- The pic is on screen well before the LAST page: the plain act() this used to --- ride appended to the end of the queue, so the trainer flashed up one row --- before finish() popped the battle. -if money then - eq(run.foeShownAt[run.pages[money]], true, - "the trainer's pic is already back for the money line, not flashed up " - .. "one row before the battle pops (#282)") -end - --- and it really scrolls rather than popping into place: 64px off the right --- edge, then 2px a frame down to the resting 16 -eq(run.foeMax, 64, "the scroll-in starts 8 tiles off the right edge") -eq(run.foeSteps, 24, - "it takes 24 frames to walk in (six 4-frame columns, " - .. "scroll_draw_trainer_pic.asm:1-31)") - --- ------------------------------------------------------ ordering vs onFinish --- finish() pops the battle, so anything the overworld pushes afterwards is a --- second screen cut. Every trainer-victory row must be consumed before it. -check(run.resultAt ~= nil and run.resultAt >= run.frames, - "onFinish fires only once the whole sequence has drained") - --- ------------------------------------------------------------- \f pages --- Five EndBattleTexts carry a `para` (e.g. _Route9Youngster1EndBattleText). --- BattleState:startMessage only splits \n and \v, so an unsplit \f would --- render as a garbage glyph instead of starting a new page. -local para = fightAndWin("Oh well.\fI give up!") -check(indexOf(para.pages, "Oh well.") ~= nil, - "a \\f EndBattleText prints its first page") -check(indexOf(para.pages, "I give up!") ~= nil, - "a \\f EndBattleText prints its second page") -local p1, p2 = indexOf(para.pages, "Oh well."), indexOf(para.pages, "I give up!") -check(p1 and p2 and p2 == p1 + 1, "the two pages are consecutive rows") -for _, page in ipairs(para.pages) do - check(page:find("\f", 1, true) == nil, - "no page still carries a raw \\f: " .. (page:gsub("\n", " / "))) -end - --- --------------------------------------------------- scripted battles --- Commands.start_battle never sets endBattleText; those scripts print their --- own follow-up, so the sequence must simply skip the row. -local none = fightAndWin(nil) -eq(none.result, "win", "a battle with no EndBattleText still resolves") -local d2, m2 = indexOf(none.pages, "defeated"), indexOf(none.pages, "for winning") -check(d2 and m2 and d2 < m2, - "defeat text then money, with nothing between them") -eq(m2, #none.pages, "the prize money is still last") - -S.finish() diff --git a/tests/parity_wardens_house_bug535.lua b/tests/parity_wardens_house_bug535.lua deleted file mode 100644 index 7ab69ee5..00000000 --- a/tests/parity_wardens_house_bug535.lua +++ /dev/null @@ -1,127 +0,0 @@ --- Regression (#535): after handing over the GOLD TEETH and receiving --- HM04, every later talk to the Warden must still say something. --- --- data/scripts/story.lua's TEXT_WARDENSHOUSE_WARDEN pointed the --- EVENT_GOT_HM04 branch (row 3, jump_if_true) at the same silent-end jump --- the give-then-thank fallthrough uses (row 13), so ScriptRunner's pc ran --- straight past the end of the row list with zero show_text calls -- the --- Warden went mute on every visit after the trade. pokered's .got_item --- branch (scripts/WardensHouse.asm) instead prints .HM04ExplanationText --- (text/WardensHouse.asm: "HM04 teaches STRENGTH ... SECRET HOUSE in --- SAFARI ZONE") on every subsequent talk. -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.PALLET_TOWN) then Data:load() end - -local S = require("tests.harness").suite("parity wardens house (#535)") -local check, eq = S.check, S.eq - -local Commands = require("src.script.Commands") -local Flags = require("src.script.Flags") -local Game = require("src.core.Game") -local Input = require("src.core.Input") -local SaveData = require("src.core.SaveData") -local ScriptRunner = require("src.script.ScriptRunner") -local StateStack = require("src.core.StateStack") - -Game.data = Data -Game.input = Input; Input:init() -Game.stack = StateStack; StateStack:init() -require("src.render.Font").load(Data) - -local story = require("data.scripts.story") -local script = story.WARDENS_HOUSE.talk.TEXT_WARDENSHOUSE_WARDEN - --- instrument show_text the way parity_gift_atomicity.lua does, to record --- exactly which text ids actually printed -local shown = {} --- forward EVERY argument: the 4th is extraOpts, which is how Commands.ask --- hands down its `choice` callback. A wrapper that stops at `subs` silently --- turns every ask in the script back into a plain show_text -- no YES/NO box, --- and ctx.lastCheck left holding whatever the previous check_* put there. -local origShow = Commands.show_text -Commands.show_text = function(ctx, textId, ...) - shown[#shown + 1] = textId - return origShow(ctx, textId, ...) -end - --- `button` drives the whole conversation: both A and B page a text box, and --- on the YES/NO box A takes the cursor's default (YES) while B snaps to NO --- and answers false (ChoiceBox:update, .choseSecondMenuItem). So holding A --- runs the yes branch and holding B runs the no branch, with no reaching --- into the choice box from the test. -local function runScript(button) - shown = {} - StateStack:init() - local ow = { map = { id = "WARDENS_HOUSE", def = { label = "WardensHouse" } }, - npcs = {}, entities = {} } - local r = ScriptRunner.new(Game, ow) - r:run(script, { npc = { def = {}, facePlayer = function() end }, - overworld = ow }) - local guard = 0 - while r:isRunning() and guard < 3000 do - guard = guard + 1 - Input.pressed = { [button or "a"] = true } - StateStack:update(1 / 60) - r:update() - end - Input.pressed = {} - return not r:isRunning() -end - --- === 1) first talk, holding the GOLD TEETH: gives HM04, sets the flag === -Game.save = SaveData.newGame() -Game.save.inventory.GOLD_TEETH = 1 -check(runScript(), "give-the-teeth talk completes") -eq(table.concat(shown, ","), - "_WardensHouseWardenGaveTheGoldTeethText,_WardensHouseWardenThanksText," - .. "_WardensHouseWardenReceivedHM04Text", - "handing over the teeth shows the give/thanks/received sequence, nothing after") -check(Flags.get(Game.save, "EVENT_GOT_HM04"), "EVENT_GOT_HM04 is set") -check(Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), "EVENT_GAVE_GOLD_TEETH is set") -check(Game.save.inventory.HM_STRENGTH ~= nil, "HM04 (Strength) lands in the bag") -check(not Game.save.inventory.GOLD_TEETH, "the GOLD TEETH is taken") - --- === 2) the regression itself: every later talk, once EVENT_GOT_HM04 is --- set, must print the explanation text instead of nothing === -check(runScript(), "post-gift talk completes") -eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText", - "every subsequent talk now prints the HM04/Safari Zone explanation (#535)") - --- run it again to confirm this is not a one-shot: it repeats every visit -check(runScript(), "a third talk completes") -eq(table.concat(shown, ","), "_WardensHouseWardenHM04ExplanationText", - "the explanation text repeats on every later talk, not just the first") - --- === 3) no GOLD TEETH yet: the gibberish question, then a YES/NO, then the --- warden's answer -- Gibberish2 on yes, Gibberish3 on no (#645). --- The port used to stop dead after the question. === -Game.save = SaveData.newGame() -check(runScript("a"), "empty-handed talk completes on yes") -eq(table.concat(shown, ","), - "_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish2Text", - "answering YES gets the warden's reply, not silence (#645)") -check(not Flags.get(Game.save, "EVENT_GOT_HM04"), "no HM04 yet") - -Game.save = SaveData.newGame() -check(runScript("b"), "empty-handed talk completes on no") -eq(table.concat(shown, ","), - "_WardensHouseWardenGibberish1Text,_WardensHouseWardenGibberish3Text", - "and answering NO gets the other reply (#645)") - --- the question is asked, not just printed: `ask` is what puts the YES/NO box --- up, so a future edit that downgrades it back to show_text fails here -local askRow -for _, row in ipairs(script) do - if row[2] == "_WardensHouseWardenGibberish1Text" then askRow = row[1] end -end -eq(askRow, "ask", "the gibberish line is asked with a YES/NO, not just shown") - --- neither answer touches the teeth trade -check(not Flags.get(Game.save, "EVENT_GAVE_GOLD_TEETH"), - "and neither answer hands over teeth the player does not have") - -Commands.show_text = origShow - -S.finish() diff --git a/tests/parity_yellow_old_man.lua b/tests/parity_yellow_old_man.lua deleted file mode 100644 index 60ce0319..00000000 --- a/tests/parity_yellow_old_man.lua +++ /dev/null @@ -1,290 +0,0 @@ --- Parity (#617): Yellow's Viridian old man is the OLD_MAN2 at (18,9), --- not the Red/Blue OLD_MAN at (17,5), and his dialog has no yes/no --- choice -- the apology speech runs the RATTATA demo battle straight --- away, the post-battle line is the losing-my-touch text, and he walks --- off and hides. --- --- Oracle: pokeyellow scripts/OaksLab.asm (OaksLabOakGivesPokedexScript: --- HideObject TOGGLE_LYING_OLD_MAN / ShowObject TOGGLE_OLD_MAN_2), --- scripts/ViridianCity.asm (ViridianCityCheckWaitingOldMan, --- ViridianCityOldMan2Text, ...InitialCatchTrainingScript, --- ...PostInitialCatchTraining) and scripts/ViridianCity_2.asm --- (ViridianCityPrintOldManText). The Red/Blue "Are you in a hurry?" --- script was running against Yellow's text: YES printed the TimeIsMoney --- alias (_ViridianCityOldManLosingMyTouchText) and NO ran the demo -- --- every talk, forever. --- --- Self-contained: `luajit tests/parity_yellow_old_man.lua`; also globbed --- by tests/run_tests.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.PALLET_TOWN) then Data:load() end -local GameVersion = require("src.core.GameVersion") -local SaveData = require("src.core.SaveData") -local ScriptRunner = require("src.script.ScriptRunner") -local TextBox = require("src.render.TextBox") -local BattleState = require("src.battle.BattleState") -local Pokemon = require("src.pokemon.Pokemon") - -local S = require("tests.harness").suite("parity Yellow old man (#617)") -local check, eq = S.check, S.eq - -local oldVersion = GameVersion.get() - -local MAP = "VIRIDIAN_CITY" -local SLEEPER = "VIRIDIANCITY_OLD_MAN_SLEEPY" -local WALKER = "VIRIDIANCITY_OLD_MAN" -local OLD_MAN2 = "VIRIDIANCITY_OLD_MAN2" -local DONE_FLAG = "EVENT_COMPLETED_CATCH_TRAINING" - --- The Yellow wiring must be attached before anything else caches the --- map-script registry: data.scripts.init branches on GameVersion at --- load, so flip it first (this file owns its own process when run --- standalone). Under tests/run_tests.lua the registry is already --- cached with the Red wiring, so attach the Yellow modules directly --- afterwards -- attachBase merges per TEXT constant and replaces hooks, --- which is a no-op on a fresh process and the fix on a shared one. -GameVersion.set("yellow") -local mapScripts = require("data.scripts.init") -local MapScripts = require("src.script.MapScripts") -MapScripts.attachBase(MAP, - require("data.scripts.yellow_viridian_old_man").VIRIDIAN_CITY) -MapScripts.attachBase("OAKS_LAB", - require("data.scripts.oaks_lab_yellow")) -local oldManMod = require("data.scripts.yellow_viridian_old_man") - --- ------------------------------------------------------- the demo species --- The catch demo is a RATTATA in Yellow (SetupBattle sets wCurOpponent --- = RATTATA) but the Yellow manifest inherited Red's WEEDLE; the runtime --- override in Data:applyVersionedFieldData repairs old caches. Kept --- active until the end of this file so the demo-battle assertions below --- run against the Yellow value; restored before S.finish() like --- parity_yellow_trades does for its trades table. -local originalOldManBattle = Data.field.oldManBattle - or { species = "WEEDLE", level = 5 } -- the fixture carries no oldManBattle -local originalTrades = Data.field.trades -eq(originalOldManBattle.species, "WEEDLE", - "Red/Blue's old man still demos a Weedle") -GameVersion.set("yellow") -Data:applyVersionedFieldData() -eq(Data.field.oldManBattle.species, "RATTATA", - "Yellow's old man demos a Rattata (#617)") - -local manifestFile = assert(io.open("tools/rom_manifest_yellow.json", "r")) -local yellowManifest = manifestFile:read("*a") -manifestFile:close() -check(yellowManifest:find('"species": "RATTATA"', 1, true) ~= nil, - "the Yellow manifest stamps RATTATA for fresh imports") -local redManifestFile = assert(io.open("tools/rom_manifest.json", "r")) -local redManifest = redManifestFile:read("*a") -redManifestFile:close() -check(redManifest:find('"species": "WEEDLE"', 1, true) ~= nil, - "and the Red/Blue manifest keeps WEEDLE") - --- ------------------------------------------------------- the Pokedex swap --- OaksLabOakGivesPokedexScript shows TOGGLE_OLD_MAN_2 (the tutorial old --- man standing on the sleeper's cell), never the Red/Blue walker -local oaksRows = mapScripts.talkScript("OAKS_LAB", "TEXT_OAKSLAB_OAK1") -check(type(oaksRows) == "table", - "the Yellow OaksLab Oak talk resolves to rows") -local sawSleepHide, sawOldMan2Show, sawOldManShow = false, false, false -for _, row in ipairs(oaksRows or {}) do - if row[1] == "hide_object" and row[3] == SLEEPER then sawSleepHide = true end - if row[1] == "show_object" and row[3] == OLD_MAN2 then sawOldMan2Show = true end - if row[1] == "show_object" and row[3] == WALKER then sawOldManShow = true end -end -check(sawSleepHide, "the Pokédex hand-over hides the lying old man") -check(sawOldMan2Show, "it shows OLD_MAN2 on the sleeper's cell") -check(not sawOldManShow, "it never shows the Red/Blue OLD_MAN (#617)") - --- both Yellow gamblers default hidden (toggle OFF), like pokeyellow --- data/maps/toggleable_objects.asm. OLD_MAN2 only exists in a Yellow --- import -- a Red-imported checkout carries just OLD_MAN -- so the --- dataset checks tolerate its absence and the Yellow manifest carries --- the OLD_MAN2 default instead. -local walkerDef, oldMan2Def -if Data.maps[MAP] then - for _, o in ipairs(Data.maps[MAP].objects or {}) do - if o.name == WALKER then walkerDef = o end - if o.name == OLD_MAN2 then oldMan2Def = o end - end -end -check(walkerDef == nil or walkerDef.hidden == true, - "VIRIDIANCITY_OLD_MAN defaults hidden in Yellow") -check(oldMan2Def == nil or oldMan2Def.hidden == true, - "VIRIDIANCITY_OLD_MAN2 defaults hidden in Yellow") -local om2Name = yellowManifest:find('"name": "VIRIDIANCITY_OLD_MAN2"', 1, true) -local om2Hidden = om2Name and yellowManifest:sub( - math.max(1, om2Name - 40), om2Name):find('"hidden": true', 1, true) -check(om2Hidden ~= nil, - "the Yellow manifest ships OLD_MAN2 with the toggle OFF") - --- ------------------------------------------------------- script registry -local talk = mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN2") -check(type(talk) == "function", - "TEXT_VIRIDIANCITY_OLD_MAN2 resolves to the Yellow handler") -check(type(mapScripts.talkScript(MAP, "TEXT_VIRIDIANCITY_OLD_MAN")) == "table", - "the Red/Blue OLD_MAN talk is still registered (unreachable in Yellow)") -local hooks = mapScripts.get(MAP) -check(hooks and type(hooks.onEnter) == "function", - "VIRIDIAN_CITY.onEnter is the Yellow swap") -check(hooks and type(hooks.onStep) == "function", - "VIRIDIAN_CITY.onStep chains the gym lock and sleeper gate") -check(oldManMod.VIRIDIAN_CITY and oldManMod.VIRIDIAN_CITY.talk - and oldManMod.VIRIDIAN_CITY.talk.TEXT_VIRIDIANCITY_OLD_MAN2 == talk, - "the handler is the module's own, not a leftover merge") - --- ------------------------------------------------------- completed branch -do - local pushed = {} - local game = { - data = Data, - save = SaveData.newGame(), - stack = { push = function(_, s) pushed[#pushed + 1] = s end }, - } - game.save.flags.EVENT_COMPLETED_CATCH_TRAINING = true - local done = false - talk(game, nil, {}, function() done = true end) - eq(#pushed, 1, "a second talk only prints one box") - eq(getmetatable(pushed[1]), TextBox, "the losing-my-touch line, in a box") - pushed[1].onDone() - check(done, "closing it hands input back") -end - --- ------------------------------- the initial tutorial, end to end --- Needs real species in the dataset (the fixture carries only FIX_*); --- the engine's old-man demo machinery itself is parity_J's territory. -if Data.pokemon.RATTATA and Data.pokemon.PIKACHU then -do - require("src.render.Font").load(Data) - local pushed = {} - local save = SaveData.newGame() - save.party = { Pokemon.new(Data, "PIKACHU", 12) } - local moves = {} - local man = { def = { index = 8, name = OLD_MAN2 } } - local ow = { - map = { id = MAP, def = { label = "ViridianCity" } }, - npcs = { man }, entities = { man }, - player = { cellX = 19, cellY = 9, facing = "left" }, - scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end, - npcByIndex = function(_, i) if i == 8 then return man end end, - } - local game = { - data = Data, - save = save, - stack = { push = function(_, s) pushed[#pushed + 1] = s end }, - } - local runner = ScriptRunner.new(game, ow) - ow.runner = runner - local done = false - talk(game, ow, man, function() done = true end) - - eq(#pushed, 1, "the initial talk opens the apology speech") - eq(getmetatable(pushed[1]), TextBox, "in a text box") - pushed[1].onDone() -- A: the apology closes, the demo battle starts - - eq(#pushed, 2, "the demo battle starts with no choice in between") - local battle = pushed[2] - check(battle and battle.demo, "it is the old-man demo battle") - eq(battle and battle.enemy and battle.enemy.mon.species, "RATTATA", - "the demo is a RATTATA in Yellow (#617)") - check(battle and battle.demoFails, - "the initial training throw breaks out, never catches (#636)") - eq(save.flags[DONE_FLAG], nil, "the flag is still clear mid-demo") - battle.onFinish() -- the battle ends, the post-battle text prints - - eq(save.flags[DONE_FLAG], true, "EVENT_COMPLETED_CATCH_TRAINING is set") - eq(#pushed, 3, "the losing-my-touch line follows the demo") - pushed[3].onDone() -- A: the old man walks off - - eq(#moves, 6, "with the player on (19,9) he walks down 6 tiles") - check(moves[1] == "down" and moves[6] == "down", - "all six steps are the ViridianCityOldManMovementData2 walk") - eq(save.objectToggles[MAP] and save.objectToggles[MAP][OLD_MAN2], false, - "TOGGLE_OLD_MAN_2 hides once the walk finishes") - check(done, "and the talk hands input back") -end - --- ---------------------------------- side talk: player not on (19,9) cell -do - local pushed = {} - local save = SaveData.newGame() - save.party = { Pokemon.new(Data, "PIKACHU", 12) } - local moves = {} - local man = { def = { index = 8, name = OLD_MAN2 } } - local pika = { def = { index = 99, name = "PIKACHU_FOLLOWER" }, - pikachuFollower = true } - local ow = { - map = { id = MAP, def = { label = "ViridianCity" } }, - npcs = { man, pika }, entities = { man, pika }, - player = { cellX = 18, cellY = 8, facing = "down" }, - scriptMove = function(_, _, dir, _, cb) moves[#moves + 1] = dir; cb() end, - npcByIndex = function(_, i) if i == 8 then return man elseif i == 99 then return pika end end, - } - local game = { - data = Data, - save = save, - stack = { push = function(_, s) pushed[#pushed + 1] = s end }, - } - local runner = ScriptRunner.new(game, ow) - ow.runner = runner - talk(game, ow, man, function() end) - pushed[1].onDone() - pushed[2].onFinish() - pushed[3].onDone() - eq(moves[1], "right", "Pikachu steps aside first (ViridianCityMovePikachu)") - eq(moves[2], "right", "then the old man turns right one tile") - eq(#moves, 2, "and no more") -end -else - check(true, "fixture dataset: demo-battle flow skipped (no RATTATA)") -end - --- --------------------------------------------------------- the (19,9) step -do - local pushed = {} - local save = SaveData.newGame() - local man = { def = { index = 8, name = OLD_MAN2 } } - local ow = { - map = { id = MAP, def = { label = "ViridianCity" } }, - npcs = { man }, entities = { man }, - player = { cellX = 19, cellY = 9, facing = "down" }, - scriptMove = function(_, _, _, _, cb) cb() end, - npcByIndex = function() end, - } - local game = { - data = Data, - save = save, - stack = { push = function(_, s) pushed[#pushed + 1] = s end }, - } - local runner = ScriptRunner.new(game, ow) - ow.runner = runner - - check(not hooks.onStep(game, ow, 5, 5), - "off the trigger cell the step passes through") - check(hooks.onStep(game, ow, 19, 9), - "pre-Pokedex the sleeper gate owns (19,9)") - eq(#pushed, 1, "with the sleepy text box") - check(save.flags[DONE_FLAG] ~= true, "the tutorial is not running") - - save.flags.EVENT_GOT_POKEDEX = true - check(hooks.onStep(game, ow, 19, 9), - "with the Pokedex, (19,9) starts the tutorial") - eq(man.facing, "right", "the old man faces the player") - eq(ow.player.facing, "left", "and the player turns to face him") - eq(#pushed, 2, "the apology box is up") - check(save.flags[DONE_FLAG] ~= true, - "no flag until the demo battle actually runs") - - save.flags.EVENT_COMPLETED_CATCH_TRAINING = true - check(not hooks.onStep(game, ow, 19, 9), - "once the tutorial is done the cell is quiet again") -end - -Data.field.trades = originalTrades -Data.field.oldManBattle = originalOldManBattle -GameVersion.set(oldVersion) - -S.finish()