From 4b9a326f6bb3ea46465420236cc5726518134beb Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 30 Jul 2026 11:14:51 -0400 Subject: [PATCH] CLOSES #339, CLOSES #354, CLOSES #360, CLOSES #372, CLOSES #373, CLOSES #374, CLOSES #375, CLOSES #378, CLOSES #379, CLOSES #383, CLOSES #384, CLOSES #385, CLOSES #391, CLOSES #392, CLOSES #393, CLOSES #394, CLOSES #395, CLOSES #396, CLOSES #397, CLOSES #398, CLOSES #413, CLOSES #420, CLOSES #423, CLOSES #424, CLOSES #425, CLOSES #426, CLOSES #427, CLOSES #429, CLOSES #430, CLOSES #431, CLOSES #433, CLOSES #435, CLOSES #436, CLOSES #438, CLOSES #439, CLOSES #441, CLOSES #442, CLOSES #444 --- data/scripts/celadon_eevee.lua | 13 +- data/scripts/story3.lua | 5 + data/scripts/yellow_gifts.lua | 19 +- data/scripts/yellow_jessie_james.lua | 16 +- .../java/org/love2d/android/GameActivity.java | 49 ++- src/battle/BattleState.lua | 71 +++- src/core/ChipAudio.lua | 7 +- src/import/CacheFs.lua | 45 ++- src/import/RomExtractor.lua | 10 + src/import/RomImporter.lua | 220 +++++++++--- src/import/SaveFileIO.lua | 7 +- src/mods/LauncherMods.lua | 26 +- src/save_convert/SaveConvert.lua | 98 ++++-- src/ui/SummaryMenu.lua | 13 +- src/ui/YellowIntro.lua | 6 +- src/world/OverworldController.lua | 30 +- src/world/PikachuFollower.lua | 169 +++++++++- tests/drivers/battle_shake_bug354_test.lua | 91 +++-- .../drivers/blackout_no_party_bug425_test.lua | 182 ++++++++++ .../jessie_james_mtmoon_bug423_test.lua | 171 ++++++++++ tests/drivers/jj_battle_pic_bug439_test.lua | 125 +++++++ tests/drivers/launcher_delete_bug433_test.lua | 90 +++++ .../drivers/oak_nidorino_flip_bug397_test.lua | 32 +- tests/drivers/pikachu_cadence_bug424_test.lua | 316 ++++++++++++++++++ .../pikachu_ledge_bug408_bug409_test.lua | 33 +- tests/drivers/pikachu_seam_bug427_test.lua | 189 +++++++++++ tests/drivers/pikachu_talk_bug407_test.lua | 14 +- tests/drivers/route8_music_bug429_test.lua | 136 ++++++++ tests/drivers/silph_tm36_bug393_test.lua | 19 +- tests/drivers/title_seam_bug373_test.lua | 11 +- .../yellow_intro_music_bug436_test.lua | 170 ++++++++++ tests/engine/jessie_james_mtmoon_walk.lua | 195 +++++++++++ tests/engine/jessie_james_pic.lua | 73 ++++ tests/engine/launcher_delete_confirm.lua | 134 ++++++++ tests/engine/launcher_stray_mount.lua | 88 +++++ tests/engine/rom_pick_error_bug442.lua | 208 ++++++++++++ tests/engine/save_import_retry_bug420.lua | 249 ++++++++++++++ tests/engine/teleport_ends_turn.lua | 105 ++++++ tests/engine/teleport_escape_ends_turn.lua | 130 +++++++ tests/engine/wave_channel_mix_bug429.lua | 139 ++++++++ tests/parity_blackout_no_party.lua | 163 +++++++++ tests/parity_gift_atomicity.lua | 185 ++++++++++ tests/parity_marowak_ball.lua | 184 ++++++++++ tests/parity_status_true_color.lua | 101 ++++++ tools/make_yellow_manifest.py | 3 + tools/rom_manifest_yellow.json | 4 + 46 files changed, 4171 insertions(+), 173 deletions(-) create mode 100644 tests/drivers/blackout_no_party_bug425_test.lua create mode 100644 tests/drivers/jessie_james_mtmoon_bug423_test.lua create mode 100644 tests/drivers/jj_battle_pic_bug439_test.lua create mode 100644 tests/drivers/launcher_delete_bug433_test.lua create mode 100644 tests/drivers/pikachu_cadence_bug424_test.lua create mode 100644 tests/drivers/pikachu_seam_bug427_test.lua create mode 100644 tests/drivers/route8_music_bug429_test.lua create mode 100644 tests/drivers/yellow_intro_music_bug436_test.lua create mode 100644 tests/engine/jessie_james_mtmoon_walk.lua create mode 100644 tests/engine/jessie_james_pic.lua create mode 100644 tests/engine/launcher_delete_confirm.lua create mode 100644 tests/engine/launcher_stray_mount.lua create mode 100644 tests/engine/rom_pick_error_bug442.lua create mode 100644 tests/engine/save_import_retry_bug420.lua create mode 100644 tests/engine/teleport_ends_turn.lua create mode 100644 tests/engine/teleport_escape_ends_turn.lua create mode 100644 tests/engine/wave_channel_mix_bug429.lua create mode 100644 tests/parity_blackout_no_party.lua create mode 100644 tests/parity_gift_atomicity.lua create mode 100644 tests/parity_marowak_ball.lua create mode 100644 tests/parity_status_true_color.lua diff --git a/data/scripts/celadon_eevee.lua b/data/scripts/celadon_eevee.lua index f7563d7c..55d0627d 100644 --- a/data/scripts/celadon_eevee.lua +++ b/data/scripts/celadon_eevee.lua @@ -84,11 +84,16 @@ return { { "jump", 13 }, -- 4 { "give_pokemon", "EEVEE", 25 }, -- 5 { "jump_if_false", 12 }, -- 6 (party+box full) - { "play_sound", "Get_Item1" }, -- 7 (GotMonText jingle) - { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 8 - { "set_flag", "EVENT_GOT_EEVEE" }, -- 9 + -- flag + HideObject before the jingle and GotMonText: the nickname + -- prompt inside give_pokemon and the text row both yield, and a + -- script that dies there would leave the EEVEE taken with the ball + -- still on the table and the gift claimable again (#426). The row + -- count is unchanged, so the numeric jump targets still hold. + { "set_flag", "EVENT_GOT_EEVEE" }, -- 7 { "hide_object", "CELADON_MANSION_ROOF_HOUSE", - "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 10 + "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL" }, -- 8 + { "play_sound", "Get_Item1" }, -- 9 (GotMonText jingle) + { "show_text", "_GotMonText", { RAM = "EEVEE" } }, -- 10 { "jump", 13 }, -- 11 { "show_text", "_BoxIsFullText" }, -- 12 }, diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 0281eda4..9d6ca76c 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -128,6 +128,11 @@ M.POKEMON_TOWER_6F = { t._PokemonTower6FBeGoneText or "Be gone...\nIntruders...", function() local BattleState = require("src.battle.BattleState") local battle = BattleState.newWild(game, "MAROWAK", 30) + -- ItemUseBall .notOldManBattle (item_effects.asm:166-175): a ball + -- thrown on POKEMON_TOWER_6F at the RESTLESS SOUL is dodged whether + -- or not the scope revealed it, so the "can't be caught" state rides + -- the battle instead of IsGhostBattle alone (#444) + battle.noCatch = true if not game.save.inventory.SILPH_SCOPE then battle:makeGhost() end diff --git a/data/scripts/yellow_gifts.lua b/data/scripts/yellow_gifts.lua index 17460594..fe2f08a1 100644 --- a/data/scripts/yellow_gifts.lua +++ b/data/scripts/yellow_gifts.lua @@ -23,10 +23,15 @@ M.CERULEAN_MELANIES_HOUSE = { rows[#rows + 1] = { "jump_if_false", "declined" } rows[#rows + 1] = { "give_pokemon", "BULBASAUR", 10 } rows[#rows + 1] = { "jump_if_false", "end" } -- party + box full - rows[#rows + 1] = { "show_text", "MelanieText3" } + -- Event and HideObject before MelanieText3: give_pokemon's nickname + -- prompt and the received text both yield, and a script that dies + -- in that window used to leave the BULBASAUR given with the event + -- clear, so Melanie offered a second one (#426). The flag is only + -- read at script entry, so the order is invisible in play. + rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" } rows[#rows + 1] = { "hide_object", "CERULEAN_MELANIES_HOUSE", "CERULEANMELANIESHOUSE_BULBASAUR" } - rows[#rows + 1] = { "set_flag", "EVENT_GOT_BULBASAUR_IN_CERULEAN" } + rows[#rows + 1] = { "show_text", "MelanieText3" } rows[#rows + 1] = { "jump", "end" } rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "show_text", "MelanieText5" } @@ -60,8 +65,13 @@ M.ROUTE_24 = { { "jump_if_false", "declined" }, { "give_pokemon", "CHARMANDER", 10 }, { "jump_if_false", "end" }, - { "show_text", "_Route24DamianText2" }, + -- SetEvent hoisted ahead of the received text (Route24.asm writes it + -- after Route24Text_515e3, where nothing in between can fail): the + -- nickname prompt and the text both yield, and a script death in that + -- window left the CHARMANDER given with EVENT_54F clear, so Damian + -- handed out a second one on the next talk (#426). { "set_flag", "EVENT_54F" }, + { "show_text", "_Route24DamianText2" }, { "jump", "end" }, { "label", "declined" }, { "show_text", "_Route24DamianText3" }, @@ -87,9 +97,10 @@ M.VERMILION_CITY = { rows[#rows + 1] = { "jump_if_false", "declined" } rows[#rows + 1] = { "give_pokemon", "SQUIRTLE", 10 } rows[#rows + 1] = { "jump_if_false", "end" } - rows[#rows + 1] = { "show_text", "_OfficerJennyText3" } + -- event before the received text, as above (#426) rows[#rows + 1] = { "set_flag", "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY" } + rows[#rows + 1] = { "show_text", "_OfficerJennyText3" } rows[#rows + 1] = { "jump", "end" } rows[#rows + 1] = { "label", "declined" } rows[#rows + 1] = { "show_text", "_OfficerJennyText4" } diff --git a/data/scripts/yellow_jessie_james.lua b/data/scripts/yellow_jessie_james.lua index 3a16dc57..c1e4f226 100644 --- a/data/scripts/yellow_jessie_james.lua +++ b/data/scripts/yellow_jessie_james.lua @@ -48,7 +48,19 @@ M.MT_MOON_B2F = { { "show_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" }, { "show_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" }, { "show_text", "_MtMoonJessieJamesText1" }, - { "face_player_dir", "right" }, + { "face_player_dir", "up" }, + { "emote", "player", "shock", 30 }, + -- MtMoonB2FScript_49e15 simulates PAD_UP for one player step, then + -- Script6/Script9 walk Jessie (object 2, MovementData_f9e65: six + -- $06) and James (object 6, f9e66: five $06); both objects' movement + -- byte 2 is LEFT, so .determineDirection makes those LEFT steps -- + -- Jessie (9,3)->(3,3) above the player at (3,4), James (9,4)->(4,4) + -- beside him. #423 + { "walk_npc", "player", { "up" } }, + { "walk_npc", 2, { "left", "left", "left", "left", "left", "left" } }, + { "face_object", 2, "down" }, + { "walk_npc", 6, { "left", "left", "left", "left", "left" } }, + { "face_object", 6, "left" }, { "show_text", "_MtMoonJessieJamesText2" }, { "start_battle", "trainer", "OPP_ROCKET", 42 }, { "check_battle_result", "win" }, @@ -57,8 +69,10 @@ M.MT_MOON_B2F = { { "show_text", "_MtMoonJessieJamesText4" }, { "stop_music" }, { "play_music", "Music_MeetJessieJames" }, + { "fade", "out" }, { "hide_object", "MT_MOON_B2F", "MTMOONB2F_JESSIE" }, { "hide_object", "MT_MOON_B2F", "MTMOONB2F_JAMES" }, + { "fade", "in" }, { "play_default_music" }, { "set_flag", "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" }, }, {}) 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 96f4aee5..52e273c0 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 @@ -78,6 +78,10 @@ public class GameActivity extends SDLActivity { private static final String PICKED_SAVE_FILENAME = "picked_save.sav"; private static final String PENDING_EXPORT_FILENAME = "pending_export.sav"; private static final String EXPORT_DONE_FILENAME = "export_done.flag"; + // Written when a SAF pick cannot be read at all, with the destination + // basename as its body, so RomImporter:focus can say so in the launcher + // instead of leaving the player on "No ROM imported" (issue #442). + private static final String PICK_ERROR_FILENAME = "pick_error.flag"; // Destination basename for the in-flight SAF pick (set by showFilePicker). private String pendingPickFilename = PICKED_ROM_FILENAME; // Suggested download name for the in-flight SAF create (set by showCreateDocument). @@ -452,6 +456,17 @@ public class GameActivity extends SDLActivity { return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); } + /** Drops a small flag file in the save identity for Lua to consume on focus. */ + private void writeSaveDirFlag(String name, String body) { + try { + FileOutputStream fos = new FileOutputStream(new File(saveIdentityDir(), name), false); + fos.write(body.getBytes()); + fos.close(); + } catch (IOException e) { + Log.d("GameActivity", "could not write " + name + ": " + e.getMessage()); + } + } + private boolean copyFileToUri(File source, Uri destUri) { InputStream in = null; OutputStream out = null; @@ -491,14 +506,7 @@ public class GameActivity extends SDLActivity { Uri uri = data.getData(); if (copyFileToUri(source, uri)) { // Signal Lua on next focus that the SAF export finished. - File flag = new File(saveIdentityDir(), EXPORT_DONE_FILENAME); - try { - FileOutputStream fos = new FileOutputStream(flag, false); - fos.write("ok".getBytes()); - fos.close(); - } catch (IOException e) { - Log.d("GameActivity", "could not write export_done flag: " + e.getMessage()); - } + writeSaveDirFlag(EXPORT_DONE_FILENAME, "ok"); // Keep pending_export.sav so a retry still works; Lua may remove it. } else { Log.d("GameActivity", "could not write export to " + uri); @@ -521,19 +529,36 @@ public class GameActivity extends SDLActivity { ? pendingPickFilename : PICKED_ROM_FILENAME; File destFile = new File(destDir, destName); - InputStream source; + // ACTION_OPEN_DOCUMENT is meant to land in the system documents UI, but + // some OEM shells (ColorOS) offer third-party file managers in a + // chooser, and those hand back either a provider URI this app has no + // grant for (SecurityException / FileNotFoundException) or a bare + // file:// path (unreadable without storage permission on targetSdk 34). + // Try the resolver, then the path, then tell Lua why nothing imported. + InputStream source = null; try { source = getContentResolver().openInputStream(uri); - } catch (FileNotFoundException e) { + } catch (Exception e) { Log.d("GameActivity", "could not open picked file: " + e.getMessage()); - return; + } + if (source == null && "file".equals(uri.getScheme()) && uri.getPath() != null) { + try { + source = new FileInputStream(uri.getPath()); + } catch (FileNotFoundException e) { + Log.d("GameActivity", "could not open picked path: " + e.getMessage()); + } } if (source == null) { - Log.d("GameActivity", "ContentResolver returned no stream for picked file"); + Log.d("GameActivity", "no readable stream for picked file " + uri); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); return; } if (!copyAssetFile(source, destFile.getPath())) { Log.d("GameActivity", "could not copy picked file to " + destFile); + // A truncated pick would only fail verification later, so drop it + // and report instead. + destFile.delete(); + writeSaveDirFlag(PICK_ERROR_FILENAME, destName); } } diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index e06ee37d..1ce7d3b7 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -4,7 +4,7 @@ -- Flow: intro -> menu (FIGHT/PKMN/ITEM/RUN) -> move select -> turn -- resolution (a queue of messages/actions/UI pushes) -> back to menu, -- until one side is out, then finish. Pops itself and calls --- onFinish("win"|"lose"|"run"|"caught"|"skipped"). +-- onFinish("win"|"lose"|"run"|"caught"). -- -- The Gen 1 move-effect pipeline (multi-hit, charge, trapping, thrash, -- bide, recharge, confusion, screens, substitute, transform, ...) is @@ -547,6 +547,19 @@ local function applySpecialMoves(data, oppClass, partyIndex, party) end end +-- Yellow only: ROCKET with wTrainerNo >= $2a is Jessie & James, who share +-- the class and the name "ROCKET" but battle behind their own pic +-- (home/trainers2.asm IsFightingJessieJames). picJessieJames exists only +-- in a Yellow cache extracted after #439, so an older cache keeps the +-- grunt pic until it is re-imported. +function BattleState.trainerPicPath(trainer, oppClass, partyIndex) + if oppClass == "OPP_ROCKET" and (partyIndex or 1) >= 42 + and trainer.picJessieJames then + return trainer.picJessieJames + end + return trainer.pic +end + function BattleState.newTrainer(game, oppClass, partyIndex) local self = newBattle(game) self.kind = "trainer" @@ -608,7 +621,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex) -- MonsterPalettes[0] = PAL_MEWMON -- InitBattleCommon zeroes -- wEnemyMonSpecies2 before the intro's SET_PAL_BATTLE -- (engine/battle/core.asm:6682, engine/gfx/palettes.asm SetPal_Battle) - self.trainerPic = getImage(self.trainer.pic, namedPalette(game.data, "MEWMON")) + self.trainerPic = getImage( + BattleState.trainerPicPath(self.trainer, oppClass, partyIndex), + namedPalette(game.data, "MEWMON")) self.introText = Strings("%s wants\nto fight!", self.trainer.name) return self end @@ -1115,10 +1130,34 @@ function BattleState:battleKind() end function BattleState:enter() - if self.dead then + -- Out of useable POKéMON before the battle even starts. pokered does not + -- skip the battle: .checkAnyPartyAlive (engine/battle/core.asm:158-162) + -- runs right after the intro and jumps to HandlePlayerBlackOut, so the + -- player blacks out and afterBattle's "lose" path revives the party at the + -- last heal point. Handing the map back with a 0 HP party instead bricked + -- the save: every later encounter aborted here too and sighted trainers + -- re-engaged forever (#425). self.dead alone is not the test -- + -- makeOldManDemo and LinkBattle install a player battler after the + -- constructor flagged the battle dead. + if self.dead and not self.player then + local name = self.game.save.player.name + self.result = "lose" self.game.stack:pop() - Runtime.emit("battle.ended", { battle = self, result = "skipped" }) - if self.onFinish then self.onFinish("skipped") end + require("src.core.Music").restoreMap(self.data) + Runtime.emit("battle.ended", { battle = self, result = "lose", skipped = true }) + local onFinish = self.onFinish + local function blackedOut() + if onFinish then onFinish("lose") end + end + -- the Oak's Lab starter rival returns above PlayerBlackedOutText2 and + -- afterBattle keeps the player in the lab, so print nothing there + if BattleState.isOaksLabStarterRival(self) then return blackedOut() end + -- _PlayerBlackedOutText2 (data/text/text_2.asm:896): the two paragraphs + -- playerMonFainted queues on the battle screen; there is no battle + -- screen to queue them on here, so they print over the map. + self.game.stack:push(require("src.render.TextBox").new(self.game, + Strings("%s is out of\nuseable POKéMON!", name) .. "\f" + .. Strings("%s blacked\nout!", name), blackedOut)) return end local Music = require("src.core.Music") @@ -1956,6 +1995,15 @@ function BattleState:resolveSwitch(newMon) end function BattleState:endOfTurn() + -- the same ret: a decided battle never reaches HandlePoisonBurnLeechSeed + -- or CheckNumAttacksLeft (core.asm:417-421, 456-460), so the residual + -- sweep and the trapping-counter release are skipped on the turn a + -- Teleport escape (or a win/loss/capture) settles it (#441). The + -- turn_ended hook still fires: mods count turns, not residuals. + if self.result then + Runtime.emit("battle.turn_ended", { battle = self, turn = self.turnCount or 0 }) + return + end -- sideToxic mirrors w*ToxicCounter: it advances only while the -- battler's badly-poisoned flag (toxicCounter) is set, an item/AI -- cure clears the flag but NOT the side counter, and a fresh Toxic @@ -2626,6 +2674,12 @@ function BattleState:syncShownStatus() end function BattleState:executeAction(user, target, action) + -- MainInBattleLoop reads wEscapedFromBattle right after Execute*Move and + -- rets (core.asm:417-421, 456-460): a Teleport/Roar/Whirlwind escape ends + -- the turn where it lands and the second mover never moves. self.result + -- is only ever set once the battle is over (run/win/lose/caught), and the + -- faint cases are already covered by the HP guard below (#441) + if self.result then return end if user.mon.hp <= 0 or target.mon.hp <= 0 then return end if not action then return end @@ -3920,12 +3974,15 @@ function BattleState:throwBall(ball) self:act(function() self:endOfTurn() end) return end - if self.ghost then + if self.ghost or self.noCatch then -- ItemUseBall's can't-be-caught path (item_effects.asm:149-153): -- the ball is thrown (TossBallAnimation still picks the arc from -- wCurItem, so a Master/Ultra toss keeps its flicker), dodged -- ($10 anim data, no wobbles), and the turn is spent like any - -- failed throw + -- failed throw. battle.noCatch is the .notOldManBattle half of the + -- same check (item_effects.asm:166-175): the POKEMON_TOWER_6F + -- RESTLESS SOUL dodges balls even once the scope has revealed it, + -- so it is not a ghost battle any more (#444) self:animNext(self:tossAnimFor(ball), true, nil, ball) self:sayNext(Strings("It dodged the\nthrown BALL!")) self:sayNext(Strings("This POKéMON\ncan't be caught!")) diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 2a2effbe..a7049f5d 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -32,17 +32,20 @@ local MUSIC_BUFFER_COUNT = ChipSynth.MUSIC_BUFFER_COUNT -- [1] pulse 1 [2] pulse 2 [3] wave [4] noise / drums -- Volume: 1 = authentic, 0 = mute, >1 boosts -- Pitch: 1 = authentic, 2 = +1 octave, 0.5 = -1 octave +-- The shipped values stay at 1: 0.25 / 0.5 on the wave channel buried the Ch3 +-- countermelodies an octave low (#429), and ChipSynth already applies the +-- wave channel's own hardware octave (frequency * 0.5). -- --------------------------------------------------------------------------- local CHANNEL_VOLUME = { [1] = 1, -- pulse 1 [2] = 1, -- pulse 2 - [3] = 0.25, -- wave + [3] = 1, -- wave [4] = 1, -- noise / drums } local CHANNEL_PITCH = { [1] = 1, -- pulse 1 [2] = 1, -- pulse 2 - [3] = 0.5, -- wave + [3] = 1, -- wave [4] = 1, -- noise / drums } ChipSynth.setChannelVolumes(CHANNEL_VOLUME) diff --git a/src/import/CacheFs.lua b/src/import/CacheFs.lua index eb7fd646..5cca3b27 100644 --- a/src/import/CacheFs.lua +++ b/src/import/CacheFs.lua @@ -179,6 +179,38 @@ local function resolveUnmount() return physfsUnmountFn end +-- PHYSFS_getMountPoint, resolved the same way: a non-NULL return means `dir` +-- is already somewhere in the search path. withMounted needs it because +-- PHYSFS_mount reports success for an already-mounted directory without +-- adding a second entry, so its unmount would drop a mount it did not make +-- (#413). +local physfsMountPointFn = nil +local function resolveMountPoint() + if physfsMountPointFn ~= nil then return physfsMountPointFn end + physfsMountPointFn = false + local ok, ffi = pcall(require, "ffi") + if not ok then return physfsMountPointFn end + pcall(ffi.cdef, "const char *PHYSFS_getMountPoint(const char *dir);") + local libs = { + function() return ffi.C end, + function() return ffi.load("love") end, + } + for _, getlib in ipairs(libs) do + local okl, lib = pcall(getlib) + if okl and lib then + local oks, fn = pcall(function() return lib.PHYSFS_getMountPoint end) + if oks and fn then + physfsMountPointFn = function(d) + local okr, ret = pcall(fn, d) + return okr and ret ~= nil and ret ~= ffi.NULL + end + break + end + end + end + return physfsMountPointFn +end + -- The portable game folder when the cache should live there, else nil. -- Resolved (and, for a fused build, mounted) once and cached. Requires a -- desktop portable install (SaveData) and a working windowless mkdir. @@ -398,13 +430,20 @@ end -- resolves -- which is what makes it safe to point at a folder whose contents -- nobody has validated. -- --- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, or the --- mount was refused), which callers must treat as "could not look", not as --- "nothing there". An error inside fn still unmounts before it propagates. +-- Returns nil when the mount is unavailable (no ffi, no PHYSFS symbol, the +-- mount was refused, or `dir` is already on the read path), which callers must +-- treat as "could not look", not as "nothing there". An error inside fn still +-- unmounts before it propagates. function CacheFs.withMounted(dir, mountPoint, fn) if not dir or dir == "" then return nil end local mount, unmount = resolveMount(), resolveUnmount() if not (mount and unmount) then return nil end + -- A directory already in the search path cannot be borrowed: PHYSFS_mount + -- returns success without adding an entry, and the unmount below would then + -- remove the mount somebody else is relying on -- for the portable game + -- folder, the one that makes its cache and mods readable at all (#413) + local mountedAt = resolveMountPoint() + if mountedAt and mountedAt(dir) then return nil end if not mount(dir, mountPoint, true) then return nil end local ok, res = pcall(fn) unmount(dir) diff --git a/src/import/RomExtractor.lua b/src/import/RomExtractor.lua index c28c0d69..180949b7 100644 --- a/src/import/RomExtractor.lua +++ b/src/import/RomExtractor.lua @@ -1307,6 +1307,16 @@ function RomExtractor:extractTrainers() } self:tick("Trainers", index, #order) end + -- Yellow's Jessie & James fight as OPP_ROCKET but carry their own pic + -- (home/trainers2.asm IsFightingJessieJames); only Yellow's manifest has + -- the symbol, so Red and Blue skip this (#439). + if self.symbols.JessieJamesPic then + local relative = "battle/trainers/jessie_james.png" + self:writeCompressedPic("JessieJamesPic", relative) + if out.OPP_ROCKET then + out.OPP_ROCKET.picJessieJames = "assets/generated/" .. relative + end + end self:write("trainers", out) return out end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 32b83697..3c729bf2 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -35,6 +35,13 @@ local REQUIRED_FILES = { "assets/generated/audio/programs.bin", } +-- Files only one version's cache carries. A version that predates one of +-- them re-imports on its own, without dragging the other versions through a +-- CACHE_FORMAT bump. +local VERSION_REQUIRED_FILES = { + yellow = { "assets/generated/battle/trainers/jessie_james.png" }, -- #439 +} + -- "Split-screen ROM selector" first-run palette (matches FirstRun.dc.html from -- the Claude Design project): a dark neon arcade panel, one column per game. -- Red, Blue, and Yellow share the same importer flow once listed in @@ -99,6 +106,9 @@ local function allRequiredFilesExist(version) for _, path in ipairs(REQUIRED_FILES) do if not CacheFs.exists(path) then ok = false; break end end + for _, path in ipairs(ok and VERSION_REQUIRED_FILES[version] or {}) do + if not CacheFs.exists(path) then ok = false; break end + end CacheFs.prefix = saved return ok end @@ -321,18 +331,46 @@ local function findPendingRom(ready) return nil end +-- GameActivity always writes the SAF pick to picked_rom.gb, so a leftover +-- under that exact basename is the file the player just chose and +-- findPendingRom silently refused: wrong size, or a hacked/overdumped cart +-- whose SHA-1 matches no known version ([b]/[BF] dumps never will). Route it +-- through startData so the launcher says which of the two it was instead of +-- staying on "No ROM imported" with no message at all (issue #442), and drop +-- the file so the next tap starts from a clean slate. A cart that is simply +-- already imported is not an error -- #167 skips it on purpose -- so leave +-- that one alone. +local function consumePickedRomError(self) + local preferred = "picked_rom.gb" + if not love.filesystem.getInfo(preferred, "file") then return false end + local data = love.filesystem.read(preferred) + if type(data) == "string" and #data == 1024 * 1024 then + local version = GameVersion.forSha1(sha1(data)) + if version and self.ready[version] then return false end + end + love.filesystem.remove(preferred) + if type(data) ~= "string" then + self:setError("The picked file could not be read. Reopen the picker and " + .. "choose the ROM with the Files (Documents) app.") + return true + end + self:startData(data, preferred) + return true +end + -- Android SAF writes mod picks to picked_mod.zip; USB copies may use any -- .zip basename at the save-dir root. preferAny=true also accepts those USB -- copies (Choose / Import); focus only consumes the SAF basename so a random -- leftover archive is never auto-installed on every refocus. -local function findPendingMod(preferAny) +local function findPendingMod(preferAny, skip) local preferred = "picked_mod.zip" if love.filesystem.getInfo(preferred, "file") then return preferred end if not preferAny then return nil end for _, name in ipairs(love.filesystem.getDirectoryItems("")) do - if name:lower():match("%.zip$") and love.filesystem.getInfo(name, "file") then + if name:lower():match("%.zip$") and not (skip and skip[name]) + and love.filesystem.getInfo(name, "file") then return name end end @@ -340,20 +378,36 @@ local function findPendingMod(preferAny) end -- Same pattern as findPendingMod for battery saves (picked_save.sav / *.sav). -local function findPendingSav(preferAny) +local function findPendingSav(preferAny, skip) local preferred = "picked_save.sav" if love.filesystem.getInfo(preferred, "file") then return preferred end if not preferAny then return nil end for _, name in ipairs(love.filesystem.getDirectoryItems("")) do - if name:lower():match("%.sav$") and love.filesystem.getInfo(name, "file") then + if name:lower():match("%.sav$") and not (skip and skip[name]) + and love.filesystem.getInfo(name, "file") then return name end end return nil end +-- Retire an Android pick once it has been through the installer / importer, +-- whether or not it worked: a pick left on disk wins the scans above forever, +-- so the next tap re-runs the same failing file and the picker never reopens +-- (#420). The SAF basename is GameActivity's own copy of the pick and is +-- always deleted; a USB copy is the player's file, so a failed one is only +-- skipped for the rest of the session. +local function consumePick(self, name, safName, ok) + if ok or name == safName then + love.filesystem.remove(name) + return + end + self.pickSkip = self.pickSkip or {} + self.pickSkip[name] = true +end + local function chooseRom(promptName) promptName = promptName or "Pokemon" local prompt = "Choose your " .. promptName .. " ROM" @@ -568,7 +622,13 @@ function RomImporter.new(onComplete, opts) end if android and needRom then local name, data = findPendingRom(self.ready) - if name then self:startData(data, name) end + if name then + self:startData(data, name) + else + -- The picker runs as its own activity and Android may kill us while it + -- is up, so a rejected pick can outlive the focus handler (#442). + consumePickedRomError(self) + end end -- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) @@ -629,28 +689,53 @@ function RomImporter:focus(f) if self.tab == "mods" then self.tab = version end return end - local modName = findPendingMod(false) - if modName then - self:_installMod(modName) - if self.modNotice and self.modNotice.ok then - love.filesystem.remove(modName) + -- The SAF pick failed inside GameActivity, which wrote pick_error.flag with + -- the destination basename in it: some OEM shells (ColorOS) let a third-party + -- archive manager win the ACTION_OPEN_DOCUMENT chooser and hand back a URI + -- this app has no permission to read, and until #442 that returned to a + -- launcher that said nothing at all. + local pickError = love.filesystem.getInfo("pick_error.flag", "file") + and love.filesystem.read("pick_error.flag") + if pickError then + love.filesystem.remove("pick_error.flag") + local text = "Could not read the picked file. Reopen the picker and choose " + .. "it with the Files (Documents) app, or copy it into: " + .. love.filesystem.getSaveDirectory() + if pickError:find("picked_mod", 1, true) then + self.modNotice = { ok = false, text = text } + elseif pickError:find("picked_save", 1, true) then + local version = self.androidPendingVersion or self:_savedropTarget() + self.androidPendingVersion = nil + self.saveNotice[version] = { ok = false, text = text } + else + self:setError(text) end return end - local savName = findPendingSav(false) + local modName = findPendingMod(false, self.pickSkip) + if modName then + self:_installMod(modName) + consumePick(self, modName, "picked_mod.zip", + self.modNotice and self.modNotice.ok) + return + end + local savName = findPendingSav(false, self.pickSkip) if savName then local version = self.androidPendingVersion or self:_savedropTarget() self.androidPendingVersion = nil self:_importSave(version, savName) - if self.saveNotice[version] and self.saveNotice[version].ok then - love.filesystem.remove(savName) - end + consumePick(self, savName, "picked_save.sav", + self.saveNotice[version] and self.saveNotice[version].ok) return end for _, v in ipairs(GameVersion.ORDER) do if not self.ready[v] then local name, data = findPendingRom(self.ready) - if name then self:startData(data, name) end + if name then + self:startData(data, name) + else + consumePickedRomError(self) + end return end end @@ -698,8 +783,9 @@ function RomImporter:startData(data, displayName) local actualHash = sha1(data) local version = GameVersion.forSha1(actualHash) if not version then - self:setError(("Unsupported ROM (SHA-1 %s). Use an unmodified US Pokemon " - .. "Red, Blue, or Yellow ROM."):format(actualHash)) + self:setError(("Unsupported ROM (SHA-1 %s). This needs a clean US Pokemon " + .. "Red, Blue, or Yellow dump; patched, trimmed or \"fixed\" dumps " + .. "(tagged [b] or [BF]) never verify."):format(actualHash)) return end local info = GameVersion.info(version) @@ -849,12 +935,11 @@ end function RomImporter:chooseMod() if self.workState == "working" then return end if self.android then - local name = findPendingMod(true) + local name = findPendingMod(true, self.pickSkip) if name then self:_installMod(name) - if self.modNotice and self.modNotice.ok then - love.filesystem.remove(name) - end + consumePick(self, name, "picked_mod.zip", + self.modNotice and self.modNotice.ok) return end if not love.system.pickFile("mod") then @@ -908,13 +993,12 @@ end function RomImporter:chooseSaveImport(version) if self.workState == "working" then return end if self.android then - local name = findPendingSav(true) + local name = findPendingSav(true, self.pickSkip) if name then self.androidPendingVersion = version self:_importSave(version, name) - if self.saveNotice[version] and self.saveNotice[version].ok then - love.filesystem.remove(name) - end + consumePick(self, name, "picked_save.sav", + self.saveNotice[version] and self.saveNotice[version].ok) return end self.androidPendingVersion = version @@ -999,6 +1083,8 @@ function RomImporter:choose(version) local name, data = findPendingRom(self.ready) if name then self:startData(data, name) + elseif consumePickedRomError(self) then + return -- a rejected pick explains itself instead of silently reopening elseif not love.system.pickFile() then -- Picker unavailable (API < 19, or no document-picker app installed): -- fall back to the USB folder-drop path as a friendly notice, not an @@ -1415,6 +1501,32 @@ function RomImporter.pageScrollFor(naturalH, viewportH, scroll) return maxPage > 0, clamp(scroll or 0, 0, maxPage), maxPage end +-- Every hit rect mousepressed dispatches on, cleared before any panel draws: +-- each rect is rebuilt only by the panel that draws its control, so whatever a +-- frame does not draw must not stay clickable. Missing the Delete rects here +-- let a click on the mods tab land on the game tab's save Delete label (#433). +function RomImporter:_resetFrameRects() + self.romButtonRect = nil + self.playButtonRect = nil + self.tabRects = {} + -- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a + -- version with no panel drawn this frame) cannot inherit last frame's rows. + self.slotRects = nil + self.slotEditRects = nil + self.slotDeleteRects = nil + self.newSlotRect = nil + -- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit + -- last frame's mod toggles / Delete labels / import button. + self.modRects = nil + self.modDeleteRects = nil + self.modImportRect = nil + -- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so + -- the mods tab cannot inherit last frame's save Import/Export/open-folder hits. + self.saveImportRect = nil + self.saveExportRect = nil + self.saveFolderRect = nil +end + function RomImporter:draw() local width, height = love.graphics.getDimensions() local s = clamp(height / 768, 0.7, 1.6) @@ -1433,23 +1545,7 @@ function RomImporter:draw() end self._hoverEnabled = self._padCursorActive or not self.android self._anyHover = false - self.romButtonRect = nil - self.playButtonRect = nil - self.tabRects = {} - -- Rebuilt only by the active version's SAVE SLOT panel, so the mods tab (or a - -- version with no panel drawn this frame) cannot inherit last frame's rows. - self.slotRects = nil - self.slotEditRects = nil - self.newSlotRect = nil - -- Rebuilt only by the mods panel; nil elsewhere so a game tab cannot inherit - -- last frame's mod toggles / import button. - self.modRects = nil - self.modImportRect = nil - -- Rebuilt only by the active game panel's SAVE FILES card; nil elsewhere so - -- the mods tab cannot inherit last frame's save Import/Export/open-folder hits. - self.saveImportRect = nil - self.saveExportRect = nil - self.saveFolderRect = nil + self:_resetFrameRects() -- Fonts + size-dependent scenery, rebuilt only when the window size changes. local fontKey = ("%dx%d"):format(width, height) @@ -1936,6 +2032,16 @@ local function inside(r, x, y) return true end +-- A Delete label is armed by one click and commits on a second one on the same +-- target; it disarms on any other press and after this many seconds, because +-- nothing in the launcher can undo a delete (#433). +local DELETE_CONFIRM_SECONDS = 4 + +local function armedDelete(a, kind, id, version) + return a ~= nil and a.kind == kind and a.id == id and a.version == version + and (love.timer.getTime() - a.t) <= DELETE_CONFIRM_SECONDS +end + function RomImporter:mousepressed(x, y, button) if self._rename then return end -- the rename modal swallows all clicks -- Whether a press can be ARMED and resolved on release, which needs a @@ -1955,6 +2061,10 @@ function RomImporter:mousepressed(x, y, button) return end if button ~= 1 then return end + -- Any press that is not the second click on an armed Delete disarms it, so + -- take the arm off self up front and let the Delete loops below re-arm. + local armed = self._confirmDelete + self._confirmDelete = nil if inside(self.bcgButton, x, y) or inside(self.linkUrlRect, x, y) then love.system.openURL(COMMUNITY_URL) return @@ -2019,7 +2129,12 @@ function RomImporter:mousepressed(x, y, button) -- targets, no scroll conflict). for _, r in ipairs(self.slotDeleteRects or {}) do if inside(r, x, y) then - self:_deleteSlot(self.panelVersion, r.id) + if armedDelete(armed, "slot", r.id, self.panelVersion) then + self:_deleteSlot(self.panelVersion, r.id) + else + self._confirmDelete = { kind = "slot", id = r.id, + version = self.panelVersion, t = love.timer.getTime() } + end return end end @@ -2053,7 +2168,11 @@ function RomImporter:mousepressed(x, y, button) end for _, r in ipairs(self.modDeleteRects or {}) do if inside(r, x, y) then - self:_deleteMod(r.id) + if armedDelete(armed, "mod", r.id, nil) then + self:_deleteMod(r.id) + else + self._confirmDelete = { kind = "mod", id = r.id, t = love.timer.getTime() } + end return end end @@ -2792,8 +2911,11 @@ function RomImporter:_drawSaveSlotPanel(version, x, y, w, h, paged) local drect = { x = delX - 6 * s, y = delY - 4 * s, width = delW + 12 * s, height = delH + 8 * s, id = slot.id } local dhot = self:_hover(drect) - col(dhot and PAL.red or PAL.warning) - love.graphics.print(delText, delX, delY) + -- Armed by a first click, the label asks before a second one commits; + -- the box stays sized to "Delete" so the row never reflows (#433). + local darmed = armedDelete(self._confirmDelete, "slot", slot.id, version) + col((darmed or dhot) and PAL.red or PAL.warning) + love.graphics.printf(darmed and "Sure?" or delText, delX, delY, delW, "center") local rightReserve = delW + 18 * s -- Edit label, immediately left of Delete: opens the bundled save @@ -3150,8 +3272,10 @@ function RomImporter:_drawModsPanel(x, y, w, h, paged) width = L.delW + 12 * s, height = delH + 4 * s, id = m.id } local dhot = self:_hover(drect) love.graphics.setFont(self.hintFont) - col(dhot and PAL.red or PAL.warning) - love.graphics.print("Delete", delX, delY) + -- Same two-click arm as a save slot's Delete (#433). + local darmed = armedDelete(self._confirmDelete, "mod", m.id, nil) + col((darmed or dhot) and PAL.red or PAL.warning) + love.graphics.printf(darmed and "Sure?" or "Delete", delX, delY, L.delW, "center") -- hit rects clipped to the visible list band local vy = math.max(trect.y, top) diff --git a/src/import/SaveFileIO.lua b/src/import/SaveFileIO.lua index 7efc09c2..f6a1f04f 100644 --- a/src/import/SaveFileIO.lua +++ b/src/import/SaveFileIO.lua @@ -75,7 +75,10 @@ function SaveFileIO.importToSlot(source, version) return false, ("A save file must be %d bytes (32 KB); this one is %d.") :format(SAVE_SIZE, #bytes) end - local save, convertErr = SaveConvert.importSav(bytes, version) + -- 3rd arg: the crosswalk has to come from THIS game's ROM cache. The + -- launcher imports before the cache is mounted on the un-prefixed paths, so + -- SaveConvert cannot find the generated tables by itself here (#420). + local save, convertErr = SaveConvert.importSav(bytes, version, version) if not save then return false, convertErr end -- Tag the game version and normalize the meta stamp: SaveConvert leaves -- meta.format = "gen1_import", but SaveData.load's migration pass compares @@ -103,7 +106,7 @@ function SaveFileIO.exportActiveSlot(version) version = version or GameVersion.get() local save = SaveData.load(version) if not save then return false, "this game has no save to export yet" end - local bytes, exportErr = SaveConvert.exportSav(save) + local bytes, exportErr = SaveConvert.exportSav(save, version) if not bytes then return false, exportErr end local slotId = SaveData.activeSlot(version) or "save" local fs = love and love.filesystem diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 84f35610..3dc397bb 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -168,6 +168,21 @@ function LauncherMods.pickStrays(found, installed) return out end +-- isReadableRoot(folder, source, cacheRoot) -> is folder already on the +-- physfs read path, pure. source is love.filesystem.getSource(), cacheRoot +-- the mounted portable game folder (CacheFs.root()); a fused build has both, +-- and they are different paths (the archive inside the executable vs the +-- folder beside it). Either one's mods/ is readable already, so nothing in +-- it is a stray -- and the portable folder must additionally never be handed +-- to CacheFs.withMounted: PHYSFS_mount reports success for a directory +-- already in the search path WITHOUT adding a second entry, so the paired +-- unmount tears down the one real mount and the panel loses every mod in the +-- game folder (#413). +function LauncherMods.isReadableRoot(folder, source, cacheRoot) + if not folder or folder == "" then return false end + return folder == source or folder == cacheRoot +end + -- ------- discovery (love.filesystem) local function decodeManifest(raw, path) @@ -349,17 +364,20 @@ end local STRAY_MOUNT = "stray_scan" -- Run fn(mountedModsRoot) for each game folder that has a readable mods/ --- directory, one mount at a time. Folders that are already the physfs source --- are skipped: their mods/ is discoverable by definition, so anything there is --- installed already and not a stray (this is every `love ` dev run). +-- directory, one mount at a time. Folders already on the read path are +-- skipped (isReadableRoot): the physfs source, which is every `love ` +-- dev run, and the portable game folder CacheFs mounted, where re-mounting is +-- what used to drop the mount (#413). local function eachStrayRoot(fn) local SaveData_ = require("src.core.SaveData") local fs = love and love.filesystem if not fs then return end local source = fs.getSource and fs.getSource() + local cacheRoot = CacheFs.root() local seen = {} for _, folder in ipairs(SaveData_.gameFolders() or {}) do - if not seen[folder] and folder ~= source then + if not seen[folder] + and not LauncherMods.isReadableRoot(folder, source, cacheRoot) then seen[folder] = true CacheFs.withMounted(folder, STRAY_MOUNT, function() local root = STRAY_MOUNT .. "/mods" diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index b9b050df..c6f3a5f1 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -11,9 +11,14 @@ -- tables load through `require`, exactly how src/core/Data.lua pulls the -- generated modules -- which resolves under both plain luajit (package.path -- "./?.lua") for the headless CLI/tests and love.filesystem for a fused --- build, with an OS-path fallback for odd working directories. The only --- place `love` is referenced is inside a guarded fallback, so running under --- stock Lua never touches it. +-- build, with an OS-path fallback for odd working directories. `love` is only +-- ever referenced inside guarded fallbacks, so running under stock Lua never +-- touches it. +-- +-- When a caller names the game a save belongs to, the generated tables come +-- out of that version's ROM cache through CacheFs instead: the launcher does +-- its importing before the cache is mounted onto the un-prefixed paths, so +-- require alone cannot see them there (#420). local GenSave = require("src.save_convert.GenSave") @@ -63,20 +68,59 @@ local function loadTable(requirePath, filePath) :format(requirePath, requirePath, filePath) end -local crosswalk -- { pokemon=, moves=, items=, maps=, eventFlags= } +-- The four generated tables live in one game's ROM cache, and the launcher +-- reaches this code before that cache is on the un-prefixed read path: +-- CacheFs.mountVersion only runs from main.lua's bootGame (after Play), and +-- Blue/Yellow keep their cache under GameVersion.cachePrefix. So a bare +-- require sees Red's copy at best, and nothing at all in a fused portable +-- build (the game folder is only readable through CacheFs's PhysFS mount) -- +-- read the tables out of the cache whenever the caller names the game the +-- save belongs to, and let the require path above cover everything else +-- (#420). +local function loadCacheTable(gameVersion, filePath) + if not (gameVersion and love and love.filesystem) then return nil end + if not filePath:match("^data/generated/") then return nil end + local okc, CacheFs = pcall(require, "src.import.CacheFs") + local okg, GameVersion = pcall(require, "src.core.GameVersion") + if not (okc and okg and type(CacheFs) == "table") then return nil end + local info = GameVersion.VERSIONS[gameVersion] + if not info then return nil end + -- CacheFs.prefix is launcher-owned global state (it points at whatever + -- import last ran), so borrow it for this read and put it back. + local saved = CacheFs.prefix + CacheFs.prefix = info.cachePrefix + local okr, bytes = pcall(CacheFs.read, filePath) + CacheFs.prefix = saved + if not (okr and type(bytes) == "string") then return nil end + local chunk = loadstring(bytes, "@" .. info.cachePrefix .. filePath) + if not chunk then return nil end + local okx, mod = pcall(chunk) + if okx and type(mod) == "table" then return mod end + return nil +end + +-- Crosswalk sets keyed by the game whose cache they came from ("*" for the +-- require-resolved set): Yellow's tables are not Red's, so one import must +-- never be handed the previous import's data (#420). +local crosswalks = {} -- [key] = { pokemon=, moves=, items=, maps=, eventFlags= } local charmapReady -local function ensureData() - if not crosswalk then +local function ensureData(gameVersion) + local key = gameVersion or "*" + if not crosswalks[key] then local data = {} - for key, spec in pairs(DATA_MODULES) do - if key ~= "charmap" then - local mod, e = loadTable(spec[1], spec[2]) - if not mod then return nil, e end - data[key] = mod + for name, spec in pairs(DATA_MODULES) do + if name ~= "charmap" then + local mod = loadCacheTable(gameVersion, spec[2]) + if not mod then + local e + mod, e = loadTable(spec[1], spec[2]) + if not mod then return nil, e end + end + data[name] = mod end end - crosswalk = data + crosswalks[key] = data end if not charmapReady then local cm, err = loadTable(DATA_MODULES.charmap[1], DATA_MODULES.charmap[2]) @@ -84,13 +128,14 @@ local function ensureData() GenSave.setCharmap(cm) charmapReady = true end - return crosswalk + return crosswalks[key] end -- Exposed for the CLI/tests so they can share the exact data set the codec --- uses (and so a caller can pre-warm the cache). Returns data, err. -function SaveConvert.loadData() - return ensureData() +-- uses (and so a caller can pre-warm the cache). gameVersion picks whose ROM +-- cache the generated tables come from. Returns data, err. +function SaveConvert.loadData(gameVersion) + return ensureData(gameVersion) end -- ------------------------------------------------------------------ @@ -138,20 +183,22 @@ SaveConvert.mergeDefaults = mergeDefaults -- Public API -- ------------------------------------------------------------------ --- importSav(bytes, version) -> saveTable, err +-- importSav(bytes, version, gameVersion) -> saveTable, err -- bytes: the raw 32768-byte SRAM string. Validates size and the main-data -- checksum, decodes through GenSave, and returns a save table fully merged -- over the new-game defaults and tagged with `version`, ready to hand to --- SaveSerializer.encode for a slot file. On any failure returns nil + a --- message (never raises). -function SaveConvert.importSav(bytes, version) +-- SaveSerializer.encode for a slot file. gameVersion ("red"/"blue"/"yellow") +-- names the game the save is being imported for, which is what selects the +-- crosswalk tables; omit it to take whatever `require` resolves. On any +-- failure returns nil + a message (never raises). +function SaveConvert.importSav(bytes, version, gameVersion) if type(bytes) ~= "string" then return nil, "expected raw save bytes as a string" end if #bytes ~= GenSave.SAVE_SIZE then return nil, ("save must be %d bytes, got %d"):format(GenSave.SAVE_SIZE, #bytes) end - local data, derr = ensureData() + local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, decoded = pcall(GenSave.decode, bytes, data) @@ -170,16 +217,17 @@ function SaveConvert.importSav(bytes, version) return mergeDefaults(decoded, version) end --- exportSav(saveTable) -> bytes, err +-- exportSav(saveTable, gameVersion) -> bytes, err -- Encodes a save table back to a raw 32768-byte SRAM image. Template-aware: -- if the table still carries the stashed import template (saveTable.rawImport) -- GenSave reproduces every unmodeled region from it; otherwise those regions --- are zero-filled. On failure returns nil + a message (never raises). -function SaveConvert.exportSav(saveTable) +-- are zero-filled. gameVersion selects the crosswalk tables exactly as in +-- importSav. On failure returns nil + a message (never raises). +function SaveConvert.exportSav(saveTable, gameVersion) if type(saveTable) ~= "table" then return nil, "expected a save table" end - local data, derr = ensureData() + local data, derr = ensureData(gameVersion) if not data then return nil, derr end local ok, bytes = pcall(GenSave.encode, saveTable, data, nil) if not ok then return nil, "encode failed: " .. tostring(bytes) end diff --git a/src/ui/SummaryMenu.lua b/src/ui/SummaryMenu.lua index 10262f61..8dc573bb 100644 --- a/src/ui/SummaryMenu.lua +++ b/src/ui/SummaryMenu.lua @@ -43,12 +43,13 @@ function SummaryMenu.new(game, mon) Stats.ensure(game.data.pokemon[mon.species], mon) local self = setmetatable({ game = game, mon = mon, page = 1 }, SummaryMenu) local Sprites = require("src.pokemon.Sprites") - local path = Sprites.path(game.data, mon.species, "front", + local path, trueColor = Sprites.path(game.data, mon.species, "front", { mon = mon, kind = "summary" }) if path then local ok, img = pcall(love.graphics.newImage, path) self.sprite = ok and img or nil end + self.spriteTrueColor = self.sprite and trueColor or false require("src.core.Sound").playCry(game.data, mon.species) return self end @@ -111,8 +112,14 @@ function SummaryMenu:draw() -- the same routine the intro's NIDORINO show-off uses (OakSpeech picFlip: -- negative x scale anchored at the pic's right edge). #280 if self.sprite then - love.graphics.draw(self.sprite, 8 + self.sprite:getWidth(), - math.max(0, 56 - self.sprite:getHeight()), 0, -1, 1) + local pw, ph = self.sprite:getDimensions() + local py = math.max(0, 56 - ph) + love.graphics.draw(self.sprite, 8 + pw, py, 0, -1, 1) + -- a full-color pic has to sit out the SGB monPal recolor, so mark the + -- rect the mirrored draw covers for the unshaded pass (#430) + if self.spriteTrueColor then + require("src.render.PaletteFX").markTrueColor(8, py, pw, ph) + end end local HudTiles = require("src.render.HudTiles") love.graphics.setColor(0, 0, 0, 1) diff --git a/src/ui/YellowIntro.lua b/src/ui/YellowIntro.lua index 5a4e3c31..17d53eed 100644 --- a/src/ui/YellowIntro.lua +++ b/src/ui/YellowIntro.lua @@ -574,10 +574,14 @@ function YellowIntro:beginScenes() self:startScene(0) end +-- The movie's exit never stops the song (intro_yellow.asm PlayIntroScene +-- .go_to_title_screen, where the A/B/START skip lands too): Music_YellowIntro +-- outlasts the scenes and plays on into the title setup until title.asm's +-- StopAllMusic before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands +-- in for (#436) function YellowIntro:finish() if self.finished then return end self.finished = true - pcall(Music.stop) self.game.stack:pop() if self.onDone then self.onDone() end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index e2719c3e..2d5db70f 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -366,7 +366,7 @@ function OverworldState:setMap(mapId, x, y, facing, opts) 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) + require("src.world.PikachuFollower").onMapEntered(Game, self, opts) -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7); @@ -899,7 +899,12 @@ function OverworldState:update(dt) -- the emotion-bubble pause holds the world for a beat if self.emote then self.emote.frames = self.emote.frames - 1 - if self.emote.frames <= 0 then + -- PikaPicAnimTimerAndJoypad (engine/pikachu/pikachu_pic_animation.asm) + -- cuts a pikapic beat short on A or B; the "!" bubble hold has no such + -- check, so only the pikapic marks itself skippable (#424) + local cut = self.emote.skippable + and (Game.input:wasPressed("a") or Game.input:wasPressed("b")) + if cut or self.emote.frames <= 0 then local done = self.emote.onDone self.emote = nil if done then done() end @@ -1291,7 +1296,14 @@ function OverworldState:crossConnection(dir, conn) -- still protects the visible step when neighbor rebuild or the sync -- fallback stalls, and avoids the rare one-frame volume spike from a -- song swap mid-step. - self:setMap(conn.map, x, y, p.facing, { seamless = true, keepMusic = true }) + -- Yellow's follower crosses the seam as one continuous walk, so hand the + -- live instance through setMap (which rebuilds self.npcs) instead of + -- letting it respawn behind the player (#427) + local PikachuFollower = require("src.world.PikachuFollower") + local pika = PikachuFollower.current(self) + local fromX, fromY = p.cellX, p.cellY + self:setMap(conn.map, x, y, p.facing, + { seamless = true, keepMusic = true, keepPikachu = pika }) self.pendingSeamMusic = conn.map -- place the player one cell before the seam (their old world spot, -- which the neighbor strip renders identically) and start the step @@ -1301,6 +1313,8 @@ function OverworldState:crossConnection(dir, conn) local d = DIRVEC[dir] p.cellX, p.cellY = x - d[1], y - d[2] p.px, p.py = p.cellX * 16, p.cellY * 16 + -- same translation for the follower and the cell it is chasing + PikachuFollower.rebase(self, p.cellX - fromX, p.cellY - fromY) self.camera:follow(p.px, p.py) p.facing = dir p.targetX, p.targetY = x, y @@ -4461,9 +4475,10 @@ function OverworldState:drawUI() -- TalkToPikachu's picture box (engine/pikachu/pikachu_pic_animation.asm -- PlacePikapicTextBoxBorder: TextBoxBorder at (6,5) with b,c = 5,5, so a -- 7x7 box holding the 5x5 pic at (7,6) -- PikaAnimTilemap_1). The - -- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so - -- the front pic holds for the whole beat while the cry and any emote - -- bubble play over the world below (#407). + -- per-emotion frame gfx (gfx/pikachu/unknown_*) are not extracted, so the + -- front pic stands in for every frame of the script; PikachuFollower + -- .picLift lifts it on the runs that draw the alternate pose, and the + -- script's own duration times the beat (#407, #424). if self.emote and self.emote.pikaPic then require("src.render.Font").drawBox(6, 5, 7, 7) -- one image per path, cached: this draws every frame of the hold, and @@ -4477,8 +4492,9 @@ function OverworldState:drawUI() if img then love.graphics.setColor(1, 1, 1, 1) local w, h = img:getDimensions() + local lift = require("src.world.PikachuFollower").picLift(self.emote) love.graphics.draw(img, math.floor(56 + (40 - w) / 2), - math.floor(48 + (40 - h) / 2)) + math.floor(48 + (40 - h) / 2) - lift) end end diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 79f7bf88..93f44841 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -181,9 +181,29 @@ local function spawnCell(ow) return p.cellX, p.cellY end -function PikachuFollower.onMapEntered(game, ow) +-- the live follower, for a caller that has to carry it across a setMap +-- that rebuilds ow.npcs (OverworldState:crossConnection, #427) +function PikachuFollower.current(ow) + local npc = findFollower(ow) + return npc +end + +function PikachuFollower.onMapEntered(game, ow, opts) remove(ow) if not shouldSpawn(game, ow) then return end + -- opts.keepPikachu is the follower a connection crossing kept alive: + -- LoadMapHeader's connection path sets wPikachuSpawnState = 2 and bit 4 + -- of wPikachuOverworldStateFlags, so SchedulePikachuSpawnForAfterText + -- 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. + local keep = opts and opts.keepPikachu + if keep then + table.insert(ow.npcs, keep) + table.insert(ow.entities, keep) + return + end local x, y = spawnCell(ow) local npc = makeFollower(game, ow, x, y, ow.player.facing) table.insert(ow.npcs, npc) @@ -271,6 +291,14 @@ end local function idleTick(ow, npc) -- Func_fc82e: a step in progress ends the idle state outright if ow.player.moving then idleReset(npc) return end + -- Every counter below burns one unit per UpdateSprites call, and a + -- standing OverworldLoop spends two DelayFrames on each pass (home/ + -- overworld.asm: OverworldLoop delays, falls into OverworldLoopLessDelay + -- which delays again, then .noDirectionButtonsPressed loops back), so the + -- whole Func_fc803 family runs at half this port's 60Hz fixed step: the + -- first glance is $20 CALLS, 64 frames, not 32 (#424). + npc.idleClock = ((npc.idleClock or 0) + 1) % 2 + if npc.idleClock ~= 0 then return end local idle = npc.idle if not idle then idle = { kind = "wait", frames = IDLE_LOOK } @@ -339,6 +367,28 @@ local function ledgeStep(game, ow, cx, cy, dir) return false end +-- Slide the follower into the connected map's coordinate frame by the +-- delta crossConnection applied to the player: the two maps are one +-- continuous world, so a seam is a pure translation. MapX/MapY in +-- wSpritePikachuStateData2 are all .normal_spawn_state rewrites there, +-- never the pixel coords, which is why the original walks through a seam +-- instead of popping (#427). +function PikachuFollower.rebase(ow, dx, dy) + local npc = findFollower(ow) + if npc then + npc.cellX, npc.cellY = npc.cellX + dx, npc.cellY + dy + npc.px, npc.py = npc.px + dx * 16, npc.py + dy * 16 + if npc.targetX then npc.targetX = npc.targetX + dx end + if npc.targetY then npc.targetY = npc.targetY + dy end + if npc.goalX then npc.goalX = npc.goalX + dx end + if npc.goalY then npc.goalY = npc.goalY + dy end + -- Func_fc82e: the player is taking a step, so any idle pose is over + if npc.idle then idleReset(npc) end + end + local trail = ow.pikachuTrail + if trail then trail.x, trail.y = trail.x + dx, trail.y + dy end +end + -- one follow step per frame: chase the cell the player last vacated -- (pikachu_follow.asm keeps it one walk step behind) function PikachuFollower.update(game, ow) @@ -371,8 +421,27 @@ function PikachuFollower.update(game, ow) local destX = p.targetX or p.cellX local destY = p.targetY or p.cellY if destX ~= trail.x or destY ~= trail.y then - npc.goalX, npc.goalY = trail.x, trail.y - trail.x, trail.y = destX, destY + local stepDir = destY > trail.y and "down" or destY < trail.y and "up" + or destX > trail.x and "right" or "left" + -- A ledge hop commits TWO steps (checkLedgeHop -> scriptMove(p, dir, 2), + -- the two simulated presses of HandleLedges) but only ONE follow + -- command: Func_fcc08 sees BIT_LEDGE_OR_FISHING and defers to + -- Func_fcc64, which appends the $5-$8 hop on the takeoff step and + -- appends nothing on the landing step (bit 6 of + -- wPikachuOverworldStateFlags toggles between the two). With no command + -- behind it the hop cannot leave the buffer -- Func_fcc92 only pops once + -- a second command is queued -- so Pikachu walks up to the cell the + -- player took off from, waits there two cells behind (the Func_fc842 + -- idle rolls), and hops one player step later (#424, after #409). + if trail.ledgeHop == stepDir then + trail.ledgeHop = nil + trail.x, trail.y = destX, destY + else + trail.ledgeHop = ledgeStep(game, ow, trail.x, trail.y, stepDir) + and stepDir or nil + npc.goalX, npc.goalY = trail.x, trail.y + trail.x, trail.y = destX, destY + end end -- standing still with nothing to chase is the idle state (Func_fc803); -- once a step is under way NPC:update owns px/py, so only the idle @@ -404,7 +473,9 @@ function PikachuFollower.update(game, ow) npc.targetX = npc.cellX + (dir == "right" and 1 or dir == "left" and -1 or 0) npc.targetY = npc.cellY + (dir == "down" and 1 or dir == "up" and -1 or 0) -- the cell ahead is the ledge the player hopped: clear both cells in one - -- step instead of stopping on the ledge (#409). pikachu_follow.asm + -- step instead of stopping on the ledge (#409). The trail above holds + -- this back until the player commits a further step, so it fires from the + -- cell on top of the ledge, a step late (#424). pikachu_follow.asm -- Func_fcc08 appends the $5-$8 hop commands while BIT_LEDGE_OR_FISHING -- is set, and Func_fca0a runs them as two AddPikachuStepVector cells over -- one normal step's frames -- no arc and no shadow, the hop command only @@ -422,7 +493,12 @@ function PikachuFollower.update(game, ow) -- steps are queued (AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer: -- walk counter $4 instead of NormalPikachuFollow's $8). local stepLen = p.stepFramesCur or p.stepFrames or 16 - if far > 1 then stepLen = math.max(1, math.floor(stepLen / 2)) end + -- the hop is never a Fast step: Func_fc7aa jumps to Func_fca0a on the $4 + -- movement status BEFORE it asks AreThereAtLeastTwoSteps..., so its two + -- cells ride one normal step's frames even though the goal is two away. + if far > 1 and not npc.hopStep then + stepLen = math.max(1, math.floor(stepLen / 2)) + end npc.stepFrames = stepLen npc.moving = true npc.progress = 0 @@ -500,6 +576,60 @@ local MOOD_MATRIX = { -- .Emotions): scripted one-shots -- 21 is the fishing-rod reaction local MODIFIER_EMOTIONS = { 18, 21, 23, 24, 25 } +-- ExecutePikaPicAnimScript spends a Delay3 on every pass of its loop +-- (pikachu_pic_animation.asm PikaPicAnimTimerAndJoypad), so one script tick +-- is three 60Hz frames: the flat 50 frame hold this port used was under a +-- third of even the shortest script (#424). +local PIKAPIC_TICK = 3 +local PIKAPIC_LIFT = 4 -- px the stand-in pic rises on an overlay run + +-- pikaemotion_pikapic's script id per emotion: emotion N takes +-- PikaPicAnimScript N, except the four listed here (data/pikachu/ +-- pikachu_emotions.asm). +local PIKAPIC_SCRIPT = { [29] = 10, [30] = 20, [31] = 23, [32] = 23 } + +-- Per script: pikapic_setduration's tick count, and for the scripts whose +-- overlay is a whole second pose, that frameset's run lengths in ticks +-- (data/pikachu/pikachu_pic_objects.asm PikaPicAnimBGFrames_*, which script +-- N reaches as frameset N+5, or N+6 from script 10 up). The list alternates +-- pikaframedelay (the base pic alone) and pikaframe (the overlay) starting +-- with a delay, so a frameset that opens on a pikaframe opens with a zero +-- here; the frameset restarts until pikapic_looptofinish runs the duration +-- out. Scripts 1, 2, 3, 5, 6, 8 and 9 are left without a list on purpose: +-- their overlays (PikaAnimTilemap_14 to _22) only paint a few tiles over a +-- pic that otherwise stands still, so with no tiles to paint the port has +-- nothing to show for them and must not bob the whole picture instead. +local PIKAPIC = { + [1] = { dur = 40 }, + [2] = { dur = 44 }, + [3] = { dur = 80 }, + [4] = { dur = 70, seq = { 8, 8, 20, 8 } }, + [5] = { dur = 32 }, + [6] = { dur = 50 }, + [7] = { dur = 58, seq = { 0, 8, 2, 8, 2, 8 } }, + [8] = { dur = 44 }, + [9] = { dur = 56 }, + [10] = { dur = 56, seq = { 8, 11, 5 } }, + [11] = { dur = 100, seq = { 20, 8, 20, 8 } }, + [12] = { dur = 50, seq = { 13, 12, 100, 8 } }, + [13] = { dur = 50, seq = { 5, 5, 5, 5, 100 } }, + [14] = { dur = 40, seq = { 2, 2, 2, 2 } }, + [15] = { dur = 50, seq = { 5, 5, 5, 5 } }, + [16] = { dur = 32, seq = { 0, 8, 100 } }, + [17] = { dur = 100, seq = { 10, 3, 3, 3, 100 } }, + [18] = { dur = 32, seq = { 3, 100, 8, 8 } }, + [19] = { dur = 44, seq = { 0, 6, 6, 6, 6 } }, + [20] = { dur = 50, seq = { 8, 12, 8, 12 } }, + [21] = { dur = 40, seq = { 8, 104 } }, + [22] = { dur = 40, seq = { 8, 100 } }, + [23] = { dur = 70, seq = { 16, 16, 16, 16 } }, + [24] = { dur = 60, seq = { 6, 6, 6, 6, 100 } }, + [25] = { dur = 50, seq = { 6, 106 } }, + [26] = { dur = 100, seq = { 20, 8, 20, 116 } }, + [27] = { dur = 30, seq = { 4, 100 } }, + [28] = { dur = 64, seq = { 12, 12, 12, 100 } }, +} + local function moodEmotion(save) local mood = save.pikachuMood or 128 local column = 5 @@ -579,17 +709,38 @@ function PikachuFollower.talk(game, ow, npc, done) -- ends with one, and its box is the only thing most of them put on -- screen (emotion 5, the fresh-save cell, has no bubble at all). The -- 40x40 front pic is the size of PikaAnimTilemap_1's 5x5 base frame; - -- Sprites.path keeps a mod's replacement skin in play. The scripts' - -- 32-58 frame durations bracket the hold below, so it stays at 50. + -- Sprites.path keeps a mod's replacement skin in play. local Sprites = require("src.pokemon.Sprites") local pic = Sprites.path(game.data, "PIKACHU", "front", { kind = "overworld" }) + local anim = PIKAPIC[PIKAPIC_SCRIPT[emotion] or emotion] or PIKAPIC[1] + local hold = anim.dur * PIKAPIC_TICK ow.emote = { - npc = npc, frames = 50, bubble = bi or false, pikaPic = pic, - onDone = done, + npc = npc, frames = hold, bubble = bi or false, pikaPic = pic, + pikaSeq = anim.seq, pikaTotal = hold, skippable = true, onDone = done, } end +-- Where the framed pic sits this frame. The overlay a pikaframe run draws +-- is a second full-body pose (PikaAnimTilemap_23 and up replace all 5x5 +-- tiles) out of gfx/pikachu/unknown_*, which the cache does not carry, so +-- the port lifts the one pic it has for the length of those runs -- the jump +-- the happy emotions make inside the box (#424, still on #407's stand-in). +function PikachuFollower.picLift(emote) + local seq = emote and emote.pikaSeq + if not seq then return 0 end + local loop = 0 + for _, run in ipairs(seq) do loop = loop + run end + if loop <= 0 then return 0 end + local elapsed = math.max(0, (emote.pikaTotal or 0) - (emote.frames or 0)) + local tick = math.floor(elapsed / PIKAPIC_TICK) % loop + for i, run in ipairs(seq) do + if tick < run then return i % 2 == 0 and PIKAPIC_LIFT or 0 end + tick = tick - run + end + return 0 +end + -- --------------------------------------------------------------------- -- PikachuWalksToNurseJoy (engine/pikachu/pikachu_emotions.asm, run by -- engine/events/pokecenter.asm once the heal is accepted): the companion diff --git a/tests/drivers/battle_shake_bug354_test.lua b/tests/drivers/battle_shake_bug354_test.lua index 70c52f98..c71d62a7 100644 --- a/tests/drivers/battle_shake_bug354_test.lua +++ b/tests/drivers/battle_shake_bug354_test.lua @@ -102,6 +102,18 @@ return function(game) battle.enemy.mon.hp = 400 battle.enemy.shownHP = 400 + -- A PIDGEY that level knows WHIRLWIND, and in a wild battle that blows the + -- player out of the fight (result "run") the moment the foe picks it, which + -- takes the untested types down with it. Drop it and the foe keeps the + -- GUST / SAND-ATTACK / QUICK ATTACK the hand-off notes below describe. + -- curMoves aliases mon.moves, so one pass covers both. + for i = #battle.enemy.mon.moves, 1, -1 do + local id = battle.enemy.mon.moves[i].id + if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then + table.remove(battle.enemy.mon.moves, i) + end + end + -- ---- watch the fx layer ------------------------------------------------ -- one entry per applying-attack row, labelled with the move that queued it local rows, cur, using = {}, nil, nil @@ -131,10 +143,13 @@ return function(game) end end - -- step n frames, sampling every one, pressing A every `mash` frames + -- step n frames, sampling every one, pressing A every `mash` frames -- but + -- only while a box is up. An A that lands on the FIGHT menu opens the move + -- list and fires the highlighted move, so mashing past the end of a turn + -- starts extra turns behind the driver's back and eventually ends the battle. local function pump(n, mash, stop) for i = 1, n do - if mash and i % mash == 0 then + if mash and i % mash == 0 and battle.phase == "messages" then table.insert(game.input.pressQueue, "a") end U.wait(1) @@ -144,16 +159,34 @@ return function(game) end end + -- a full turn is the player's announcement, animation and damage drain plus + -- the foe's, so budget frames for the slowest of them rather than the typical + -- one: a short budget hands the next move a battle still in `messages` local function toMenu() - pump(400, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) + pump(1200, 6, function() return battle.phase == "menu" and #battle.queue == 0 end) return battle.phase == "menu" end - -- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where up/down - -- walk the slots. A press that lands on a frame the battle is not reading - -- input is simply lost, so every step retries instead of assuming. + -- The move list is a flat column in the classic layout, but the wide one + -- lays the four slots out 2x2 (WideBattle.navigate): up/down swap rows and + -- left/right swap columns, so walking down the list there never reaches + -- slots 2 and 4. Return the press that closes the gap in either layout. + local function towardSlot(slot) + if battle.moveIndex == slot then return nil end + if battle:wideLayout() then + local row, col = math.floor((battle.moveIndex - 1) / 2), (battle.moveIndex - 1) % 2 + local wantRow, wantCol = math.floor((slot - 1) / 2), (slot - 1) % 2 + if row ~= wantRow then return row < wantRow and "down" or "up" end + return col < wantCol and "right" or "left" + end + return battle.moveIndex < slot and "down" or "up" + end + + -- FIGHT is menuIndex 1 of the 2x2 grid; A opens moveSelect, where the presses + -- above walk the slots. A press that lands on a frame the battle is not + -- reading input is simply lost, so every step retries instead of assuming. local function useMove(slot) - for _ = 1, 40 do + for _ = 1, 80 do if battle.phase == "moveSelect" then break end if battle.phase == "menu" then if battle.menuIndex ~= 1 then @@ -161,13 +194,17 @@ return function(game) else U.tap(game, "a") end + else + -- the last turn's boxes are still up: keep turning pages + U.tap(game, "a") end U.wait(4) end if battle.phase ~= "moveSelect" then return false end for _ = 1, 30 do - if battle.moveIndex == slot then break end - U.tap(game, battle.moveIndex < slot and "down" or "up") + local dir = towardSlot(slot) + if not dir then break end + U.tap(game, dir) U.wait(3) end if battle.moveIndex ~= slot then return false end @@ -193,15 +230,22 @@ return function(game) check("the battle reached its FIGHT menu", toMenu()) for _, m in ipairs(MOVES) do - local sent = useMove(m.slot) - check("chose " .. m.id .. " from the move menu", sent) - if sent then - -- catch the shake mid-flight for the screenshot: the offset is live for - -- only a couple of dozen frames - local shotAt + -- catch the shake mid-flight for the screenshot: the offset is live for + -- only a couple of dozen frames + local shotAt + -- HYPNOSIS lands 6 times in 10 and TACKLE 19 in 20, and a miss plays no + -- applying-attack animation at all, so give each move a few turns to + -- connect instead of reading a whiffed one as a regression + local sent, r + for _ = 1, 8 do + -- the foe leans on SAND-ATTACK, and stacked accuracy drops make even + -- TACKLE whiff turn after turn, so hand the accuracy back each try + battle.player.stages.accuracy = 0 + sent = useMove(m.slot) + if not sent then break end -- A every 8 frames: the text box between the announcement and the -- animation waits on the button like any other - pump(400, 8, function() + pump(1200, 8, function() local live = battle.fx and (m.peak and (battle.fx.shakeX or 0) ~= 0 or (not m.peak and battle.fx.blink and battle.fx.blink.frames > 0)) @@ -209,12 +253,19 @@ return function(game) shotAt = true U.shot(game, DIR .. "/" .. SHOTS[m.id]) end - local r = lastRowFor(m.id) - return r ~= nil and battle.fx.shakeProg == nil - and (r.frames > 0 or r.blinked) + local row = lastRowFor(m.id) + -- back at the menu with the row in hand: the turn is over either way + if row and battle.phase == "menu" and #battle.queue == 0 then return true end + return row ~= nil and battle.fx.shakeProg == nil + and (row.frames > 0 or row.blinked) end) toMenu() - local r = lastRowFor(m.id) + r = lastRowFor(m.id) + if r then break end + U.log(" " .. m.id .. " did not connect that turn; using it again") + end + check("chose " .. m.id .. " from the move menu", sent) + if sent then check(m.id .. " queued an applying-attack row", r ~= nil) if r then check(("%s is animation type %d (got %s)") diff --git a/tests/drivers/blackout_no_party_bug425_test.lua b/tests/drivers/blackout_no_party_bug425_test.lua new file mode 100644 index 00000000..9b7384e6 --- /dev/null +++ b/tests/drivers/blackout_no_party_bug425_test.lua @@ -0,0 +1,182 @@ +-- Walking into grass with no useable POKeMON must black the player out, not +-- cancel the battle and hand the map back (#425). pokered core.asm:158-162 +-- runs .checkAnyPartyAlive after the intro and jumps to HandlePlayerBlackOut +-- (core.asm:1145-1166, PlayerBlackedOutText2). Machine half also in +-- tests/parity_blackout_no_party.lua. Never under POKEPORT_SPEED: the wipe, +-- the theme change and the two text boxes come apart from each other. +-- POKEPORT_DRIVER=tests/drivers/blackout_no_party_bug425_test.lua POKEPORT_IDENTITY=bug425 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Music = require("src.core.Music") + local BattleState = require("src.battle.BattleState") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local MAP = "ROUTE_1" + 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 waitFor(fn, limit) + for _ = 1, limit do + if fn() then return true end + U.wait(1) + end + return false + end + + -- every song request in order, so "the battle theme was never restored" + -- and "the map theme was never playing" read differently in the log + local songs = {} + local realPlay = Music.play + Music.play = function(data, song, loop, ctx) + songs[#songs + 1] = { song = song, frame = U.frame() } + U.log(("frame %d play %s"):format(U.frame(), tostring(song))) + return realPlay(data, song, loop, ctx) + end + local function playedAfter(song, frame) + for _, s in ipairs(songs) do + if s.song == song and s.frame >= frame then return s end + end + return nil + end + + local opts = game.save.options + if (opts and opts.musicVol or 0) == 0 then + U.log("MUSIC VOLUME IS 0: the theme swap this bug is about is silent.") + U.log("Raise it in OPTION, quit and rerun; the log half still holds.") + end + if (opts and opts.sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: the wipe and the text beeps will be") + U.log("silent too.") + end + + -- MAGIKARP with SPLASH only would still be a battle; 0 HP is the state + -- under test, and money is the blackout's other visible half + game.save.party = { Pokemon.new(game.data, "MAGIKARP", 10) } + local mon = game.save.party[1] + local full = mon.stats.hp + mon.hp = 0 + game.save.money = 3000 + + -- Route 1's first grass row. data/generated/maps.lua holds the tiles + -- (pokered data/maps/objects/Route1.asm has no grass in it), so the cell is + -- checked against the loaded map and rescanned if a map edit moved it. + local GRASS = { x = 10, y = 6 } + U.teleport(game, MAP, GRASS.x, GRASS.y, "down") + local ow = game.overworld + local function grassAt(cx, cy) + return ow.map:isGrassCell(cx, cy) and ow.map:isWalkableCell(cx, cy) + end + if not grassAt(GRASS.x, GRASS.y) then + local found + for y = 0, ow.map.heightCells - 1 do + for x = 0, ow.map.widthCells - 1 do + -- a neighbouring grass cell too, so the hand-off half below has + -- somewhere to step without leaving the patch + if grassAt(x, y) and grassAt(x + 1, y) then found = { x = x, y = y } break end + end + if found then break end + end + if found then + U.log(("(%d, %d) is not grass any more, standing on") + :format(GRASS.x, GRASS.y), found.x, found.y) + GRASS = found + U.teleport(game, MAP, found.x, found.y, "down") + ow = game.overworld + end + end + check("the player is standing in " .. MAP .. " grass", + grassAt(GRASS.x, GRASS.y)) + + local heal = ow:healPoint() + U.log("heal point:", heal.map, heal.x, heal.y) + local MAP_SONG = game.data.audio.mapSongs[MAP] + local HEAL_SONG = game.data.audio.mapSongs[heal.map] + local WILD_SONG = game.data.audio.battle.wild + U.log("songs:", tostring(MAP_SONG), tostring(WILD_SONG), tostring(HEAL_SONG)) + + -- the encounter OverworldState:checkEncounter would build for this cell + local encDef = game.data.encounters[MAP] + local slots = encDef and encDef.grass and encDef.grass.slots + check(MAP .. " has a grass encounter table", slots ~= nil and #slots > 0) + if not slots then + U.log("Nothing can be encountered here; rerun after a ROM re-import.") + while true do coroutine.yield() end + end + + local start = U.frame() + local battle = BattleState.newWild(game, slots[1].species, slots[1].level) + check("the encounter starts out flagged dead (no healthy party)", + battle.dead == true) + local got + battle.onFinish = function(result) + got = result + ow:afterBattle(result, battle) + end + ow:pushBattle(battle) + check("the wipe started the battle theme", + waitFor(function() return playedAfter(WILD_SONG, start) ~= nil end, 120)) + + local function box() + local top = game.stack:top() + return getmetatable(top) == TextBox and top or nil + end + local shown = waitFor(function() return box() ~= nil end, 600) + check("the blackout text came up over the map", shown) + if shown then + local lines = {} + for _, page in ipairs(box().pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end + end + local text = table.concat(lines, " / ") + U.log("box reads:", text) + check("it is PlayerBlackedOutText2, both paragraphs", + text:find("out of", 1, true) ~= nil + and text:find("blacked", 1, true) ~= nil) + U.wait(60) + U.shot(game, SHOT_DIR .. "/bug425_blackout.png") + U.log("captured", SHOT_DIR .. "/bug425_blackout.png") + end + + -- page both paragraphs, then let the warp run + for _ = 1, 12 do + if not box() then break end + U.tap(game, "a") + U.wait(20) + end + local warped = waitFor(function() + return game.overworld and game.overworld.map + and game.overworld.map.id == heal.map + and not game.overworld.transitioning + end, 900) + check("the player was warped to the heal point map " .. heal.map, warped) + check("onFinish reported a loss, not a cancelled battle", got == "lose") + check("the party was revived", mon.hp == full) + check("half the money is gone (3000 -> 1500)", game.save.money == 1500) + if HEAL_SONG then + check("the heal point's own theme replaced the battle theme", + playedAfter(HEAL_SONG, start) ~= nil) + end + local stillBattle = songs[#songs] and songs[#songs].song == WILD_SONG + check("the battle theme is not what is still playing", not stillBattle) + U.log(("%d passed, %d failed"):format(pass, fail)) + + -- re-arm for the hand-off: same 0 HP party, back in the grass + mon.hp = 0 + game.save.money = 3000 + U.teleport(game, MAP, GRASS.x, GRASS.y, "down") + U.log("MAGIKARP is at 0 HP again and you are back in the Route 1 grass.") + U.log("Step around until an encounter rolls: the wipe and the battle theme,") + U.log("then the two boxes over the map with the route theme back under them,") + U.log("then the warp to Pallet Town. Landing back in the grass with the") + U.log("battle music looping, over and over, is the bug.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/jessie_james_mtmoon_bug423_test.lua b/tests/drivers/jessie_james_mtmoon_bug423_test.lua new file mode 100644 index 00000000..55ae4b46 --- /dev/null +++ b/tests/drivers/jessie_james_mtmoon_bug423_test.lua @@ -0,0 +1,171 @@ +-- Eye check on the Mt Moon B2F Jessie & James ambush (#423): the duo walk up +-- to the player and leave behind a fade. pokeyellow scripts/MtMoonB2F.asm +-- MtMoonB2FScript_49e15 simulates PAD_UP, Script6/Script9 walk Jessie six and +-- James five LEFT steps, Script8/Script11 face them; Script14 hides them between +-- GBFadeOutToBlack / GBFadeInFromBlack. No POKEPORT_SPEED (the fade and the +-- theme sting ride the audio clock), and no POKEPORT_IDENTITY (it re-imports): +-- POKEPORT_VERSION=yellow SHOT_DIR=/tmp/shots POKEPORT_TOUCH=0 POKEPORT_DRIVER=tests/drivers/jessie_james_mtmoon_bug423_test.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local GameVersion = require("src.core.GameVersion") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + pcall(function() io.stdout:setvbuf("no") end) -- LOVE block-buffers stdout + + -- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 JESSIE at (9,3), + -- object 6 JAMES at (9,4). MtMoonB2FScript_49e15's trigger is (3,5), so the + -- PAD_UP step puts the player on (3,4) and the walks land (3,3) and (4,4). + local MAP = "MT_MOON_B2F" + local TRIGGER = { x = 3, y = 5 } + local JESSIE, JAMES = "MTMOONB2F_JESSIE", "MTMOONB2F_JAMES" + local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" + local WANT = { + player = { x = TRIGGER.x, y = TRIGGER.y - 1 }, + jessie = { x = TRIGGER.x, y = TRIGGER.y - 2, facing = "down" }, + james = { x = TRIGGER.x + 1, y = TRIGGER.y - 1, facing = "left" }, + } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + local function npcNamed(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 + + -- ---- what the eye cannot check ----------------------------------------- + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0: the theme sting that opens the ambush and the") + U.log(" bubble blip over the player are both silent, so the audio half") + U.log(" cannot be judged. Set SFX to 7 in OPTION first.") + end + check(("sfx volume %d"):format(sfxVol), sfxVol > 0) + check("running Yellow", GameVersion.isYellow()) + + -- one strong mon with one damaging move: a stat move stalls the A-mash + local mon = Pokemon.new(game.data, "MEWTWO", 100) + mon.moves = { { id = "TACKLE", pp = 35 } } + game.save.party = { mon } + game.save.flags[BEAT] = nil + game.save.flags.EVENT_GOT_HELIX_FOSSIL = true + + -- ---- reach the trigger --------------------------------------------------- + U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up") + local ow = game.overworld + U.hold(game, "up", 20) + U.wait(10) + if not ow.runner:isRunning() then + -- a map or mod edit blocked the approach from below: step in from whatever + -- free walkable neighbour is left + local sides = { { 1, 0, "left" }, { -1, 0, "right" }, { 0, -1, "down" } } + 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:npcAtCell(cx, cy) then + U.log("approach from below failed, stepping in from", cx, cy) + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + U.hold(game, s[3], 20) + U.wait(10) + if ow.runner:isRunning() then break end + end + end + end + check("the ambush script is running", ow.runner:isRunning()) + U.shot(game, DIR .. "/jj423_0_trigger.png") + + -- ---- the duo close in ---------------------------------------------------- + -- Sample every mash: the closed-in positions only hold between the motto and + -- the challenge line, and the battle push ends the window. + local closed = nil + local battle = nil + for _ = 1, 1500 do + local top = game.stack:top() + if getmetatable(top) == BattleState then + battle = top + break + end + local jessie, james = npcNamed(ow, JESSIE), npcNamed(ow, JAMES) + if not closed and jessie and james and not jessie.moving and not james.moving + and jessie.cellX == WANT.jessie.x and james.cellX == WANT.james.x then + closed = { + px = ow.player.cellX, py = ow.player.cellY, + jx = jessie.cellX, jy = jessie.cellY, jf = jessie.facing, + mx = james.cellX, my = james.cellY, mf = james.facing, + } + U.shot(game, DIR .. "/jj423_1_scene.png") + end + U.tap(game, "a") + U.wait(3) + end + + check("the duo walked in instead of staying at (9,3) and (9,4)", closed ~= nil) + closed = closed or {} + U.log("player", tostring(closed.px), tostring(closed.py), + "jessie", tostring(closed.jx), tostring(closed.jy), tostring(closed.jf), + "james", tostring(closed.mx), tostring(closed.my), tostring(closed.mf)) + check("the player took the simulated PAD_UP step onto (3,4)", + closed.px == WANT.player.x and closed.py == WANT.player.y) + check("Jessie is standing directly above the player", + closed.jx == WANT.jessie.x and closed.jy == WANT.jessie.y) + check("Jessie is facing down at him", closed.jf == WANT.jessie.facing) + check("James is standing beside the player", + closed.mx == WANT.james.x and closed.my == WANT.james.y) + check("James is facing left at him", closed.mf == WANT.james.facing) + check("the challenge line opened the battle", battle ~= nil) + if battle then + for _ = 1, 240 do + if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end + U.wait(1) + end + U.shot(game, DIR .. "/jj423_1b_battle.png") + end + + -- ---- and leave behind a fade -------------------------------------------- + local peakFade, fadeShot = 0, false + local settled = false + for i = 1, 6000 do + local overlay = ow.fadeOverlay + local alpha = overlay and overlay.alpha or 0 + if alpha > peakFade then peakFade = alpha end + if game.stack:top() == ow and not ow.runner:isRunning() + and #ow.scriptMoves == 0 and game.save.flags[BEAT] then + settled = true + break + end + if alpha > 0.35 and not fadeShot then + fadeShot = true + U.shot(game, DIR .. "/jj423_2_fade.png") + end + U.tap(game, "a") + -- step frame by frame while the overlay lives: a 12-frame ramp sampled + -- every third frame reads as no fade at all + U.wait((alpha > 0 or i > 1200) and 1 or 3) + end + U.shot(game, DIR .. "/jj423_3_done.png") + + check("the scene ran to its end", settled) + check("the beat flag is set", game.save.flags[BEAT] == true) + check("Jessie is gone", npcNamed(ow, JESSIE) == nil) + check("James is gone", npcNamed(ow, JAMES) == nil) + U.log(("peak fade alpha %.2f"):format(peakFade)) + check("the exit dipped the screen toward black", peakFade > 0.3) + + U.log("jj423_1_scene.png must show Jessie one cell ABOVE the player looking") + U.log("down at him and James on his right looking left, not the pair parked") + U.log("across the corridor; the exclamation bubble pops over the player just") + U.log("before they start walking, so watch the window live for that beat.") + U.log("jj423_2_fade.png is the room dimming with the duo still standing in") + U.log("it, and jj423_3_done.png is the same room lit and empty; the second") + U.log("Music_MeetJessieJames sting covers the fade, then the cave theme.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/jj_battle_pic_bug439_test.lua b/tests/drivers/jj_battle_pic_bug439_test.lua new file mode 100644 index 00000000..182a6e61 --- /dev/null +++ b/tests/drivers/jj_battle_pic_bug439_test.lua @@ -0,0 +1,125 @@ +-- Eye check on the enemy pic in the Mt Moon Jessie & James fight (#439). +-- pokeyellow home/trainers2.asm:36-50 IsFightingJessieJames points +-- wTrainerPicPointer at JessieJamesPic when ROCKET fights with wTrainerNo +-- >= $2a, leaving GetTrainerName alone. Needs a Yellow cache re-imported +-- after #439. No POKEPORT_SPEED: the theme sting rides the audio clock. +-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/jj_battle_pic_bug439_test.lua POKEPORT_IDENTITY=bug439 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local GameVersion = require("src.core.GameVersion") + local CacheFs = require("src.import.CacheFs") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- pokeyellow data/maps/objects/MtMoonB2F.asm parks JESSIE at (9, 3) and + -- JAMES at (9, 4); MtMoonB2FScript's coordinate trigger is the (3, 5) tile + -- west of them, reachable from (3, 6) once a fossil is in the bag. + local MAP = "MT_MOON_B2F" + local TRIGGER = { x = 3, y = 5 } + local BEAT = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the eye cannot check ----------------------------------------- + local opts = game.save.options or {} + local sfxVol = opts.sfxVol or 7 + if sfxVol == 0 then + U.log("FAIL sfx volume is 0: the theme sting that opens the ambush is") + U.log(" silent, so the scene's audio half cannot be judged. Set SFX") + U.log(" to 7 in OPTION first.") + end + check(("sfx volume %d"):format(sfxVol), sfxVol > 0) + + check("running Yellow", GameVersion.isYellow()) + local trainer = game.data.trainers.OPP_ROCKET + check("OPP_ROCKET is in the trainer table", trainer ~= nil) + trainer = trainer or {} + check("the name is still ROCKET, as GetTrainerName leaves it", + trainer.name == "ROCKET") + -- a Yellow cache built before #439 has no duo pic at all, and every check + -- below it would then be measuring the old grunt-only behaviour + if not trainer.picJessieJames then + U.log("FAIL the Yellow cache predates #439: no duo pic was extracted.") + U.log(" Re-import the Yellow ROM from the launcher, then run again.") + end + check("the cache carries a duo pic", trainer.picJessieJames ~= nil) + if trainer.picJessieJames then + check("the duo pic file is on the read path", + CacheFs.exists(trainer.picJessieJames) + or love.filesystem.getInfo(trainer.picJessieJames) ~= nil) + end + local picked = BattleState.trainerPicPath(trainer, "OPP_ROCKET", 42) + U.log("pic:", tostring(picked)) + check("party 42 selects the duo pic", picked == trainer.picJessieJames) + check("a lone grunt party still selects the class pic", + BattleState.trainerPicPath(trainer, "OPP_ROCKET", 3) == trainer.pic) + + -- ---- reach the ambush --------------------------------------------------- + -- one strong mon so the fight is survivable if the reader plays it out + game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) } + game.save.flags[BEAT] = nil + game.save.flags.EVENT_GOT_HELIX_FOSSIL = true + + U.teleport(game, MAP, TRIGGER.x, TRIGGER.y + 1, "up") + local ow = game.overworld + U.hold(game, "up", 20) + U.wait(10) + if not ow.runner:isRunning() then + -- a map or mod edit blocked the approach from below: come down onto the + -- trigger from whichever free walkable neighbour is left + local sides = { + { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + 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:npcAtCell(cx, cy) then + U.log("approach from below failed, stepping in from", cx, cy) + U.teleport(game, MAP, cx, cy, s[3]) + ow = game.overworld + U.hold(game, s[3], 20) + U.wait(10) + if ow.runner:isRunning() then break end + end + end + end + check("the ambush script is running", ow.runner:isRunning()) + U.shot(game, DIR .. "/jj439_0_trigger.png") + + -- A-mash the motto and the challenge line until the battle is pushed, then + -- stop: the trainer pic stays up until the intro text is dismissed + local battle = nil + for _ = 1, 1200 do + local top = game.stack:top() + if getmetatable(top) == BattleState then + battle = top + break + end + U.tap(game, "a") + U.wait(3) + end + check("the ambush opened a battle", battle ~= nil) + if battle then + for _ = 1, 240 do + if battle.showEnemyTrainer and (battle.introSlide or 0) <= 0 then break end + U.wait(1) + end + check("the enemy trainer pic is on screen", battle.showEnemyTrainer == true) + U.shot(game, DIR .. "/jj439_1_intro.png") + U.wait(40) + U.shot(game, DIR .. "/jj439_2_intro_late.png") + U.log("intro text:", tostring(battle.introText)) + end + + U.log("Both intro shots must show the two-character duo pic: a woman on the") + U.log("left and a man on the right, one 7x7 pic, no Meowth -- not the single") + U.log("crouching ROCKET grunt. The name in the intro line stays ROCKET.") + U.log("The theme under the trigger shot is Music_MeetJessieJames.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/launcher_delete_bug433_test.lua b/tests/drivers/launcher_delete_bug433_test.lua new file mode 100644 index 00000000..ddafda75 --- /dev/null +++ b/tests/drivers/launcher_delete_bug433_test.lua @@ -0,0 +1,90 @@ +-- Manual check of the launcher's Delete labels (#433): a stale hit rect let a +-- click on the Mods tab delete a save slot, and Delete never asked first. +-- No pokered counterpart: src/import/RomImporter.lua is this port's own desktop +-- launcher, so hit-rect coords here come from the panel that draws them. +-- POKEPORT_DRIVER=tests/drivers/launcher_delete_bug433_test.lua POKEPORT_IDENTITY=bug433 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +-- Leave POKEPORT_SPEED unset: this one is clicked by hand, at real time. +return function(game) + local U = dofile("tests/drivers/util.lua") + local RomImporter = require("src.import.RomImporter") + local SaveData = require("src.core.SaveData") + local GameVersion = require("src.core.GameVersion") + + local version = GameVersion.get() + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- A driver run boots straight past the launcher (main.lua: POKEPORT_DRIVER is + -- a scripted run), so bring a real interactive launcher up ourselves and take + -- over the frame + click handlers the launcher normally owns. + local imp = RomImporter.new(function() end, { launcher = true }) + imp.play = function() U.log("Play is inert here: the game is already booted.") end + + local prevDraw, prevPressed = love.draw, love.mousepressed + local prevKey, prevText = love.keypressed, love.textinput + love.draw = function() + imp:update(love.timer.getDelta()) + imp:draw() + end + love.mousepressed = function(x, y, button) imp:mousepressed(x, y, button or 1) end + love.keypressed = function(key) imp:keypressed(key) end + love.textinput = function(t) imp:textinput(t) end + local function restore() + love.draw, love.mousepressed = prevDraw, prevPressed + love.keypressed, love.textinput = prevKey, prevText + end + + local function slotCount() + return #(SaveData.listSlots(version) or {}) + end + + -- Two throwaway rows to click on, in this identity's save dir only. + while slotCount() < 2 do imp:_newSlot(version) end + imp.tab = version + imp.pageScroll = 0 + U.wait(4) + + -- The Delete rects exist only after the panel that owns them has drawn. + local del = (imp.slotDeleteRects or {})[1] + if not check("the save panel drew a Delete rect for a slot row", del ~= nil) then + U.log("Nothing to click: the", version, "tab drew no slot rows.") + restore() + while true do coroutine.yield() end + end + local spot = { x = del.x + del.width / 2, y = del.y + del.height / 2, id = del.id } + U.log(("Delete for %s sits at (%d, %d)."):format(spot.id, spot.x, spot.y)) + + imp.tab = "mods" + U.wait(4) + check("a Mods frame leaves no save Delete rect live", + imp.slotDeleteRects == nil) + local before = slotCount() + imp:mousepressed(spot.x, spot.y, 1) + U.wait(4) + check("the reporter's click on that spot from Mods deletes nothing", + slotCount() == before and imp._confirmDelete == nil) + + imp.tab = version + U.wait(4) + imp:mousepressed(spot.x, spot.y, 1) + U.wait(2) + check("one click on Delete arms instead of deleting", + slotCount() == before and imp._confirmDelete ~= nil + and imp._confirmDelete.id == spot.id) + imp:mousepressed(4, 4, 1) -- a press anywhere else + U.wait(2) + check("a press elsewhere takes the arm back off", imp._confirmDelete == nil) + check("the slot survived the whole sequence", slotCount() == before) + + U.log("The launcher on screen is live; the", version, "tab is up, disarmed.") + U.log("Clicking Delete once should turn that label red and read \"Sure?\"") + U.log("without the row moving, and go back to \"Delete\" after ~4 seconds or") + U.log("on any other click. A second click on \"Sure?\" removes the slot.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/oak_nidorino_flip_bug397_test.lua b/tests/drivers/oak_nidorino_flip_bug397_test.lua index 842d009d..abc04135 100644 --- a/tests/drivers/oak_nidorino_flip_bug397_test.lua +++ b/tests/drivers/oak_nidorino_flip_bug397_test.lua @@ -105,11 +105,13 @@ return function(game) speech.picFlip == true and speech.pic == speech.demoPic) U.shot(game, DIR .. "/oak_nido_2a.png") - -- one A per page of 2A; picFlip must survive every one of them + -- one A per page of 2A; picFlip must survive every one of them. The page + -- count is not fixed (an A during the typewriter only finishes the line), so + -- keep turning until the step moves on rather than budgeting presses. local held = true - for _ = 1, 8 do + for _ = 1, 60 do U.tap(game, "a") - U.wait(10) + U.wait(12) if speech.picFlip ~= true or speech.pic ~= speech.demoPic then held = false break @@ -126,22 +128,32 @@ return function(game) U.shot(game, DIR .. "/oak_nido_2b.png") U.log("shots in", DIR) - -- put the beat back so the transition can be watched live: pop the box, rewind - -- the step counter and re-run the demo beat (wipe, cry, page A) + -- put the beat back so the page break can be watched live: pop the box and + -- rewind the step counter. The wipe and the cry are both skipped on the way + -- back in -- the sprite is already standing there and it already called once, + -- so replaying them reads as NIDORINO entering twice -- leaving just page A. for _ = 1, 8 do if top() == speech then break end game.stack:pop() end if top() == speech then - speech.picReveal = nil + local Sound = require("src.core.Sound") + local realReveal, realCry = speech.revealPic, Sound.playCry + speech.revealPic = function(self, _, next) + self.picReveal = nil + if next then next() end + end + Sound.playCry = function() end speech.step = demoIdx speech:runStep(speech.steps[demoIdx]) - U.wait(60) + speech.revealPic, Sound.playCry = realReveal, realCry + U.wait(30) end - U.log("NIDORINO wipes in from the right facing LEFT, horn toward screen-left.") - U.log("Press A to turn the page: the sprite must not move or mirror, it stays") - U.log("facing left until the screen fades to white for the naming beat.") + U.log("NIDORINO is standing where the wipe left it, facing LEFT with its horn") + U.log("toward screen-left, and page A is back up. Press A to turn the page:") + U.log("the sprite must not move or mirror, it stays facing left until the") + U.log("screen fades to white for the naming beat.") while true do coroutine.yield() diff --git a/tests/drivers/pikachu_cadence_bug424_test.lua b/tests/drivers/pikachu_cadence_bug424_test.lua new file mode 100644 index 00000000..0cb9263e --- /dev/null +++ b/tests/drivers/pikachu_cadence_bug424_test.lua @@ -0,0 +1,316 @@ +-- Yellow follower cadence (#424): the ledge hop deferred a player step, the +-- standing idle counters at OverworldLoop's two DelayFrames per pass, and the +-- pikapic beat timed by its own pikapic_setduration (pokeyellow engine/pikachu/ +-- pikachu_follow.asm Func_fc803/Func_fcc64/Func_fcc92, home/overworld.asm:43, +-- pikachu_pic_animation.asm ExecutePikaPicAnimScript). Never POKEPORT_SPEED: +-- it scales the logic clock alone and every number judged here is a frame count. +-- POKEPORT_DRIVER=tests/drivers/pikachu_cadence_bug424_test.lua POKEPORT_IDENTITY=bug424 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local GameVersion = require("src.core.GameVersion") + local Pokemon = require("src.pokemon.Pokemon") + local PikachuFollower = require("src.world.PikachuFollower") + + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local results = {} + local function check(label, ok) + results[#results + 1] = { label = label, ok = ok and true or false } + return ok + end + local function report() + for _, r in ipairs(results) do U.log(r.ok and "PASS" or "FAIL", r.label) end + end + local function idleForever() + while true do coroutine.yield() end + end + + if not GameVersion.isYellow() then + check("running the Yellow cache (POKEPORT_VERSION=yellow)", false) + report() + U.log("Red and Blue have no follower, no idle rolls and no pikapic box.") + idleForever() + end + + -- ShouldPikachuSpawn's preconditions (pikachu_follow.asm): the lab gift + -- happened and a healthy Pikachu leads the party. Without both there is no + -- follower and every count below would read as the bug. + 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 + + local frames = 0 + local function step(n) + for _ = 1, n do + coroutine.yield() + frames = frames + 1 + end + end + + -- ROUTE_1 (pokeyellow data/maps/objects/Route1.asm has no object near the + -- ledge run) carries the first plain south-facing ledge; (5, 4) is the cell + -- to hop from, re-derived below out of the cached blocks against + -- data.field.ledges so a map edit moves the test instead of breaking it. + local MAP = "ROUTE_1" + local WANT = { x = 5, y = 4 } + + local function southLedge(map, cx, cy) + if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end + -- the landing plus one more open cell below it: the queued hop only drains + -- on the player's next step, so the ledge needs room for that step + if not (map:isWalkableCell(cx, cy) and map:isWalkableCell(cx, cy - 1) + and map:isWalkableCell(cx, cy + 2) + and map:isWalkableCell(cx, cy + 3)) then + return false + end + local standing, front = map:cellTile(cx, cy), map:cellTile(cx, cy + 1) + for _, l in ipairs(game.data.field.ledges or {}) do + if (l.tileset or "OVERWORLD") == map.def.tileset + and l.facing == "down" and l.input == "down" + and l.standingTile == standing and l.ledgeTile == front then + return true + end + end + return false + end + + U.teleport(game, MAP, WANT.x, WANT.y - 1, "down") + U.wait(20) + + local ow = game.overworld + check("overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + if not ow then + report() + idleForever() + end + + local hx, hy = WANT.x, WANT.y + if not southLedge(ow.map, hx, hy) then + for cy = 1, ow.map.heightCells - 4 do + for cx = 0, ow.map.widthCells - 1 do + if southLedge(ow.map, cx, cy) then hx, hy = cx, cy break end + end + if hx ~= WANT.x or hy ~= WANT.y then break end + end + U.log(("(%d, %d) is no longer a south ledge; using"):format(WANT.x, WANT.y), + hx, hy) + U.teleport(game, MAP, hx, hy - 1, "down") + U.wait(20) + ow = game.overworld + end + check(("a south ledge to hop at (%d, %d)"):format(hx, hy), + southLedge(ow.map, hx, hy)) + + local function follower() + for _, n in ipairs(ow.npcs or {}) do + if n.pikachuFollower then return n end + end + return nil + end + + local npc = follower() + if not check("the follower spawned", npc ~= nil) then + report() + idleForever() + end + + -- ---- the standing idle clock ---- + -- Func_fc803 reloads $20 and decrements it once per UpdateSprites call, and + -- a standing OverworldLoop burns two DelayFrames per pass, so one counter + -- unit is two of this port's fixed steps: the glance lands on frame 64. + local function glanceLeft() + local idle = npc.idle + if idle and idle.kind == "wait" then return idle.frames end + return nil + end + + for _ = 1, 90 do + if glanceLeft() then break end + step(1) + end + if not check("the follower is in the standing glance state", glanceLeft() ~= nil) then + report() + idleForever() + end + + local last, lastAt = glanceLeft(), frames + -- the first change lands a partial unit after the sampling started, so it + -- only sets the baseline: every gap measured after it is a whole unit + local minGap, maxGap, seen, resetAt, period = 99, 0, 0, nil, nil + for _ = 1, 300 do + step(1) + local now = glanceLeft() + if now == nil then break end + if now ~= last then + seen = seen + 1 + local gap = frames - lastAt + if seen > 1 then + if gap < minGap then minGap = gap end + if gap > maxGap then maxGap = gap end + end + if now > last then + if resetAt then period = period or frames - resetAt end + resetAt = frames + end + last, lastAt = now, frames + end + end + check("the idle counter loses one unit every two frames, not every frame", + minGap == 2 and maxGap == 2) + check("a full glance takes 64 frames ($20 units), not 32", period == 64) + U.log("idle counter gap", minGap, "to", maxGap, "frames; glance period", + tostring(period)) + + -- ---- the ledge hop, one player step late ---- + -- Func_fcc08 hands a ledge step to Func_fcc64, which appends the $5-$8 hop + -- on the takeoff step and nothing on the landing step; Func_fcc92 cannot pop + -- a command with nothing queued behind it, so the hop waits for the player's + -- next step and Pikachu sits two cells back until then. + local sawHop, hopLen = false, nil + local function sample() + local f = follower() + if f and f.hopStep then + sawHop = true + hopLen = hopLen or f.stepFrames + end + end + + for _ = 1, 200 do + table.insert(game.input.pressQueue, "down") + game.input.state["down"] = true + step(1) + sample() + if (ow.player.hopFrames or 0) > 0 then break end + end + game.input.state["down"] = false + check("the player's ledge hop fired", (ow.player.hopFrames or 0) > 0) + + for _ = 1, 150 do + step(1) + sample() + end + npc = follower() + check("the follower waits on the cell the player took off from", + npc ~= nil and npc.cellX == hx and npc.cellY == hy) + check("it does not hop while the player stands still", not sawHop) + if npc then + U.log("player at", ow.player.cellX, ow.player.cellY, + "| follower waiting at", npc.cellX, npc.cellY) + end + + -- one more player step drains the buffered hop + for _ = 1, 120 do + table.insert(game.input.pressQueue, "down") + game.input.state["down"] = true + step(1) + sample() + if ow.player.cellY > hy + 2 then break end + end + game.input.state["down"] = false + for _ = 1, 120 do + step(1) + sample() + end + + check("the deferred hop then ran", sawHop) + -- Func_fc7aa jumps to Func_fca0a on the $4 movement status before it asks + -- AreThereAtLeastTwoStepsInPikachuFollowCommandBuffer, so a hop is never a + -- Fast step even though its goal is two cells off + local walkLen = ow.player.stepFramesCur or ow.player.stepFrames or 16 + check("the hop takes a full step's frames, not a halved Fast step", + hopLen == walkLen) + U.log("hop step frames", tostring(hopLen), "against a walk's", walkLen) + npc = follower() + check("the follower cleared the ledge row", + npc ~= nil and npc.cellY > hy + 1 + and ow.map:isWalkableCell(npc.cellX, npc.cellY)) + + -- ---- the pikapic beat ---- + -- happiness 255 with mood 200 is the matrix cell for emotion 20 (a heart + -- bubble and PikaPicAnimScript20: 50 ticks, so 150 frames, with the second + -- full-body pose up for 24 of every 40 ticks). + game.save.pikachuHappiness = 255 + game.save.pikachuMood = 200 + + local DIRS = { { "down", 0, 1 }, { "up", 0, -1 }, + { "left", -1, 0 }, { "right", 1, 0 } } + local function facingFollower() + local fx, fy = ow.player:facingCell() + return follower() ~= nil and ow:npcAtCell(fx, fy) == follower() + end + if not facingFollower() then + for _, d in ipairs(DIRS) do + if npc and npc.cellX == ow.player.cellX + d[2] + and npc.cellY == ow.player.cellY + d[3] then + U.tap(game, d[1]) + U.wait(6) + break + end + end + end + check("player is facing the follower", facingFollower()) + + -- a muted run sounds like a beat that never started, so say so up front + local sfxVol = game.save.options and game.save.options.sfxVol + U.log("save.options.sfxVol:", tostring(sfxVol)) + if (sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: the squeak cannot be heard whether or not") + U.log("WARNING one plays. Raise it in OPTIONS before judging the sound.") + end + + U.tap(game, "a") + U.wait(1) + local emote = ow.emote + if not check("the A press raised the framed pikapic", emote ~= nil) then + report() + idleForever() + end + check("the beat runs the script's 50 ticks at three frames each", + emote.pikaTotal == 150) + check("its length is not the old flat 50 frame hold", emote.pikaTotal ~= 50) + check("the pikapic marks itself skippable", emote.skippable == true) + check("emotion 20 carries an overlay frameset", type(emote.pikaSeq) == "table") + + local lifted, grounded, liftShot = 0, 0, nil + for _ = 1, 60 do + if not ow.emote then break end + local lift = PikachuFollower.picLift(ow.emote) + if lift > 0 then + lifted = lifted + 1 + if not liftShot then + liftShot = SHOT_DIR .. "/bug424_pikapic_lift.png" + if U.shot(game, liftShot) then U.log("captured", liftShot) + else liftShot = nil end + end + else + grounded = grounded + 1 + end + step(1) + end + check("the pic sits on the box floor for part of the beat", grounded > 0) + check("and rises off it for another part", lifted > 0) + U.log("of the first 60 frames the pic was up for", lifted, "and down for", + grounded) + + local leftWhenCut = ow.emote and ow.emote.frames or 0 + U.tap(game, "a") + U.wait(2) + check("A cuts the beat short (PikaPicAnimTimerAndJoypad)", + leftWhenCut > 0 and ow.emote == nil) + U.log("cut with", leftWhenCut, "frames still on the clock") + + report() + + -- hand the pad back one cell above the same ledge so Down replays the hop + U.teleport(game, MAP, hx, hy - 1, "down") + U.wait(20) + + U.log("Hold Down: Pikachu should walk up to the ledge lip and WAIT there,") + U.log("then clear both cells in one motion on your next step, at walking") + U.log("speed. Let go and count the first glance: about a second, not half.") + U.log("Face it, press A: the framed pic hops inside the box for the whole") + U.log("beat, and A or B cuts the beat off early.") + + idleForever() +end diff --git a/tests/drivers/pikachu_ledge_bug408_bug409_test.lua b/tests/drivers/pikachu_ledge_bug408_bug409_test.lua index c4920236..68fa1c82 100644 --- a/tests/drivers/pikachu_ledge_bug408_bug409_test.lua +++ b/tests/drivers/pikachu_ledge_bug408_bug409_test.lua @@ -71,11 +71,14 @@ return function(game) -- a cell the player can hop south from, with two walkable cells above it -- to walk in from and a walkable landing two cells below local function hopCellOk(map, cx, cy) - if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 2)) then + if not (map:inBounds(cx, cy) and map:inBounds(cx, cy + 3)) then return false end if not map:isWalkableCell(cx, cy) then return false end if not map:isWalkableCell(cx, cy + 2) then return false end + -- one more open cell below the landing: the queued hop only drains on + -- the player's NEXT step, so the ledge needs room to take it (#424) + if not map:isWalkableCell(cx, cy + 3) then return false end if not (map:isWalkableCell(cx, cy - 1) and map:isWalkableCell(cx, cy - 2)) then return false @@ -209,6 +212,26 @@ return function(game) U.log("the arc was already over before the capture; no mid-air frame") end + -- The hop command is buffered a step behind the player: Func_fcc64 appends + -- nothing for the landing half of the jump, so Func_fcc92 cannot pop the + -- hop until another command queues behind it. Pikachu therefore walks to + -- the cell the player took off from and waits there (#424). + step(120) + local waiting = follower() + check("the follower waits on the cell the player took off from", + waiting ~= nil and waiting.cellX == hx and waiting.cellY == hy) + check("it has not hopped while the player stood still", not sawHopStep) + + -- one more player step drains the queued hop + for _ = 1, 90 do + table.insert(game.input.pressQueue, "down") + game.input.state["down"] = true + coroutine.yield() + sample() + if ow.player.cellY > hy + 2 then break end + end + game.input.state["down"] = false + -- let the follower finish crossing (its hop is one normal step's frames -- with a doubled step vector, pikachu_follow.asm Func_fca0a) step(120) @@ -242,9 +265,11 @@ return function(game) U.log("Hold Down to hop the ledge again from here.") U.log("During the arc there should be one flat ellipse on the ground under") - U.log("the player, not a taller blob with a seam across its middle, and") - U.log("Pikachu should clear both cells in one motion. The near miss to") - U.log("watch for is Pikachu pausing a beat on the ledge tile itself.") + U.log("the player, not a taller blob with a seam across its middle.") + U.log("Pikachu should walk up to the cell you jumped from and STAY there") + U.log("until you take another step, then clear both cells in one motion.") + U.log("The near misses to watch for are Pikachu hopping alongside you and") + U.log("Pikachu pausing a beat on the ledge tile itself.") while true do coroutine.yield() diff --git a/tests/drivers/pikachu_seam_bug427_test.lua b/tests/drivers/pikachu_seam_bug427_test.lua new file mode 100644 index 00000000..3df8e219 --- /dev/null +++ b/tests/drivers/pikachu_seam_bug427_test.lua @@ -0,0 +1,189 @@ +-- A connection crossing has to carry Yellow's follower, not respawn it (#427). +-- pokeyellow home/overworld.asm:648-655 sets bit 4 of +-- wPikachuOverworldStateFlags before LoadMapHeader, so pikachu_follow.asm's +-- SchedulePikachuSpawnForAfterText takes .normal_spawn_state: coords rebased, +-- sprite data left alone. Never POKEPORT_SPEED here, the offsets are pixels. +-- POKEPORT_DRIVER=tests/drivers/pikachu_seam_bug427_test.lua POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local PikachuFollower = require("src.world.PikachuFollower") + local GameVersion = require("src.core.GameVersion") + local Map = require("src.world.Map") + + 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 + + if not GameVersion.isYellow() then + check("only Yellow has a follower; re-run with POKEPORT_VERSION=yellow", false) + while true do coroutine.yield() end + end + + -- pokeyellow data/maps/objects/PalletTown.asm puts no object on the town's + -- north exit and PALLET_TOWN's north connection to ROUTE_1 carries offset 0, + -- so the landing column is the column walked off the edge. + local MAP = "PALLET_TOWN" + local NORTH = "ROUTE_1" + local COL = 10 + local START_Y = 3 + + game.save.party = { Pokemon.new(game.data, "PIKACHU", 100) } + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.onBike = false + game.save.repelSteps = 9999 + game.save.player.name = "bryan" + + U.teleport(game, MAP, COL, START_Y, "up") + U.wait(10) + local ow = game.overworld + local map = ow.map + + -- a usable exit column: four clear cells up to the edge, nothing standing + -- in them, and a landing the engine's own edge read accepts (Map.defPassable + -- is what crossConnection consults before it commits to the crossing) + local function exitOK(cx) + for cy = 0, START_Y do + if not map:isWalkableCell(cx, cy) then return false end + -- ow.npcs carries the follower too, and it is standing in this column + local at = ow:npcAtCell(cx, cy) + if at and not at.pikachuFollower then return false end + end + local p = ow.player + local keepX = p.cellX + p.cellX = cx + local dest, ts, lx, ly = ow:connectionLanding("up") + p.cellX = keepX + return dest ~= nil and dest.id == NORTH + and Map.defPassable(dest, ts, lx, ly, false) + end + + if not exitOK(COL) then + local found + for cx = 0, map.widthCells - 1 do + if exitOK(cx) then found = cx break end + end + U.log(("column %d no longer walks off the north edge; using column %s") + :format(COL, tostring(found))) + COL = found or COL + U.teleport(game, MAP, COL, START_Y, "up") + U.wait(10) + ow = game.overworld + map = ow.map + end + check(("column %d walks off %s's north edge onto %s"):format(COL, MAP, NORTH), + exitOK(COL)) + + local pika = PikachuFollower.current(ow) + check("the follower spawned on " .. MAP, pika ~= nil) + + -- exactly one step north, then stop: the follower has to be trailing and + -- settled, since a respawn on the cell it already occupies is invisible + local p = ow.player + local stopY = p.cellY - 1 + for _ = 1, 60 do + if p.cellY <= stopY and not p.moving then break end + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + coroutine.yield() + end + game.input.state.up = false + U.wait(20) + U.log(("before the seam: player (%d, %d), follower (%s, %s)") + :format(p.cellX, p.cellY, tostring(pika and pika.cellX), + tostring(pika and pika.cellY))) + check("the follower trails one cell behind before the seam", + pika ~= nil and pika.cellX == p.cellX and pika.cellY == p.cellY + 1) + if U.shot(game, SHOT_DIR .. "/bug427_before_seam.png") then + U.log("captured", SHOT_DIR .. "/bug427_before_seam.png") + end + + -- Hold up through the crossing, sampling once per logic step (main.lua + -- resumes the driver exactly once per Game:update). The pixel offset + -- between the two sprites is the measurement: a respawn loses the in-flight + -- step, so the offset snaps even when the new instance lands on the right + -- cell. The mid-seam capture is armed inline instead of through U.shot so + -- the walk keeps running while it lands. + local samples, missing, maxJump = 0, 0, 0 + local prevX, prevY + local crossedAt, pikaAfter + local frames = 0 + while frames < 240 do + table.insert(game.input.pressQueue, "up") + game.input.state.up = true + frames = frames + 1 + coroutine.yield() + local npc = PikachuFollower.current(ow) + if not npc then + missing = missing + 1 + else + local pl = ow.player + local ox, oy = npc.px - pl.px, npc.py - pl.py + if prevX then + local jump = math.max(math.abs(ox - prevX), math.abs(oy - prevY)) + if jump > maxJump then maxJump = jump end + end + prevX, prevY = ox, oy + samples = samples + 1 + end + if not crossedAt and ow.map.def.id == NORTH then + crossedAt = frames + pikaAfter = npc + game.capturePath = SHOT_DIR .. "/bug427_mid_seam.png" + end + if crossedAt and frames >= crossedAt + 24 then break end + end + game.input.state.up = false + U.wait(30) + + check("the player crossed the seam onto " .. NORTH, + crossedAt ~= nil and ow.map.def.id == NORTH) + check("the follower across the seam is the same instance", + pikaAfter ~= nil and pikaAfter == pika) + check("it is still the same instance once the walk settles", + PikachuFollower.current(ow) == pika) + check(("it never dropped out of ow.npcs (%d of %d frames)") + :format(missing, samples + missing), missing == 0 and samples > 0) + check(("its pixel offset to the player never jumped (max %dpx)") + :format(maxJump), maxJump <= 4) + + local settled = PikachuFollower.current(ow) + p = ow.player + if settled then + U.log(("player (%d, %d) facing %s, follower (%d, %d)") + :format(p.cellX, p.cellY, tostring(p.facing), + settled.cellX, settled.cellY)) + end + check("the follower came to rest one cell behind", + settled ~= nil and settled.cellX == p.cellX + and settled.cellY == p.cellY + 1) + local trail = ow.pikachuTrail + check("the chased trail cell was rebased into the new map", + trail ~= nil and ow.map:inBounds(trail.x, trail.y)) + + local mid = io.open(SHOT_DIR .. "/bug427_mid_seam.png", "rb") + if mid then + mid:close() + U.log("captured", SHOT_DIR .. "/bug427_mid_seam.png") + else + U.log("FAIL screenshot did not reach disk:", SHOT_DIR .. "/bug427_mid_seam.png") + end + if U.shot(game, SHOT_DIR .. "/bug427_after_seam.png") then + U.log("captured", SHOT_DIR .. "/bug427_after_seam.png") + end + + U.log("You just walked north out of Pallet onto Route 1 with Pikachu behind") + U.log("you. In bug427_mid_seam.png it has to be below you and mid-stride, on") + U.log("the pixels the walk left it on; the bug put it squarely on a cell in") + U.log("front of you instead, once per seam, which is the pop in the report.") + U.log("Warps still respawn it behind you (walk through any door), and a") + U.log("fainted or deposited Pikachu still leaves no follower at a seam.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_talk_bug407_test.lua b/tests/drivers/pikachu_talk_bug407_test.lua index 98838278..b5929e5e 100644 --- a/tests/drivers/pikachu_talk_bug407_test.lua +++ b/tests/drivers/pikachu_talk_bug407_test.lua @@ -196,6 +196,11 @@ return function(game) type(emote.pikaPic) == "string" and love.filesystem.getInfo(emote.pikaPic) ~= nil) check("a cry source was created", reportCries(before)) + -- the beat now runs the pikapic script's own pikapic_setduration, three + -- 60Hz frames per tick, so even the shortest script (32 ticks) outlasts + -- the 50 frame hold this port used to serve every emotion (#424) + check("the hold runs the script's own duration, not a flat 50 frames", + (emote.frames or 0) >= 80) bubbleReport(emote) U.log("happiness", tostring(game.save.pikachuHappiness or 90), "mood", tostring(game.save.pikachuMood or 128)) @@ -209,7 +214,14 @@ return function(game) -- wPikachuEmotionModifier 5 is MapSpecificPikachuExpression's fifth -- entry, emotion 25: BOLT_BUBBLE plus PCM clip 35. Forcing it takes the -- mood roll out of the picture, so a missing bubble here is a real fault. - U.wait(70) -- the 50 frame hold, plus slack, before input is looked at again + -- wait the pikapic beat out instead of counting frames: its length is the + -- script's now, and an A press during it would only cut it short + -- (PikaPicAnimTimerAndJoypad, #424) + for _ = 1, 400 do + if not ow.emote then break end + U.wait(1) + end + U.wait(6) game.save.pikachuEmotionModifier = 5 check("still facing the follower for the second press", facingFollower()) before = #cries diff --git a/tests/drivers/route8_music_bug429_test.lua b/tests/drivers/route8_music_bug429_test.lua new file mode 100644 index 00000000..66c47887 --- /dev/null +++ b/tests/drivers/route8_music_bug429_test.lua @@ -0,0 +1,136 @@ +-- Ear check: Route 8's wave-channel countermelody, at full volume and in the +-- right octave (#429). ROUTE_8 is MUSIC_ROUTES3 (pokered data/maps/songs.asm:22) +-- and Music_Routes3_Ch3 is a dense 2-frame-per-note line; ChipAudio shipped +-- ch3 at 0.25 volume and 0.5 pitch, a quarter as loud and an octave below the +-- hardware's own octave (audio/engine_1.asm:904-944 writes CHAN3's frequency +-- register unmodified). SELECT toggles the 0.1.38 mix back on to compare. +-- POKEPORT_DRIVER=tests/drivers/route8_music_bug429_test.lua POKEPORT_IDENTITY=bug429 POKEPORT_TOUCH=0 love . +-- No POKEPORT_SPEED: fast-forward scales the logic clock only and desyncs audio. +return function(game) + local U = dofile("tests/drivers/util.lua") + local ChipAudio = require("src.core.ChipAudio") + local Music = require("src.core.Music") + + -- pokered data/maps/objects/Route8.asm keeps its trainers at (8,5), (13,9), + -- (26,3-6) and (42,6), and warps at x 1, 8 and 13, so the road east of the + -- gate is clear; (30, 8) is only a listening spot, walk anywhere from it. + local MAP = "ROUTE_8" + local SONG = "Music_Routes3" + local STAND = { x = 30, y = 8, facing = "down" } + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- what the ear cannot check ----------------------------------------- + local opts = game.save.options or {} + local musicVol = opts.musicVol or 7 + local sfxVol = opts.sfxVol or 7 + if musicVol == 0 then + U.log("FAIL music volume is 0: nothing below can be heard at all.") + U.log(" Set MUSIC to 7 in OPTION first.") + end + if sfxVol == 0 then + U.log("FAIL sfx volume is 0, so the step and menu sounds are gone too;") + U.log(" set SFX to 7 in OPTION if the run should sound normal.") + end + check(("music volume %d, sfx volume %d"):format(musicVol, sfxVol), + musicVol > 0) + + check(MAP .. " plays " .. SONG, + (game.data.audio.mapSongs or {})[MAP] == SONG) + local vols = ChipAudio.getChannelVolumes() + local pitches = ChipAudio.getChannelPitches() + check(("the shipped wave mix is unity (volume %s, pitch %s)") + :format(tostring(vols[3]), tostring(pitches[3])), + vols[3] == 1 and pitches[3] == 1) + + -- render the ch3 layer alone: a silent or near-silent wave track would make + -- every claim below unhearable no matter what the mix says + local song = (game.data.audio.songs or {})[SONG] + local function waveLayer() + return ChipAudio._renderMusicChannelForTest(game.data, song, 1.5, 3) + end + local function measure(sd) + local peak, crossings, prev = 0, 0, sd:getSample(0) + for index = 1, sd:getSampleCount() - 1 do + local sample = sd:getSample(index) + if math.abs(sample) > peak then peak = math.abs(sample) end + if prev * sample < 0 then crossings = crossings + 1 end + prev = sample + end + return peak, crossings + end + local peak, crossings = measure(waveLayer()) + check(("the ch3 countermelody renders (peak %.3f, %d zero crossings)") + :format(peak, crossings), peak > 0.02 and crossings > 200) + + ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 }) + ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 }) + local brokenPeak, brokenCrossings = measure(waveLayer()) + ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) + ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) + check(("the 0.1.38 mix would render it at peak %.3f and %d crossings") + :format(brokenPeak, brokenCrossings), + brokenPeak < peak * 0.4 and brokenCrossings < crossings * 0.7) + -- the invariant tests/engine/wave_channel_mix_bug429.lua asserts on blobs; + -- this is the same statement measured on the ROM's own Music_Routes3 + + -- ---- stand on the road with the theme running -------------------------- + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(20) + local ow = game.overworld + if not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit or a mod blocked the cell: take any free neighbour, the + -- audio is what is being judged and any cell on Route 8 will do + 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("standing on " .. MAP, ow.map.id == MAP) + + local function restart() + -- the playback queue holds ~6s of already-synthesized audio, so a mix + -- change is inaudible until the song is rebuilt from the new values + Music.stop() + Music.playMap(game.data, MAP) + end + restart() + U.wait(60) + + U.log("Route 8's theme is running and the pad is yours; walking works.") + U.log("Under the lead there is a fast wave line, about two notes per beat,") + U.log("as loud as the melody and no lower than the octave it is written in.") + U.log("SELECT swaps in the 0.1.38 mix (quarter volume, a further octave") + U.log("down: a dull rumble that all but vanishes), SELECT again restores it.") + + local broken = false + local held = false + while true do + local down = game.input:isDown("select") + if down and not held then + broken = not broken + if broken then + ChipAudio.setChannelVolumes({ 1, 1, 0.25, 1 }) + ChipAudio.setChannelPitches({ 1, 1, 0.5, 1 }) + U.log("0.1.38 mix: wave volume 0.25, one octave down") + else + ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) + ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) + U.log("authentic mix: wave volume 1, pitch 1") + end + restart() + end + held = down + coroutine.yield() + end +end diff --git a/tests/drivers/silph_tm36_bug393_test.lua b/tests/drivers/silph_tm36_bug393_test.lua index 5d6aa0da..57fee1a5 100644 --- a/tests/drivers/silph_tm36_bug393_test.lua +++ b/tests/drivers/silph_tm36_bug393_test.lua @@ -121,15 +121,24 @@ return function(game) check("and the TM is still hers while it is up", not hasTm()) U.shot(game, DIR .. "/bug393_1_scared.png") - -- read the rest of the conversation the way a player does - for i = 2, 8 do + -- read the rest of the conversation the way a player does. The pleading runs + -- seven lines before GiveItem, and the gift box and the SELFDESTRUCT warning + -- follow it, so turn pages until the talk actually ends instead of counting + -- them out -- the bound is only there so a stuck box cannot hang the driver. + -- boxText reads the whole box, so several A presses walk one string: log a + -- box the first time it is seen, not once per page turn + local i, last = 1, first + for _ = 1, 40 do U.tap(game, "a") U.wait(40) local t = boxText() if not t then break end - U.log(("box %d reads:"):format(i), t) - if t:find("TM36", 1, true) then - U.shot(game, DIR .. "/bug393_2_tm36.png") + if t ~= last then + i, last = i + 1, t + U.log(("box %d reads:"):format(i), t) + if t:find("TM36", 1, true) then + U.shot(game, DIR .. "/bug393_2_tm36.png") + end end end check("TM36 ended up in the bag", hasTm()) diff --git a/tests/drivers/title_seam_bug373_test.lua b/tests/drivers/title_seam_bug373_test.lua index 215f5b22..4ad62d7b 100644 --- a/tests/drivers/title_seam_bug373_test.lua +++ b/tests/drivers/title_seam_bug373_test.lua @@ -30,7 +30,16 @@ return function(game) local opts = game.save.options local mode = opts and opts.colors or PaletteFX.mode - check("COLORS is SGB (the default this bug shows in)", mode == "gbc") + -- the seam only exists under the SGB zone clips, so put the renderer there + -- rather than trusting whatever COLORS the save was last left on (setMode + -- is display-only; the option itself is restored below) + if PaletteFX.mode ~= "gbc" then + U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check") + PaletteFX.setMode("gbc") + U.wait(20) + end + check("the view is in SGB (the mode this bug shows in)", + PaletteFX.mode == "gbc") if (opts and opts.musicVol or 0) == 0 then U.log("WARNING music volume is 0: the title theme will be silent.") end diff --git a/tests/drivers/yellow_intro_music_bug436_test.lua b/tests/drivers/yellow_intro_music_bug436_test.lua new file mode 100644 index 00000000..9ccbde59 --- /dev/null +++ b/tests/drivers/yellow_intro_music_bug436_test.lua @@ -0,0 +1,170 @@ +-- Ear check on Music_YellowIntro across the cut to the title (#436): finish() +-- used to stop the song there, on both the scene-done exit and the A/B/START +-- skip. pokeyellow engine/movie/intro_yellow.asm PlayIntroScene +-- .go_to_title_screen (both exits land there) touches no audio at all; the +-- song only dies at engine/movie/title.asm:149 StopAllMusic, one instruction +-- before MUSIC_TITLE_SCREEN, which TitleState:startMusic stands in for. +-- POKEPORT_VERSION=yellow POKEPORT_DRIVER=tests/drivers/yellow_intro_music_bug436_test.lua POKEPORT_TOUCH=0 love . +-- SKIP=1 on the same command taps START 300 frames into the movie. Add +-- POKEPORT_IDENTITY only if that identity already carries a Yellow import; +-- an identity with no cache lands in the launcher and nothing runs. +-- Do not set POKEPORT_SPEED: it scales the logic clock only, so the movie and +-- the audio clock drift apart and the comparison means nothing. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Music = require("src.core.Music") + local GameVersion = require("src.core.GameVersion") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local SKIP = os.getenv("SKIP") == "1" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- every music call in order, so "the song was cut" and "the song was never + -- started" read differently in the log; nothing under src/ changes + local events = {} + local realPlay, realStop = Music.play, Music.stop + Music.play = function(data, song, loop, ctx) + events[#events + 1] = { kind = "play", song = song, frame = U.frame() } + U.log(("frame %d play %s"):format(U.frame(), tostring(song))) + return realPlay(data, song, loop, ctx) + end + Music.stop = function(...) + events[#events + 1] = { kind = "stop", frame = U.frame() } + U.log(("frame %d stop"):format(U.frame())) + return realStop(...) + end + + local function firstPlay(song) + for i, e in ipairs(events) do + if e.kind == "play" and e.song == song then return i, e end + end + return nil + end + + local function stopBetween(a, b) + for i = a, b do + if events[i] and events[i].kind == "stop" then return events[i] end + end + return nil + end + + local function waitFor(fn, limit) + for _ = 1, limit do + if fn() then return true end + U.wait(1) + end + return false + end + + check("this build booted as Yellow", GameVersion.isYellow()) + if not GameVersion.isYellow() then + U.log("Only Yellow plays this movie; rerun with POKEPORT_VERSION=yellow.") + while true do coroutine.yield() end + end + + local opts = game.save.options + if (opts and opts.musicVol or 0) == 0 then + U.log("MUSIC VOLUME IS 0. There is nothing to hear. Raise it in OPTION,") + U.log("quit and rerun; the log half below still holds.") + end + if (opts and opts.sfxVol or 0) == 0 then + U.log("WARNING sfx volume is 0: the logo crash and the whoosh over the") + U.log("song will be silent, and Pikachu's cry with them.") + end + + local songs = game.data.audio and game.data.audio.songs + local INTRO = songs and songs.Music_YellowIntro and "Music_YellowIntro" + or "Music_IntroBattle" + check("the movie's song is in the audio table", songs ~= nil + and songs[INTRO] ~= nil) + + -- the copyright card and the GAME FREAK splash run before the scenes + -- (IntroMovie phases 1-2), so wait on the song rather than a frame count + local started = waitFor(function() return firstPlay(INTRO) ~= nil end, 900) + check("the movie started " .. INTRO, started) + if not started then + U.log("The movie never reached its own music; nothing below can run.") + while true do coroutine.yield() end + end + local introIdx = firstPlay(INTRO) + + if SKIP then + -- PlayIntroScene:16-19, hJoyPressed & (PAD_A | PAD_B | PAD_START) + U.wait(300) + U.log("tapping START mid-movie (the skip exit)") + U.tap(game, "start") + end + + local function titleState() + local top = game.stack:top() + if top and top.screenId == "TitleState" then return top end + return nil + end + + -- the scene timers add up to about 1040 frames; the skip path lands sooner + local reached = waitFor(function() return titleState() ~= nil end, 1500) + check("the movie handed off to the title screen", reached) + if not reached then + U.log("The title never came up; nothing below can run.") + while true do coroutine.yield() end + end + local title = titleState() + local cut = U.frame() + U.log(("cut to the title at frame %d, %d frames after the song started") + :format(cut, cut - events[introIdx].frame)) + + -- the bug: a stop right here, at the cut + local cutStop = stopBetween(introIdx + 1, #events) + check("no music was stopped at the cut", cutStop == nil) + if cutStop then + U.log(("a stop landed on frame %d; before #436 that is finish() killing") + :format(cutStop.frame)) + U.log("the song, and the title screen comes up silent until the cry.") + end + check("the title runs the Yellow logo-drop sequence, not the plain title", + title.yellowLayout == true and title.phase == "drop") + + U.shot(game, SHOT_DIR .. "/bug436_logo_drop.png") + U.log("captured", SHOT_DIR .. "/bug436_logo_drop.png") + + -- drop 32 frames, settle 36, bubble 3, then the cry (title.asm's + -- WaitForSoundToFinish) before MUSIC_TITLE_SCREEN + local seen = title.phase + for _ = 1, 600 do + if title.phase ~= seen then + seen = title.phase + U.log(("frame %d title phase %s"):format(U.frame(), seen)) + if seen == "loop" then break end + end + U.wait(1) + end + check("the title sequence reached its interactive loop", title.phase == "loop") + + -- read the song off the log rather than naming it: field.boot lets a mod + -- own the title theme (TitleState:startMusic, self.title.music) + local titleIdx, titlePlay + for i = introIdx + 1, #events do + if events[i].kind == "play" then titleIdx, titlePlay = i, events[i] break end + end + check("the title theme started", titlePlay ~= nil) + if titlePlay then + check("it started after the cut, not at it", titlePlay.frame > cut + 32) + U.log(("%s started %d frames after the cut"):format( + tostring(titlePlay.song), titlePlay.frame - cut)) + check("nothing stopped the music between the two songs", + stopBetween(introIdx + 1, titleIdx - 1) == nil) + end + + U.log("The title is up and the pad is yours. The one thing to hear: the") + U.log("intro song runs unbroken through the cut and on over the logo drop,") + U.log("with the crash and the whoosh layered on top, until the title theme") + U.log("takes over after Pikachu's cry. A break at the cut is the bug.") + U.log("Rerun with SKIP=1 for the START skip; it must sound the same.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/jessie_james_mtmoon_walk.lua b/tests/engine/jessie_james_mtmoon_walk.lua new file mode 100644 index 00000000..21fbf14e --- /dev/null +++ b/tests/engine/jessie_james_mtmoon_walk.lua @@ -0,0 +1,195 @@ +-- The Mt Moon B2F Jessie & James ambush closes in, and vanishes behind a +-- fade (#423). pokeyellow MtMoonB2FScript_49e15 (scripts/MtMoonB2F.asm:225) +-- shows both objects, prints TEXT_MTMOONB2F_TEXT12, then simulates PAD_UP for +-- one player step; Script6/Script9 MoveSprite Jessie with MovementData_f9e65 +-- (six $06) and James with f9e66 (its last five), and both objects carry +-- movement byte 2 = LEFT (data/maps/objects/MtMoonB2F.asm), which +-- .determineDirection (engine/overworld/movement.asm) turns into LEFT steps. +-- Script8/Script11 then face Jessie DOWN and James LEFT, and Script14 wraps +-- the two HideObjects in GBFadeOutToBlack / GBFadeInFromBlack. The port used +-- to jump from the motto straight to the challenge with the duo still parked +-- at (9,3)/(9,4), and popped them out with no fade. +-- ROM-free: reads data/scripts/ only. +-- luajit tests/engine/jessie_james_mtmoon_walk.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") + +local sites = require("data.scripts.yellow_jessie_james") + +-- pokeyellow data/maps/objects/MtMoonB2F.asm: object 2 is JESSIE at (9,3), +-- object 6 is JAMES at (9,4), both STAY LEFT. The trigger tile is (3,5). +local JESSIE, JAMES = 2, 6 +local JESSIE_START = { x = 9, y = 3 } +local JAMES_START = { x = 9, y = 4 } +local TRIGGER = { x = 3, y = 5 } + +-- captures the row list a site's onStep hands to the runner +local function rowsFor(site, x, y, flags) + local captured = nil + local ow = { runner = { run = function(_, rows) captured = rows end } } + local game = { save = { flags = flags or {} } } + local fired = sites[site].onStep(game, ow, x, y) + return captured, fired +end + +local function indexOf(rows, pred, from) + for i = (from or 1), #rows do + if pred(rows[i]) then return i end + end + return nil +end + +local function isRow(name, arg2) + return function(r) + return r[1] == name and (arg2 == nil or r[2] == arg2) + end +end + +local function dirsMatch(dirs, count, want) + if type(dirs) ~= "table" or #dirs ~= count then return false end + for i = 1, count do + if dirs[i] ~= want then return false end + end + return true +end + +-- ---- the ambush arms on the pokeyellow conditions ------------------------ + +check(type(sites.MT_MOON_B2F.onStep) == "function", "MT_MOON_B2F has an onStep") + +local armed = { EVENT_GOT_HELIX_FOSSIL = true } +local rows, fired = rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, armed) +check(fired == true, "stepping on (3,5) with a fossil fires the ambush") +check(rows ~= nil, "the ambush handed a script to the runner") +rows = rows or {} + +check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, {})) == false, + "no fossil in the bag, no ambush") +check(select(2, rowsFor("MT_MOON_B2F", TRIGGER.x, TRIGGER.y, + { EVENT_GOT_HELIX_FOSSIL = true, + EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true })) == false, + "a beaten duo does not re-engage") + +-- ---- the duo closes in --------------------------------------------------- + +local motto = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText1")) +local challenge = indexOf(rows, isRow("show_text", "_MtMoonJessieJamesText2")) +local battle = indexOf(rows, isRow("start_battle")) +check(motto ~= nil, "the motto plays") +check(challenge ~= nil, "the challenge line plays") +check(battle ~= nil, "the fight starts") + +local playerStep = indexOf(rows, function(r) + return r[1] == "walk_npc" and r[2] == "player" +end) +check(playerStep ~= nil, "the simulated PAD_UP step is walked") +if playerStep then + check(dirsMatch(rows[playerStep][3], 1, "up"), + "one step, since wSimulatedJoypadStatesIndex is 1") + check(motto and playerStep > motto, + "the step comes after TEXT12, as StartSimulatingJoypadStates does") +end + +local jessieWalk = indexOf(rows, isRow("walk_npc", JESSIE)) +local jamesWalk = indexOf(rows, isRow("walk_npc", JAMES)) +check(jessieWalk ~= nil, "Jessie walks") +check(jamesWalk ~= nil, "James walks") +check(jessieWalk and jamesWalk and jessieWalk < jamesWalk, + "Script6 moves Jessie before Script9 moves James") + +if jessieWalk then + check(dirsMatch(rows[jessieWalk][3], 6, "left"), + "Jessie walks the six $06 of MovementData_f9e65, resolved LEFT") +end +if jamesWalk then + check(dirsMatch(rows[jamesWalk][3], 5, "left"), + "James walks the five $06 of MovementData_f9e66, resolved LEFT") +end + +-- where those step counts land them, against the object coordinates +if jessieWalk and jamesWalk and playerStep then + local playerX, playerY = TRIGGER.x, TRIGGER.y - 1 + local jx = JESSIE_START.x - #rows[jessieWalk][3] + local jmx = JAMES_START.x - #rows[jamesWalk][3] + eq(jx, playerX, "Jessie ends in the player's column") + eq(JESSIE_START.y, playerY - 1, "Jessie ends one cell above the player") + eq(jmx, playerX + 1, "James ends one cell east of the player") + eq(JAMES_START.y, playerY, "James ends level with the player") +end + +local faceJessie = indexOf(rows, isRow("face_object", JESSIE), jessieWalk) +local faceJames = indexOf(rows, isRow("face_object", JAMES), jamesWalk) +check(faceJessie ~= nil and rows[faceJessie][3] == "down", + "Script8 leaves Jessie facing DOWN at the player") +check(faceJames ~= nil and rows[faceJames][3] == "left", + "Script11 leaves James facing LEFT at the player") + +local facePlayer = indexOf(rows, isRow("face_player_dir")) +check(facePlayer ~= nil and rows[facePlayer][2] == "up", + "the player turns up to the duo, not right") +local emote = indexOf(rows, isRow("emote", "player")) +check(emote ~= nil, "the player gets the exclamation bubble TEXT12 shows") + +if challenge and jamesWalk and faceJames then + check(faceJames < challenge and challenge < (battle or math.huge), + "both walks finish before TEXT13, which precedes the fight") +end + +-- ---- and leaves behind a fade ------------------------------------------- + +-- Script14: GBFadeOutToBlack, HideObject JESSIE, HideObject JAMES, +-- UpdateSprites, Delay3, GBFadeInFromBlack. Checked at all four sites: the +-- other three already faded, so they are the regression half. +local function checkFadedExit(site, label, x, y, flags) + local siteRows = rowsFor(site, x, y, flags) + check(siteRows ~= nil, label .. " handed a script to the runner") + if not siteRows then return end + local fadeOut = indexOf(siteRows, function(r) + return r[1] == "fade" and r[2] == "out" + end) + check(fadeOut ~= nil, label .. " fades out before the duo vanishes") + if not fadeOut then return end + local firstHide = indexOf(siteRows, isRow("hide_object")) + check(firstHide ~= nil and firstHide > fadeOut, + label .. " hides nobody while the screen is still lit") + local secondHide = indexOf(siteRows, isRow("hide_object"), (firstHide or 0) + 1) + check(secondHide ~= nil and secondHide == (firstHide or 0) + 1, + label .. " hides both of them inside the same fade") + local fadeIn = indexOf(siteRows, function(r) + return r[1] == "fade" and r[2] == "in" + end, (secondHide or 0) + 1) + check(fadeIn ~= nil, label .. " fades back in with the duo gone") + local music = indexOf(siteRows, isRow("play_default_music")) + check(music ~= nil and fadeIn and music > fadeIn, + label .. " resumes the map theme after the fade, as PlayDefaultMusic does") +end + +checkFadedExit("MT_MOON_B2F", "Mt Moon B2F", TRIGGER.x, TRIGGER.y, armed) +checkFadedExit("ROCKET_HIDEOUT_B4F", "Rocket Hideout B4F", 24, 14, {}) +checkFadedExit("POKEMON_TOWER_7F", "Pokemon Tower 7F", 10, 12, {}) +checkFadedExit("SILPH_CO_11F", "Silph Co 11F", 3, 3, + { EVENT_BEAT_SILPH_CO_GIOVANNI = true }) + +-- every site walks the duo in rather than fighting them from across the room +for _, site in ipairs({ "MT_MOON_B2F", "ROCKET_HIDEOUT_B4F", + "POKEMON_TOWER_7F", "SILPH_CO_11F" }) do + local siteRows = ({ + MT_MOON_B2F = function() return rowsFor(site, TRIGGER.x, TRIGGER.y, armed) end, + ROCKET_HIDEOUT_B4F = function() return rowsFor(site, 25, 14, {}) end, + POKEMON_TOWER_7F = function() return rowsFor(site, 11, 12, {}) end, + SILPH_CO_11F = function() + return rowsFor(site, 2, 3, { EVENT_BEAT_SILPH_CO_GIOVANNI = true }) + end, + })[site]() + local walk = siteRows and indexOf(siteRows, isRow("walk_npc")) + local start = siteRows and indexOf(siteRows, isRow("start_battle")) + check(walk ~= nil and start ~= nil and walk < start, + site .. " walks somebody before the battle begins") +end + +T.finish("jessie james mt moon walk") diff --git a/tests/engine/jessie_james_pic.lua b/tests/engine/jessie_james_pic.lua new file mode 100644 index 00000000..8fb5d3ba --- /dev/null +++ b/tests/engine/jessie_james_pic.lua @@ -0,0 +1,73 @@ +-- Yellow's Jessie & James battle behind their own pic while keeping the +-- ROCKET class and the name "ROCKET" (#439). pokeyellow +-- home/trainers2.asm:36-50 IsFightingJessieJames swaps wTrainerPicPointer +-- for JessieJamesPic when wTrainerClass is ROCKET and wTrainerNo >= $2a +-- (parties 42-45), and GetTrainerName right below it is untouched, so only +-- the pic changes. Red has no such symbol, so the swap must stay Yellow's. +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local BattleState = require("src.battle.BattleState") + +local CLASS_PIC = "assets/generated/battle/trainers/rocket.png" +local DUO_PIC = "assets/generated/battle/trainers/jessie_james.png" + +-- a Yellow cache extracted after #439: OPP_ROCKET carries both pics +local yellow = { name = "ROCKET", pic = CLASS_PIC, picJessieJames = DUO_PIC } +-- Red/Blue, and any Yellow cache built before #439, carry only the class pic +local grunt = { name = "ROCKET", pic = CLASS_PIC } + +for _, party in ipairs({ 42, 43, 44, 45 }) do + T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", party), DUO_PIC, + "party " .. party .. " fights behind the duo pic") +end +T.eq(yellow.name, "ROCKET", "the duo keeps the class name") + +-- $2a is the first duo party; every grunt below it keeps the class pic +T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 41), CLASS_PIC, + "party 41 is still a lone grunt") +T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", 1), CLASS_PIC, + "the first ROCKET party is still a lone grunt") +T.eq(BattleState.trainerPicPath(yellow, "OPP_ROCKET", nil), CLASS_PIC, + "an unnumbered ROCKET falls back to party 1") +T.eq(BattleState.trainerPicPath( + { pic = "assets/generated/battle/trainers/super_nerd.png", + picJessieJames = DUO_PIC }, "OPP_SUPER_NERD", 42), + "assets/generated/battle/trainers/super_nerd.png", + "the class gate holds: only ROCKET swaps") +T.eq(BattleState.trainerPicPath(grunt, "OPP_ROCKET", 42), CLASS_PIC, + "a cache with no duo pic keeps the grunt instead of a nil pic") + +-- The extractor writes jessie_james.png only when the symbol is in the +-- manifest, so the symbol table is the other half of the contract. +local function readFile(path) + local handle = io.open(path, "r") + if not handle then return nil end + local text = handle:read("*a") + handle:close() + return text +end + +local yellowManifest = readFile("tools/rom_manifest_yellow.json") +T.check(yellowManifest ~= nil, "tools/rom_manifest_yellow.json is readable") +if yellowManifest then + local bank, addr = yellowManifest:match( + '"JessieJamesPic"%s*:%s*%[%s*(%d+)%s*,%s*(%d+)%s*%]') + T.eq(bank, "19", "JessieJamesPic sits in bank 0x13") + T.eq(addr, "31873", "JessieJamesPic sits at 0x7c81 in that bank") +end + +for _, path in ipairs({ "tools/rom_manifest.json", "tools/rom_manifest_blue.json" }) do + local text = readFile(path) + T.check(text ~= nil, path .. " is readable") + T.check(text == nil or text:find("JessieJamesPic", 1, true) == nil, + path .. " has no such symbol, matching pokered") +end + +-- the manifest is generated, so the generator has to keep asking for it +local gen = readFile("tools/make_yellow_manifest.py") +T.check(gen ~= nil, "tools/make_yellow_manifest.py is readable") +T.check(gen == nil or gen:find('"JessieJamesPic"', 1, true) ~= nil, + "a regenerated Yellow manifest keeps JessieJamesPic") + +T.finish("jessie james pic") diff --git a/tests/engine/launcher_delete_confirm.lua b/tests/engine/launcher_delete_confirm.lua new file mode 100644 index 00000000..dc60dc84 --- /dev/null +++ b/tests/engine/launcher_delete_confirm.lua @@ -0,0 +1,134 @@ +-- Launcher Delete affordance (src/import/RomImporter.lua): the per-frame hit +-- rects a draw() clears, and the two-click arm that guards both save-slot and +-- mod deletes (#433). Drives RomImporter:mousepressed / :_resetFrameRects on a +-- bare instance, so no window, no cache and no real save files are involved. +-- luajit tests/engine/launcher_delete_confirm.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") + +-- mousepressed timestamps the arm and expires it, so the clock has to move +local clock = 1000 +love.timer.getTime = function() return clock end + +local RomImporter = require("src.import.RomImporter") + +local function rect(id, y) + return { x = 100, y = y or 200, width = 40, height = 14, id = id } +end + +-- Only the fields mousepressed reads on its way to the Delete loops, plus +-- recorders in place of the two destructive calls. +local function launcher() + local self = setmetatable({}, RomImporter) + self.android = false + self.panelVersion = "red" + self.tab = "red" + self.slotScroll = {} + self.deletedSlots = {} + self.deletedMods = {} + self.selected = {} + self._deleteSlot = function(_, version, id) + table.insert(self.deletedSlots, version .. "/" .. id) + end + self._deleteMod = function(_, id) table.insert(self.deletedMods, id) end + self._selectSlot = function(_, version, id) + table.insert(self.selected, version .. "/" .. id) + end + return self +end + +local function clickDelete(self, r) + self:mousepressed(r.x + 2, r.y + 2, 1) +end + +-- ------- a frame that draws no panel leaves no Delete rect behind + +do + local self = launcher() + self.slotDeleteRects = { rect("slot1") } + self.modDeleteRects = { rect("bigmod", 260) } + self.slotRects = { rect("slot1") } + self.modRects = { rect("bigmod", 260) } + self:_resetFrameRects() + eq(self.slotDeleteRects, nil, "a frame reset drops the save Delete rects") + eq(self.modDeleteRects, nil, "a frame reset drops the mod Delete rects") + eq(self.slotRects, nil, "and the slot rows they sit on") + eq(self.modRects, nil, "and the mod toggles") + + -- the reporter's click: mods tab is up, the press lands where the game tab + -- drew Delete last time it was shown + self.tab = "mods" + clickDelete(self, rect("slot1")) + eq(#self.deletedSlots, 0, "a press on a stale Delete spot deletes nothing") +end + +-- ------- a save Delete needs two clicks on the same row + +do + local self = launcher() + local r = rect("slot1") + self.slotDeleteRects = { r } + clickDelete(self, r) + eq(#self.deletedSlots, 0, "the first click on Delete does not delete") + check(self._confirmDelete ~= nil and self._confirmDelete.id == "slot1", + "the first click arms that row") + clickDelete(self, r) + eq(self.deletedSlots[1], "red/slot1", "the second click on it deletes") + eq(self._confirmDelete, nil, "the arm is spent") +end + +-- ------- the arm is per row, per version, and any other press clears it + +do + local self = launcher() + local one, two = rect("slot1", 200), rect("slot2", 230) + self.slotDeleteRects = { one, two } + clickDelete(self, one) + clickDelete(self, two) + eq(#self.deletedSlots, 0, "a click on another row's Delete only arms that row") + eq(self._confirmDelete.id, "slot2", "the arm moved to the row just clicked") + + self.slotRects = { rect("slot3", 260) } + clickDelete(self, one) -- re-arm slot1 + self:mousepressed(102, 262, 1) -- press somewhere else entirely + clickDelete(self, one) + eq(#self.deletedSlots, 0, "a press elsewhere disarms, so Delete asks again") + + self:mousepressed(102, 262, 1) -- clear the arm left by the pair above + clickDelete(self, one) + self.panelVersion = "blue" + clickDelete(self, one) + eq(#self.deletedSlots, 0, "an arm from one game's tab cannot fire on another") +end + +-- ------- a stale arm expires instead of committing much later + +do + local self = launcher() + local r = rect("slot1") + self.slotDeleteRects = { r } + clickDelete(self, r) + clock = clock + 30 + clickDelete(self, r) + eq(#self.deletedSlots, 0, "an arm older than the confirm window is dead") + clickDelete(self, r) + eq(self.deletedSlots[1], "red/slot1", "and the fresh pair still deletes") +end + +-- ------- mods delete arms the same way + +do + local self = launcher() + local r = rect("bigmod", 260) + self.modDeleteRects = { r } + clickDelete(self, r) + eq(#self.deletedMods, 0, "the first click on a mod's Delete does not delete") + clickDelete(self, r) + eq(self.deletedMods[1], "bigmod", "the second click removes the mod") +end + +T.finish("launcher delete confirm") diff --git a/tests/engine/launcher_stray_mount.lua b/tests/engine/launcher_stray_mount.lua new file mode 100644 index 00000000..de6d65bc --- /dev/null +++ b/tests/engine/launcher_stray_mount.lua @@ -0,0 +1,88 @@ +-- The launcher's stray-mod scan must not borrow a game folder that is already +-- on the physfs read path: PHYSFS_mount succeeds for such a folder without +-- adding a search-path entry, so the paired unmount drops the mount the +-- portable cache and its mods/ live on, and the MODS panel empties (#413). +-- Runs the real SaveData.gameFolders / LauncherMods scan over a fused macOS +-- layout, with a search path that copies those physfs semantics. +-- luajit tests/engine/launcher_stray_mount.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") + +-- scripts/build.sh mac layout: the game is an archive inside the .app, the +-- player's portable folder is the one holding the .app. +local PORTABLE = "/Users/p/Games" +local APP = PORTABLE .. "/gen1recomp.app" +local SOURCE = APP .. "/Contents/Resources/game.love" +local BASE = APP .. "/Contents/MacOS" + +love.system = love.system or {} +love.system.getOS = function() return "OS X" end +love.filesystem.getSource = function() return SOURCE end +love.filesystem.getSourceBaseDirectory = function() return BASE end + +local CacheFs = require("src.import.CacheFs") +local SaveData = require("src.core.SaveData") +local LauncherMods = require("src.mods.LauncherMods") + +-- ------- isReadableRoot: the two folders a fused build can already read + +do + check(LauncherMods.isReadableRoot(SOURCE, SOURCE, PORTABLE), + "the physfs source is already readable") + check(LauncherMods.isReadableRoot(PORTABLE, SOURCE, PORTABLE), + "so is the portable folder CacheFs mounted") + check(not LauncherMods.isReadableRoot(BASE, SOURCE, PORTABLE), + "a folder beside them is not, so it still needs a scan mount") + check(not LauncherMods.isReadableRoot(nil, SOURCE, PORTABLE), + "no folder is not a readable folder") + check(not LauncherMods.isReadableRoot("", SOURCE, PORTABLE), + "nor is an empty path, which would match an unresolved cache root") + check(not LauncherMods.isReadableRoot(PORTABLE, SOURCE, nil), + "with no cache root mounted the portable folder is scannable again") +end + +-- ------- withMounted answers "could not look", never "nothing there" + +do + eq(CacheFs.withMounted("", "stray_scan", function() return "ran" end), nil, + "an empty dir is refused without running fn") +end + +-- ------- the scan over that layout leaves the portable mount standing + +do + eq(SaveData.gameFolders()[1], PORTABLE, + "the folder holding the .app is the first game folder") + + -- PHYSFS_mount on a directory already in the search path bails with success + -- and adds nothing (physfs 2.x and 3.x alike), while PHYSFS_unmount removes + -- whatever entry is there -- so a borrowed mount of an already-mounted + -- folder costs the caller the real one. + local searchPath = { [PORTABLE] = true } + local mounted = {} + CacheFs.root = function() return PORTABLE end + CacheFs.withMounted = function(dir, mountPoint, fn) + mounted[#mounted + 1] = dir + if not searchPath[dir] then searchPath[dir] = true end + local res = fn() + searchPath[dir] = nil + return res + end + + LauncherMods.strays() + + check(searchPath[PORTABLE], + "the portable folder is still mounted after the stray scan") + for _, dir in ipairs(mounted) do + check(dir ~= PORTABLE, "the scan never remounts the portable folder") + check(dir ~= SOURCE, "nor the source, whose mods/ is readable already") + end + eq(mounted[1], BASE, + "the folders that do need a mount are still scanned") +end + +T.finish("launcher stray scan mount") diff --git a/tests/engine/rom_pick_error_bug442.lua b/tests/engine/rom_pick_error_bug442.lua new file mode 100644 index 00000000..5009d259 --- /dev/null +++ b/tests/engine/rom_pick_error_bug442.lua @@ -0,0 +1,208 @@ +-- #442: an Android pick that cannot be used must say so. GameActivity writes +-- pick_error.flag when it has no readable stream for the chosen URI, and a pick +-- it did copy but the importer refuses (wrong size, unknown SHA-1) has to reach +-- startData's messages instead of leaving the launcher silent with the file +-- stuck on disk. Covers the routing, the removal, and the #167 carve-out for a +-- cart that is merely already imported. +-- luajit tests/engine/rom_pick_error_bug442.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 GameVersion = require("src.core.GameVersion") +local RomImporter = require("src.import.RomImporter") + +local MiB = 1024 * 1024 +local redData = string.rep("R", MiB) +local hackData = string.rep("X", MiB) -- 1 MiB, matches no known cart +local shortData = string.rep("R", MiB / 2) -- trimmed dump +local UNKNOWN_SHA1 = "0000000000000000000000000000000000000000" + +-- Map the fake blobs onto real version ids by first byte, so no crypto library +-- is needed headless (same trick as tests/rom_importer_android_pick_test.lua). +love.data = { hash = function(_, data) return { tag = data:sub(1, 1) } end } +love.data.encode = function(_, _, digest) + if type(digest) == "table" and digest.tag == "R" then + return GameVersion.info("red").sha1 + end + return UNKNOWN_SHA1 +end +love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end + +local pickCalls = {} +love.system = { + getOS = function() return "Android" end, + pickFile = function(kind) + pickCalls[#pickCalls + 1] = kind or "rom" + return true + end, +} + +-- Pre-fix these paths leave the launcher untouched, so read the message +-- defensively: every check should report, not blow up on the first nil. +local function detail(ri) return tostring(ri.detail or "") end + +local function clearSaveDir() + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + love.filesystem.remove(name) + end +end + +-- Only the fields the Android focus / choose / chooseMod paths read. _installMod +-- is stubbed per case because the outcome it reports is what consumePick keys on. +local function freshImporter(opts) + opts = opts or {} + pickCalls = {} + return setmetatable({ + android = true, + launcher = true, + workState = nil, + tab = opts.tab or "red", + ready = { red = opts.redReady and true or false, blue = false, yellow = false }, + saveNotice = {}, + modNotice = nil, + notice = nil, + slotScroll = {}, + activeSlot = {}, + _installMod = function(self, name) + self._installed = name + self.modNotice = { ok = opts.modOk and true or false, text = "install result" } + end, + _importSave = function(self, version, name) + self._imported = { version = version, name = name } + self.saveNotice[version] = { ok = false, text = "import result" } + end, + }, RomImporter) +end + +-- ------- pick_error.flag: GameActivity could not read the chosen URI at all + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "picked_rom.gb") + local ri = freshImporter({}) + ri:focus(true) + eq(ri.workState, "error", "an unreadable ROM pick lands on the error state") + check(detail(ri):find("Could not read the picked file", 1, true), + "the ROM notice names the unreadable pick") + check(detail(ri):find("/sdcard/pokeport/save", 1, true), + "the notice offers the save dir as the copy-it-yourself fallback") + eq(love.filesystem.getInfo("pick_error.flag"), nil, + "the flag is consumed, so refocusing does not re-report it") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "picked_mod.zip") + local ri = freshImporter({}) + ri:focus(true) + check(ri.modNotice ~= nil and ri.modNotice.ok == false, + "an unreadable mod pick reports on the mods panel") + eq(ri.workState, nil, "a failed mod pick does not error out the ROM panel") +end + +do + clearSaveDir() + love.filesystem.write("pick_error.flag", "picked_save.sav") + local ri = freshImporter({ redReady = true, tab = "mods" }) + ri.androidPendingVersion = "blue" + ri:focus(true) + check(ri.saveNotice.blue ~= nil and ri.saveNotice.blue.ok == false, + "an unreadable save pick reports on the game it was picked for") + eq(ri.androidPendingVersion, nil, "the pending save target is consumed with it") +end + +-- ------- a pick that copied fine but the importer refuses + +do + clearSaveDir() + love.filesystem.write("picked_rom.gb", shortData) + local ri = freshImporter({}) + ri:focus(true) + eq(ri.workState, "error", "a trimmed pick reports instead of staying silent") + check(detail(ri):find("1 MiB", 1, true), "the wrong-size message names the size") + eq(love.filesystem.getInfo("picked_rom.gb"), nil, + "the refused pick is dropped so the next tap starts clean") +end + +do + clearSaveDir() + love.filesystem.write("picked_rom.gb", hackData) + local ri = freshImporter({}) + ri:focus(true) + eq(ri.workState, "error", "a cart matching no known SHA-1 reports") + check(detail(ri):find(UNKNOWN_SHA1, 1, true), "the message quotes the hash") + check(detail(ri):find("[b] or [BF]", 1, true), + "the message names the dump tags that can never verify") + eq(love.filesystem.getInfo("picked_rom.gb"), nil, "the bad dump is dropped") +end + +do + clearSaveDir() + love.filesystem.write("picked_rom.gb", redData) + local ri = freshImporter({ redReady = true }) + ri:focus(true) + eq(ri.workState, nil, "an already-imported cart is not an error (#167)") + check(love.filesystem.getInfo("picked_rom.gb") ~= nil, + "#167's leftover is left alone, not reported and deleted") +end + +do + clearSaveDir() + love.filesystem.write("picked_rom.gb", redData) + local read = love.filesystem.read + love.filesystem.read = function(name) + if name == "picked_rom.gb" then return nil end + return read(name) + end + local ri = freshImporter({}) + ri:focus(true) + love.filesystem.read = read + eq(ri.workState, "error", "a pick present but unreadable from Lua reports too") + check(detail(ri):find("could not be read", 1, true), + "the unreadable-file message points at the picker") +end + +-- Choose must explain the refused pick rather than reopening the picker over it. +do + clearSaveDir() + love.filesystem.write("picked_rom.gb", hackData) + local ri = freshImporter({}) + ri:choose("blue") + eq(#pickCalls, 0, "Choose reports the refused pick before reopening the picker") + eq(ri.workState, "error", "Choose surfaces the same message focus does") +end + +-- ------- a rejected pick must not wall off the branches under it + +do + clearSaveDir() + love.filesystem.write("picked_mod.zip", "not a zip") + love.filesystem.write("picked_rom.gb", shortData) + local ri = freshImporter({}) + ri:focus(true) + eq(ri._installed, "picked_mod.zip", "the mod branch runs first") + eq(love.filesystem.getInfo("picked_mod.zip"), nil, + "a rejected SAF mod pick is retired, not left to win every refocus") + ri:focus(true) + eq(ri.workState, "error", "the next focus reaches the ROM pick under it") +end + +-- A USB copy is the player's own file: keep it, but stop retrying it this session. +do + clearSaveDir() + love.filesystem.write("mymod.zip", "not a zip") + local ri = freshImporter({}) + ri:chooseMod() + eq(ri._installed, "mymod.zip", "Import Mod picks up a USB copy") + check(love.filesystem.getInfo("mymod.zip") ~= nil, "the player's own file stays") + ri:chooseMod() + eq(#pickCalls, 1, "the second tap opens the picker instead of retrying it") + eq(pickCalls[1], "mod", "and asks for a mod archive") +end + +clearSaveDir() +T.finish() diff --git a/tests/engine/save_import_retry_bug420.lua b/tests/engine/save_import_retry_bug420.lua new file mode 100644 index 00000000..0ae47ec1 --- /dev/null +++ b/tests/engine/save_import_retry_bug420.lua @@ -0,0 +1,249 @@ +-- #420: importing a .sav on Android. Two halves: a pick that fails has to be +-- retired so the next tap reopens the picker instead of re-running the same +-- file forever, and the crosswalk tables have to come out of the target game's +-- ROM cache -- the launcher runs before CacheFs.mountVersion, so a bare require +-- sees Red's copy at best and nothing at all in a fused build. +-- luajit tests/engine/save_import_retry_bug420.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 GameVersion = require("src.core.GameVersion") + +-- ------------------------------------------------------------------ +-- Bug A: a failed Android save pick must not block the retry +-- ------------------------------------------------------------------ + +local RomImporter = require("src.import.RomImporter") + +local pickCalls = {} +love.system = { + getOS = function() return "Android" end, + pickFile = function(kind) + pickCalls[#pickCalls + 1] = kind or "rom" + return true + end, +} +love.filesystem.getSaveDirectory = function() return "/sdcard/pokeport/save" end + +local function clearSaveDir() + for _, name in ipairs(love.filesystem.getDirectoryItems("")) do + love.filesystem.remove(name) + end +end + +-- Only the fields the Android save-import path reads. _importSave is stubbed +-- with the outcome consumePick keys on, since the whole question is what the +-- launcher does with the file afterwards. +local function freshImporter(importOk) + pickCalls = {} + return setmetatable({ + android = true, + workState = nil, + tab = "red", + ready = { red = true, blue = false, yellow = false }, + saveNotice = {}, + notice = nil, + _importSave = function(self, version, name) + self._imports = self._imports or {} + self._imports[#self._imports + 1] = { version = version, name = name } + self.saveNotice[version] = { ok = importOk and true or false, text = "result" } + end, + }, RomImporter) +end + +do + clearSaveDir() + love.filesystem.write("picked_save.sav", "too short to be SRAM") + local ri = freshImporter(false) + ri:chooseSaveImport("red") + eq(#ri._imports, 1, "the pending SAF pick is what Import save consumes") + eq(love.filesystem.getInfo("picked_save.sav"), nil, + "a rejected SAF pick is dropped: it is GameActivity's own copy") + ri:chooseSaveImport("red") + eq(#pickCalls, 1, "the second tap reopens the picker instead of retrying it") + eq(pickCalls[1], "sav", "and asks for a battery save") + eq(#ri._imports, 1, "the rejected file is not run through the importer twice") +end + +do + clearSaveDir() + love.filesystem.write("picked_save.sav", "32768 bytes, as far as this test cares") + local ri = freshImporter(true) + ri:chooseSaveImport("blue") + eq(love.filesystem.getInfo("picked_save.sav"), nil, + "a pick that imported cleanly is still removed") + eq(#pickCalls, 0, "and the picker is not reopened over a successful import") +end + +-- A USB copy is the player's own file sitting in the save dir, so a failed one +-- is skipped for the session rather than deleted. +do + clearSaveDir() + love.filesystem.write("pokemon_red.sav", "wrong size") + local ri = freshImporter(false) + ri:chooseSaveImport("red") + eq(ri._imports[1].name, "pokemon_red.sav", "Import save picks up a USB copy") + check(love.filesystem.getInfo("pokemon_red.sav") ~= nil, + "the player's own .sav stays on disk") + ri:chooseSaveImport("red") + eq(#pickCalls, 1, "the skipped USB copy no longer wins the scan") + eq(#ri._imports, 1, "and is not re-imported") +end + +-- The focus path (SAF returns, GameActivity has written the pick) retires the +-- same way, so a refocus loop cannot re-run a rejected file. +do + clearSaveDir() + love.filesystem.write("picked_save.sav", "wrong size") + local ri = freshImporter(false) + ri.androidPendingVersion = "yellow" + ri:focus(true) + eq(ri._imports[1].version, "yellow", "focus imports for the game that was picked for") + eq(love.filesystem.getInfo("picked_save.sav"), nil, "and retires the rejected pick") + ri:focus(true) + eq(#ri._imports, 1, "the next focus has nothing left to re-import") +end + +clearSaveDir() + +-- ------------------------------------------------------------------ +-- Bug B: the crosswalk comes from the target game's ROM cache +-- ------------------------------------------------------------------ + +-- Fake CacheFs, installed before SaveConvert is required so its lazy require +-- picks it up. Each read reports the prefix it was asked under and hands back +-- a module tagged with it, which is what makes "whose cache" observable. +local reads = {} +local SENTINEL = "SENTINEL/" +local cacheFails = false +local fakeCache = { prefix = SENTINEL } +function fakeCache.read(path) + reads[#reads + 1] = { path = path, prefix = fakeCache.prefix } + if cacheFails then error("no such cache entry: " .. path) end + return ("return { cacheTag = %q, path = %q }") + :format(tostring(fakeCache.prefix), path) +end +package.loaded["src.import.CacheFs"] = fakeCache + +local SaveConvert = require("src.save_convert.SaveConvert") + +local GENERATED = { "pokemon", "moves", "items", "maps" } + +local function prefixes() + local seen = {} + for _, r in ipairs(reads) do seen[r.prefix] = (seen[r.prefix] or 0) + 1 end + return seen +end + +do + reads = {} + local data, err = SaveConvert.loadData("blue") + check(type(data) == "table", "loadData(blue) resolves: " .. tostring(err)) + for _, name in ipairs(GENERATED) do + eq(data and data[name] and data[name].cacheTag, GameVersion.VERSIONS.blue.cachePrefix, + name .. " comes out of Blue's cache, not the un-prefixed read path") + end + eq(prefixes()[GameVersion.VERSIONS.blue.cachePrefix], #GENERATED, + "all four generated tables are read under Blue's cache prefix") + eq(fakeCache.prefix, SENTINEL, + "CacheFs.prefix is launcher-owned state and is put back after the read") + check(data and data.eventFlags ~= nil, + "the save-convert-only crosswalks still load through require") +end + +do + reads = {} + SaveConvert.loadData("blue") + eq(#reads, 0, "a second load for the same game reuses the cached set") +end + +-- importSav's 2nd arg is the save-format stamp; the 3rd is what selects the +-- crosswalk. Red's cache prefix is the empty string, so the recorded prefix +-- doubles as proof the read went through CacheFs at all. +local zeros = string.rep("\0", SaveConvert.SAVE_SIZE) +do + reads = {} + SaveConvert.importSav(zeros, 2, "red") + eq(prefixes()[GameVersion.VERSIONS.red.cachePrefix], #GENERATED, + "importSav reads the crosswalk from the game named by its 3rd argument") +end + +do + reads = {} + SaveConvert.importSav(zeros, 2) + eq(#reads, 0, "with no game named, the format stamp is not read as one") +end + +do + reads = {} + local ok, _, err = pcall(SaveConvert.exportSav, { meta = {} }, "yellow") + check(ok, "exportSav takes the same game argument without raising: " .. tostring(err)) + eq(prefixes()[GameVersion.VERSIONS.yellow.cachePrefix], #GENERATED, + "exportSav reads Yellow's crosswalk on the way back out") +end + +do + reads = {} + local data = SaveConvert.loadData("yellow") + eq(data and data.maps and data.maps.cacheTag, GameVersion.VERSIONS.yellow.cachePrefix, + "Yellow gets Yellow's tables: the set is keyed per game, not global") + eq(#reads, 0, "and shares the set exportSav already warmed") +end + +-- The require path still has to carry SaveConvert under plain luajit, where +-- there is no cache to read from. +do + reads = {} + cacheFails = true + local ok, data, err = pcall(SaveConvert.loadData, "red") + cacheFails = false + check(ok, "an unreadable cache falls back instead of raising: " .. tostring(data)) + if loadfile("data/generated/maps.lua") then + check(type(data) == "table", + "the require path still resolves the tables headless: " .. tostring(err)) + end +end + +-- ------------------------------------------------------------------ +-- The launcher's glue names the game it is importing for (cross-file contract) +-- ------------------------------------------------------------------ + +do + local seen = {} + package.loaded["src.save_convert.SaveConvert"] = { + SAVE_SIZE = 32768, + importSav = function(_, version, gameVersion) + seen.import = { version = version, gameVersion = gameVersion } + return nil, "stub" + end, + exportSav = function(_, gameVersion) + seen.export = { gameVersion = gameVersion } + return nil, "stub" + end, + } + package.loaded["src.core.SaveData"] = { + load = function() return { meta = {} } end, + activeSlot = function() return "slot1" end, + buildMeta = function() return {} end, + createSlot = function() return "slot1" end, + writeSlot = function() return true end, + setActiveSlot = function() return true end, + } + local SaveFileIO = require("src.import.SaveFileIO") + + SaveFileIO.importToSlot(string.rep("\0", 32768), "yellow") + eq(seen.import and seen.import.gameVersion, "yellow", + "importToSlot tells SaveConvert whose cache to read") + eq(seen.import and seen.import.version, "yellow", + "and still passes the version stamp it always did") + + SaveFileIO.exportActiveSlot("blue") + eq(seen.export and seen.export.gameVersion, "blue", + "exportActiveSlot names the game on the way back out") +end + +T.finish() diff --git a/tests/engine/teleport_ends_turn.lua b/tests/engine/teleport_ends_turn.lua new file mode 100644 index 00000000..de8b3adf --- /dev/null +++ b/tests/engine/teleport_ends_turn.lua @@ -0,0 +1,105 @@ +-- A successful Teleport/Roar/Whirlwind ends the turn where it lands: the +-- second mover never moves and the residual sweep never runs (#438). +-- MainInBattleLoop reads wEscapedFromBattle after every Execute*Move and +-- `ret nz` (engine/battle/core.asm:419-457), leaving the loop before the +-- other side's move and before HandlePoisonBurnLeechSeed; the port queues +-- both moves plus endOfTurn up front, so each has to re-read battle.result. +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) + +-- keyed TELEPORT because SwitchAndTeleportEffect picks its escape and +-- failure text off the move id (Roar and Whirlwind read differently) +Data.moves.TELEPORT = { + id = "TELEPORT", index = 97, name = "FIX TELEPORT", + type = "PSYCHIC", power = 0, accuracy = 100, pp = 20, + effect = "SWITCH_AND_TELEPORT_EFFECT", +} +Data.moves.FIX_SING = { + id = "FIX_SING", index = 98, name = "FIX SING", + type = "NORMAL", power = 0, accuracy = 55, pp = 15, + effect = "SLEEP_EFFECT", +} + +local save = SaveData.newGame() +save.party = { Pokemon.new(Data, "FIXMON_A", 20) } +local game = { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } + +-- foeLevel decides SwitchAndTeleportEffect's auto-success (effects.asm: +-- 810-909): at or above the player's level it always escapes, below it +-- the rng(0, sum) roll of 0 below foeLevel/4 always fails +local function setup(foeLevel) + local battle = BattleState.newWild(game, "FIXMON_C", foeLevel) + battle.rng = function() return 0 end + battle.enemyAction = function() return { id = "TELEPORT", pp = 20 } end + battle.enemy.curStats.speed = 200 -- the foe moves first + battle.player.curStats.speed = 1 + battle.player.mon.status = "PSN" -- something for the residual sweep to do + battle.player.mon.hp = battle.player.curStats.hp + return battle, { id = "FIX_SING", pp = 15 } +end + +-- consume the queue the way updateQueue does, minus the presentation +local function drain(battle) + local texts = {} + for _ = 1, 400 do + local item = table.remove(battle.queue, 1) + if not item then return texts end + if item.text then texts[#texts + 1] = item.text end + if item.fn then + battle.nextInsert = 0 + item.fn() + end + end + error("the turn queue never drained") +end + +local function saidWith(texts, needle) + for _, text in ipairs(texts) do + if text:find(needle, 1, true) then return true end + end + return false +end + +local escaped, sing = setup(40) +local hpBefore = escaped.player.mon.hp +escaped:resolveTurn(sing) +local texts = drain(escaped) + +T.eq(escaped.result, "run", "the escape decides the battle") +T.eq(escaped.afterQueue, "finish", "the queue closes the battle") +T.check(saidWith(texts, "ran from battle"), "the foe announces the escape") +T.check(not saidWith(texts, "FIX SING"), "the player's move never announces") +T.eq(texts[#texts]:find("ran from battle", 1, true) ~= nil, true, + "the escape line is the last thing printed") +T.eq(escaped.enemy.mon.status, nil, "the foe is gone, not asleep") +T.eq(sing.pp, 15, "the unspent move keeps its PP") +T.eq(escaped.player.mon.hp, hpBefore, "no residual poison tick after the escape") + +-- the guard must not swallow the ordinary turn: a failed Teleport leaves +-- result nil, so the player still moves and the residual sweep still runs +local stayed, sing2 = setup(8) +local hpBefore2 = stayed.player.mon.hp +stayed:resolveTurn(sing2) +local texts2 = drain(stayed) + +T.eq(stayed.result, nil, "a failed escape decides nothing") +T.eq(stayed.afterQueue, "menu", "the turn hands back to the menu") +T.check(saidWith(texts2, "But, it failed!"), "the failed Teleport says so") +T.check(saidWith(texts2, "FIX SING"), "the player still moves") +T.eq(stayed.enemy.mon.status, "SLP", "the foe still falls asleep") +T.eq(sing2.pp, 14, "the move that ran spent its PP") +T.check(stayed.player.mon.hp < hpBefore2, "the residual sweep still ticks poison") + +Data.moves.TELEPORT = nil +Data.moves.FIX_SING = nil +T.finish("teleport ends turn") diff --git a/tests/engine/teleport_escape_ends_turn.lua b/tests/engine/teleport_escape_ends_turn.lua new file mode 100644 index 00000000..a42e8ecc --- /dev/null +++ b/tests/engine/teleport_escape_ends_turn.lua @@ -0,0 +1,130 @@ +-- A wild mon that escapes with TELEPORT ends the turn where it lands +-- (#441). MainInBattleLoop reads wEscapedFromBattle right after +-- ExecuteEnemyMove and rets (engine/battle/core.asm:417-421, 456-460), so +-- the second mover never announces, HandlePoisonBurnLeechSeed never runs +-- the residual sweep, and CheckNumAttacksLeft never releases a trapping +-- counter. resolveTurn queues both movers plus endOfTurn up front, so the +-- escape has to be observed by the rows themselves. +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) + +Data.moves.TELEPORT = { + id = "TELEPORT", index = 100, name = "TELEPORT", + type = "PSYCHIC", power = 0, accuracy = 100, pp = 20, + effect = "SWITCH_AND_TELEPORT_EFFECT", +} + +local function newGame() + local save = SaveData.newGame() + save.party = { Pokemon.new(Data, "FIXMON_A", 30) } + return { data = Data, save = save, + stack = { top = function() return nil end, push = function() end } } +end + +-- enemy first: the escaping mon outspeeds, and it holds TELEPORT alone so +-- the wild success path (level 40 >= level 30) cannot roll a failure +local function setup(enemyMove) + local battle = BattleState.newWild(newGame(), "FIXMON_C", 40) + battle.player.mon.stats.speed = 1 + battle.enemy.mon.stats.speed = 200 + battle.enemy.mon.moves = { { id = enemyMove, pp = 20 } } + battle.enemy.curMoves = battle.enemy.mon.moves + battle.enemyAction = function() return battle.enemy.curMoves[1] end + battle.rng = function(lo) return lo end -- accuracy roll hits, damage roll floors + return battle +end + +-- drain the queue the way updateQueue does (fn rows run with nextInsert +-- reset so sayNext/actNext land right behind the current row), collecting +-- every message the turn would have printed +local function pump(battle) + local said = {} + local guard = 0 + while battle.queue[1] and guard < 400 do + guard = guard + 1 + local item = table.remove(battle.queue, 1) + if item.fn then + battle.nextInsert = 0 + item.fn() + elseif item.text then + said[#said + 1] = item.text + end + end + T.check(guard < 400, "the turn queue drained") + return said +end + +local function saidWith(said, needle) + for _, line in ipairs(said) do + if line:find(needle, 1, true) then return line end + end + return nil +end + +-- --- the escape turn ------------------------------------------------- +local battle = setup("TELEPORT") +battle.player.mon.status = "PSN" +local tackle = battle.player.curMoves[1] +local startEnemyHP, startPlayerHP = battle.enemy.mon.hp, battle.player.mon.hp +local startPP = tackle.pp + +battle:resolveTurn(tackle) +local said = pump(battle) + +T.check(saidWith(said, "ran from battle!"), "the wild mon announces the escape") +T.eq(battle.result, "run", "the escape settles the battle as a run") +T.eq(battle.afterQueue, "finish", "the queue finishes instead of reopening the menu") +T.check(not saidWith(said, "FIX TACKLE"), + "the second mover never announces its move after the escape") +T.eq(battle.enemy.mon.hp, startEnemyHP, "the escaped mon takes no damage") +T.eq(tackle.pp, startPP, "the lost turn costs the second mover no PP") +T.check(not saidWith(said, "hurt by"), "the residual sweep is skipped") +T.eq(battle.player.mon.hp, startPlayerHP, "no poison tick on the escape turn") + +-- --- the trapping counter, on its own ------------------------------- +-- a counter sitting at 0 holds its victim until CheckNumAttacksLeft clears +-- it, so it rides a separate turn: the held player must not be what keeps +-- the second mover from moving above. +local trap = setup("TELEPORT") +trap.enemy.trappingTurns = 0 +trap:resolveTurn(trap.player.curMoves[1]) +pump(trap) +T.eq(trap.result, "run", "the escape still settles the trapped turn") +T.eq(trap.enemy.trappingTurns, 0, + "CheckNumAttacksLeft does not release the counter on the escape turn") + +local trapOk = setup("FIX_TACKLE") +trapOk.enemy.trappingTurns = 0 +trapOk:resolveTurn(trapOk.player.curMoves[1]) +pump(trapOk) +T.eq(trapOk.enemy.trappingTurns, nil, + "an ordinary turn releases the spent counter") + +-- --- a plain turn still does all of it ------------------------------ +local ok = setup("FIX_TACKLE") +ok.player.mon.status = "PSN" +local scratch = ok.player.curMoves[1] +local okEnemyHP, okPlayerHP = ok.enemy.mon.hp, ok.player.mon.hp +local okPP = scratch.pp + +ok:resolveTurn(scratch) +local okSaid = pump(ok) + +T.eq(ok.result, nil, "an ordinary turn settles nothing") +T.check(saidWith(okSaid, "FIX TACKLE"), "both movers announce") +T.check(ok.enemy.mon.hp < okEnemyHP, "the second mover's move lands") +T.eq(scratch.pp, okPP - 1, "the second mover spends PP") +T.check(ok.player.mon.hp < okPlayerHP, "the poison tick runs") +T.check(saidWith(okSaid, "hurt by"), "the residual sweep prints") + +Data.moves.TELEPORT = nil +T.finish("teleport escape ends turn") diff --git a/tests/engine/wave_channel_mix_bug429.lua b/tests/engine/wave_channel_mix_bug429.lua new file mode 100644 index 00000000..56bb7820 --- /dev/null +++ b/tests/engine/wave_channel_mix_bug429.lua @@ -0,0 +1,139 @@ +-- The shipped per-channel mix must be authentic, and the wave channel must +-- sit exactly one octave below a pulse at the same note (#429). On hardware +-- ch3 counts 65536/(2048-x) against the pulses' 131072/(2048-x), and pokered +-- writes the frequency register for CHAN3 unmodified +-- (audio/engine_1.asm:904-944 Audio1_ApplyWavePatternAndFrequency loads only +-- the wave pattern), with no attenuation either +-- (Audio1_ApplyDutyCycleAndSoundLength:887 skips just the duty nibble). A +-- CHANNEL_VOLUME/CHANNEL_PITCH of 0.25/0.5 on ch3 therefore buried every Ch3 +-- countermelody at quarter amplitude and a second octave down. +-- ROM-free: ChipAsm blobs, no data/generated/. +-- luajit tests/engine/wave_channel_mix_bug429.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check = T.check + +love = require("tests.love_stub") + +local ChipAsm = require("src.audio.ChipAsm") +local ChipSynth = require("src.core.ChipSynth") +-- requiring ChipAudio is the point of the first block: the module pushes its +-- shipped CHANNEL_VOLUME / CHANNEL_PITCH into ChipSynth at load +local ChipAudio = require("src.core.ChipAudio") + +-- ------- the shipped defaults + +local vols = ChipSynth.getChannelVolumes() +local pitches = ChipSynth.getChannelPitches() +for hw = 1, 4 do + check(vols[hw] == 1, + ("channel %d ships at volume 1, not %s"):format(hw, tostring(vols[hw]))) + check(pitches[hw] == 1, + ("channel %d ships at pitch 1, not %s"):format(hw, tostring(pitches[hw]))) +end +-- ChipAudio.playMusic forwards these to the worker with every play command +-- (ChipAudio.lua:190-191 -> chip_worker.lua:50-54), so a non-unity default +-- reaches the threaded path as well as the synchronous one +local forwarded = ChipAudio.getChannelVolumes() +check(forwarded[3] == 1, "the wave gain handed to the worker is unity") +check(ChipAudio.getChannelPitches()[3] == 1, + "the wave pitch handed to the worker is unity") + +-- ------- fixtures: one note, once on a pulse and once on the wave channel + +local data = { audio = {} } + +-- +1 for the first half of the table and -1 for the second, so the wave +-- crosses zero twice per cycle exactly like a 50% duty pulse +local squareWave = {} +for index = 1, 32 do squareWave[index] = index <= 16 and 1 or -1 end + +local function pulseSong(note, octave) + return ChipAsm.song{ + channels = { { hw = 1, program = { + { duty = 2 }, + { notetype = { speed = 12, volume = 15, fade = 0 } }, + { octave = octave }, + { note = note, len = 15 }, + } } }, + } +end + +local function waveSong(note, octave) + return ChipAsm.song{ + channels = { { hw = 3, program = { + { notetype = { speed = 12, waveLevel = 1, waveInstrument = 0 } }, + { octave = octave }, + { note = note, len = 15 }, + } } }, + waves = { squareWave }, + } +end + +local function crossings(sd) + local count, prev = 0, sd:getSample(0) + for index = 1, sd:getSampleCount() - 1 do + local sample = sd:getSample(index) + if prev * sample < 0 then count = count + 1 end + prev = sample + end + return count +end + +local function renderPulse(note, octave) + return crossings(ChipAudio._renderMusicChannelForTest( + data, pulseSong(note, octave), 0.25, 1)) +end + +local function renderWave(note, octave) + return crossings(ChipAudio._renderMusicChannelForTest( + data, waveSong(note, octave), 0.25, 3)) +end + +-- ------- one octave down, and only one + +for _, spec in ipairs({ { "C", 4 }, { "G", 4 }, { "C", 5 } }) do + local note, octave = spec[1], spec[2] + local pulse = renderPulse(note, octave) + local wave = renderWave(note, octave) + check(pulse > 40, ("%s%d on the pulse channel sounds (%d crossings)") + :format(note, octave, pulse)) + check(wave > 20 and math.abs(wave - pulse / 2) <= math.max(4, pulse * 0.06), + ("%s%d on the wave channel is one octave down (%d vs %d crossings)") + :format(note, octave, wave, pulse)) +end + +-- the wave channel one octave up from a pulse note lands on that pulse note, +-- which is the same statement from the other side +local waveUp = renderWave("C", 5) +local pulseAt = renderPulse("C", 4) +check(math.abs(waveUp - pulseAt) <= math.max(4, pulseAt * 0.06), + ("wave C5 matches pulse C4 (%d vs %d crossings)"):format(waveUp, pulseAt)) + +-- ------- the 0.1.38 mix, so the guard above is known to bite + +local brokenPitch, brokenVol +local baseValue = ChipAudio._traceFirstMusicSampleForTest( + data, waveSong("C", 4))[1].value +ChipAudio.setChannelPitch(3, 0.5) +brokenPitch = renderWave("C", 4) +ChipAudio.setChannelVolume(3, 0.25) +brokenVol = ChipAudio._traceFirstMusicSampleForTest( + data, waveSong("C", 4))[1].value +ChipAudio.setChannelVolumes({ 1, 1, 1, 1 }) +ChipAudio.setChannelPitches({ 1, 1, 1, 1 }) + +local authentic = renderWave("C", 4) +check(brokenPitch > 0 and math.abs(brokenPitch - authentic / 2) + <= math.max(4, authentic * 0.06), + ("pitch 0.5 on ch3 drops a second octave (%d vs %d crossings)") + :format(brokenPitch, authentic)) +check(math.abs(baseValue) > 0 and math.abs(brokenVol - baseValue * 0.25) < 1e-9, + "volume 0.25 on ch3 quarters the wave sample") +check(ChipSynth.getChannelVolumes()[3] == 1 + and ChipSynth.getChannelPitches()[3] == 1, + "the mix is back at unity for the suites that follow") + +T.finish("wave channel mix") diff --git a/tests/parity_blackout_no_party.lua b/tests/parity_blackout_no_party.lua new file mode 100644 index 00000000..ce1adc40 --- /dev/null +++ b/tests/parity_blackout_no_party.lua @@ -0,0 +1,163 @@ +-- Parity: starting a battle with no useable POKeMON blacks the player out, +-- it does not cancel the battle (#425). pokered's .checkAnyPartyAlive +-- (engine/battle/core.asm:158-162) runs after EnemySendOutFirstMon and the +-- 40-frame delay -- the battle has already begun -- and jumps to +-- HandlePlayerBlackOut (core.asm:1145-1166), which prints +-- PlayerBlackedOutText2 (data/text/text_2.asm:896) and drops through to the +-- overworld blackout warp (home/overworld.asm:335-360). Cancelling instead +-- left the battle theme looping, the party at 0 HP and no warp, so every +-- later encounter took the same exit. +-- Self-contained; run via `luajit tests/parity_blackout_no_party.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 blackout no party") +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 Music = require("src.core.Music") +local Runtime = require("src.mods.Runtime") +local TextBox = require("src.render.TextBox") +local BattleState = require("src.battle.BattleState") +local OW = require("src.world.OverworldController") + +local MAP = "ROUTE_1" +local MAP_SONG = Data.audio.mapSongs[MAP] +local WILD_SONG = Data.audio.battle.wild + +-- every song request in order: "the theme was never restored" and "the theme +-- was never started" have to read differently. Music.playMap still sets the +-- state Music.restoreMap reads, so the real restore path is under test. +local songs = {} +Music.play = function(_, song) songs[#songs + 1] = song end +local function lastSong() return songs[#songs] end +-- run_tests.lua dofiles the parity files into one process and other suites +-- leave Music.playBattle a no-op, so stand in for its body here: one +-- Music.play of audio.battle[kind] (src/core/Music.lua:350-357) +Music.playBattle = function(data, kind) + local b = data.audio.battle + Music.play(data, b[kind] or b.wild) +end + +local events = { listeners = {} } +function events:emit(name, payload) + self.listeners[name] = payload +end +function events:removeOwner() end +Runtime.install(events, Runtime.hooks) + +Game.data = Data +Game.input = Input; Input:init() +Game.renderer = Renderer; Renderer:init() +Game.stack = StateStack; StateStack:init() + +local warp +local function newWorld(mapId) + while Game.stack:top() do Game.stack:pop() end + Game.stack:push(OW, mapId, 5, 5, "down") + local ow = Game.stack:top() + Game.overworld = ow + -- the real one runs a Transition; only the destination matters here + ow.startWarpTo = function(self, map, x, y, facing) + warp = { map = map, x = x, y = y, facing = facing } + self.transitioning = false + end + Music.playMap(Data, mapId) + return ow +end + +-- a wild encounter as OverworldState:checkEncounter builds it (:3182-3194) +local function encounter(ow) + local battle = BattleState.newWild(Game, "PIDGEY", 3) + local got + battle.onFinish = function(result) + got = result + ow:afterBattle(result, battle) + end + return battle, function() return got end +end + +Game.save = SaveData.newGame() +Game.save.player.name = "RED" +Game.save.money = 3000 +Game.save.party = { Pokemon.new(Data, "MAGIKARP", 10) } +Game.save.party[1].hp = 0 +local full = Game.save.party[1].stats.hp +Game.save.lastHeal = { map = "VIRIDIAN_POKECENTER", x = 3, y = 3, + outdoor = { id = "VIRIDIAN_CITY", x = 17, y = 8 } } + +local ow = newWorld(MAP) +eq(lastSong(), MAP_SONG, MAP .. " is playing its own theme before the step") + +local battle, result = encounter(ow) +eq(battle.dead, true, "the constructor still flags a partyless wild battle") +eq(battle.player, nil, "and installs no player battler (Party.firstHealthy)") + +-- pushBattle starts the theme with the wipe, before enter() ever runs +-- (:660-679, audio/play_battle_music.asm), which is why a cancelled battle +-- left the battle music looping over the map +ow:pushBattle(battle) +eq(lastSong(), WILD_SONG, "the wipe has already started the battle theme") +Game.stack:pop() -- the transition; its callback pushes the battle below + +Game.stack:push(battle) +eq(Game.stack:top() ~= battle, true, "enter() takes the battle back off the stack") +eq(lastSong(), MAP_SONG, "and restores the map theme instead of looping the battle one") +eq(battle.result, "lose", "the battle resolves as a loss, not a skip") +local ended = events.listeners["battle.ended"] +check(ended ~= nil and ended.result == "lose", + "battle.ended reports a loss for mods too") + +local box = Game.stack:top() +check(getmetatable(box) == TextBox, "the blackout text is on screen over the map") +local lines = {} +for _, page in ipairs(box and box.pages or {}) do + for _, line in ipairs(page) do lines[#lines + 1] = line end +end +local text = table.concat(lines, " / ") +check(text:find("RED", 1, true) ~= nil, "the text names the player") +check(text:find("out of", 1, true) ~= nil and text:find("POK", 1, true) ~= nil, + "PlayerBlackedOutText2 line 1: is out of useable POKeMON!") +check(text:find("blacked", 1, true) ~= nil, + "PlayerBlackedOutText2 line 2: blacked out!") + +-- the cancel path called onFinish itself, with no box to dismiss +if box and box.onDone then box.onDone() end +eq(result(), "lose", "onFinish gets \"lose\", so afterBattle blacks out") +eq(Game.save.party[1].hp, full, "the party is revived at the heal point") +eq(Game.save.money, 1500, "half the money is lost, as on any blackout") +check(warp ~= nil and warp.map == "VIRIDIAN_POKECENTER", + "and the player is warped to the last heal point") +-- the brick: before #425 the party was still at 0 HP here, so this second +-- encounter took the same exit, and so did every one after it +local ow2 = newWorld(MAP) +local live = BattleState.newWild(Game, "PIDGEY", 3) +eq(live.dead, nil, "the next encounter is a real battle again") +check(live.player ~= nil, "with the revived MAGIKARP leading it") + +-- Oak's Lab starter rival: HandlePlayerBlackOut rets above +-- PlayerBlackedOutText2 (core.asm:1147-1149) and the lab script heals, so +-- there is no text, no money loss and no warp. +Game.save.money = 3000 +Game.save.party[1].hp = 0 +local lab = newWorld("OAKS_LAB") +warp = nil +local labResult +local rival = BattleState.newTrainer(Game, "OPP_RIVAL1", 1) +rival.onFinish = function(r) labResult = r lab:afterBattle(r, rival) end +eq(rival.dead, true, "a partyless lab rival battle is flagged the same way") +Game.stack:push(rival) +check(getmetatable(Game.stack:top()) ~= TextBox, + "the lab rival prints no blackout text (core.asm:1147-1149)") +eq(labResult, "lose", "it still finishes as a loss") +eq(Game.save.money, 3000, "no money is lost in the lab") +eq(warp, nil, "and the player stays in the lab for OaksLabRivalEndBattleScript") + +S.finish() diff --git a/tests/parity_gift_atomicity.lua b/tests/parity_gift_atomicity.lua new file mode 100644 index 00000000..96e6e394 --- /dev/null +++ b/tests/parity_gift_atomicity.lua @@ -0,0 +1,185 @@ +-- 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 new file mode 100644 index 00000000..75383ece --- /dev/null +++ b/tests/parity_marowak_ball.lua @@ -0,0 +1,184 @@ +-- 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 + 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 the disguise is gone") + 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_status_true_color.lua b/tests/parity_status_true_color.lua new file mode 100644 index 00000000..bb7e6aea --- /dev/null +++ b/tests/parity_status_true_color.lua @@ -0,0 +1,101 @@ +-- Parity test: the status screen's mon pic has to sit out the SGB recolor +-- when the sprite record is true-color (#430). SetPal_StatusScreen puts a +-- monPal zone over the pic rect (status_screen.asm, mirrored by +-- SummaryMenu:sgbPalettes), so a full-color pic drawn there was run through +-- the 4-shade remap the way battle and the Pokedex entry page (#350) already +-- avoid. Run with `luajit tests/parity_status_true_color.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 status true color") +local check, eq = S.check, S.eq + +local Data = require("src.core.Data") +if not Data.maps then Data:load() end +require("src.render.Font").load(Data) + +local PaletteFX = require("src.render.PaletteFX") +local Sprites = require("src.pokemon.Sprites") +local Sound = require("src.core.Sound") +local Pokemon = require("src.pokemon.Pokemon") +local SaveData = require("src.core.SaveData") +local SummaryMenu = require("src.ui.SummaryMenu") + +local savedCry = Sound.playCry +Sound.playCry = function() end + +-- the rects the frame reported on the UI canvas, the pass the status screen +-- draws into (Renderer appends them to that pass's zone list) +local function uiRects(draw) + PaletteFX.clearTrueColor() + PaletteFX.setPass("ui") + draw() + local rects = PaletteFX.trueColorRects("ui") + PaletteFX.setPass(nil) + return rects +end + +local def = Data.pokemon.PIKACHU +local savedTrueColor = def.trueColor + +local save = SaveData.newGame() +local mon = Pokemon.new(Data, "PIKACHU", 20) +local game = { data = Data, save = save, stack = { pop = function() end } } + +-- the premise: this screen colorizes the pic through a species zone, so a +-- true-color pic needs an unshaded rect on top of it +do + local pals = SummaryMenu.sgbPalettes({ mon = mon }, game) + local monPal = PaletteFX.monPal(Data, "PIKACHU") + local zone + for _, z in ipairs(pals or {}) do + if z.colors == monPal and z.w < 160 then zone = z end + end + check(zone ~= nil, "SetPal_StatusScreen's monPal zone covers the pic") + if zone then + eq(zone.x .. "," .. zone.y, "8,0", "and it starts at the pic's corner") + end +end + +def.trueColor = true + +local _, pathTrueColor = Sprites.path(Data, "PIKACHU", "front", + { mon = mon, kind = "summary" }) +check(pathTrueColor == true, + "Sprites.path reports trueColor for the summary pic") + +local screen = SummaryMenu.new(game, mon) +check(screen.spriteTrueColor == true, + "SummaryMenu keeps the sprite's trueColor flag (#430)") + +local pw, ph = screen.sprite:getDimensions() +local py = math.max(0, 56 - ph) + +-- LoadFlippedFrontSpriteByMonIndex: the draw is a negative x scale anchored +-- at 8 + pw, so the covered rect still starts at x = 8 +local page1 = uiRects(function() screen:draw() end) +eq(#page1, 1, "page 1 reports one true-color rect") +if page1[1] then + eq(("%d,%d,%d,%d"):format(page1[1].x, page1[1].y, page1[1].w, page1[1].h), + ("%d,%d,%d,%d"):format(8, py, pw, ph), + "and it is the mirrored draw's rect, not the unflipped one") + check(page1[1].colors == false, + "and it is an unshaded zone, not a palette") +end + +screen.page = 2 +local page2 = uiRects(function() screen:draw() end) +eq(#page2, 1, "page 2 (StatusScreen2 keeps the pic) reports it too") + +-- vanilla records set no flag, so the zone list stays exactly what +-- sgbPalettes returned +def.trueColor = nil +local plain = SummaryMenu.new(game, mon) +check(plain.spriteTrueColor == false, "a plain pic reports no flag") +eq(#uiRects(function() plain:draw() end), 0, + "and marks no rect, leaving the SGB pass untouched") + +def.trueColor = savedTrueColor +Sound.playCry = savedCry +PaletteFX.clearTrueColor() +S.finish() diff --git a/tools/make_yellow_manifest.py b/tools/make_yellow_manifest.py index f1527e2b..52b1b537 100755 --- a/tools/make_yellow_manifest.py +++ b/tools/make_yellow_manifest.py @@ -75,6 +75,9 @@ YELLOW_EXTRA_SYMBOLS = ( "YellowIntroCloudGFX", "PikachuCriesPointerTable", "CGBBasePalettes", + # Jessie & James share the ROCKET trainer class but battle behind their + # own pic (home/trainers2.asm IsFightingJessieJames) (#439) + "JessieJamesPic", # the five Pikachu-only emotion bubbles (emotion_bubbles.asm) "SkullEmote", "HeartEmote", diff --git a/tools/rom_manifest_yellow.json b/tools/rom_manifest_yellow.json index 7612be9a..346d2305 100644 --- a/tools/rom_manifest_yellow.json +++ b/tools/rom_manifest_yellow.json @@ -21981,6 +21981,10 @@ 9, 20457 ], + "JessieJamesPic": [ + 19, + 31873 + ], "JigglypuffPicBack": [ 11, 25410