From e2820e02c5c9b79f1f2f580371a04413bc7dd0fa Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 5 Aug 2026 16:36:37 -0400 Subject: [PATCH] CLOSES #604, CLOSES #666, CLOSES #716, CLOSES #727, CLOSES #763, CLOSES #781, CLOSES #784, CLOSES #799, CLOSES #801, CLOSES #810, CLOSES #828, CLOSES #834, CLOSES #838, CLOSES #849, CLOSES #852, CLOSES #857, CLOSES #863, CLOSES #864, CLOSES #867, CLOSES #869, CLOSES #870, CLOSES #872, CLOSES #839 --- data/scripts/story.lua | 47 +- data/scripts/story3.lua | 24 +- main.lua | 35 +- .../love/src/jni/love/src/common/android.cpp | 32 +- .../java/org/love2d/android/GameActivity.java | 56 +- src/core/SafeArea.lua | 18 + src/core/SaveData.lua | 12 + src/import/LauncherView.lua | 10 +- src/import/RomImporter.lua | 14 +- src/mods/LauncherMods.lua | 59 +- src/render/Renderer.lua | 19 +- src/save_convert/GenSave.lua | 88 ++ src/save_convert/SaveConvert.lua | 20 + src/save_convert/data/event_flags_yellow.lua | 1057 +++++++++++++++++ src/save_convert/data/toggle_objects.lua | 243 ++++ src/ui/TitleState.lua | 9 +- src/update/check_worker.lua | 13 + src/world/OverworldController.lua | 51 +- src/world/PikachuFollower.lua | 11 +- tests/drivers/bag_full_pickup_bug872_test.lua | 222 ++++ .../faithful_res_mobile_veil_bug864_test.lua | 296 +++++ .../drivers/giovanni_silph11f_bug869_test.lua | 224 ++++ .../drivers/marowak_departed_bug867_test.lua | 188 +++ .../pikachu_warp_spawn_bug863_test.lua | 164 +++ .../title_menu_palette_bug870_test.lua | 149 +++ .../launcher_one_column_reach_bug852.lua | 112 ++ .../options_backup_rollforward_bug828.lua | 109 ++ .../engine/options_write_readback_bug828.lua | 25 + tests/engine/safe_area_units_test.lua | 51 + tests/engine/save_convert_toggle_objects.lua | 187 +++ tests/launcher_mods_install_zip_test.lua | 37 + ...ncher_mods_shadow_copy_bug801_834_test.lua | 225 ++++ tests/mod_ui_tests.lua | 16 +- tests/rom_importer_cursor_bug781_test.lua | 71 ++ tests/rom_importer_cursor_test.lua | 8 + tests/save_convert_yellow_bug838_test.lua | 208 ++++ 36 files changed, 4046 insertions(+), 64 deletions(-) create mode 100644 src/save_convert/data/event_flags_yellow.lua create mode 100644 src/save_convert/data/toggle_objects.lua create mode 100644 tests/drivers/bag_full_pickup_bug872_test.lua create mode 100644 tests/drivers/faithful_res_mobile_veil_bug864_test.lua create mode 100644 tests/drivers/giovanni_silph11f_bug869_test.lua create mode 100644 tests/drivers/marowak_departed_bug867_test.lua create mode 100644 tests/drivers/pikachu_warp_spawn_bug863_test.lua create mode 100644 tests/drivers/title_menu_palette_bug870_test.lua create mode 100644 tests/engine/launcher_one_column_reach_bug852.lua create mode 100644 tests/engine/options_backup_rollforward_bug828.lua create mode 100644 tests/engine/safe_area_units_test.lua create mode 100644 tests/engine/save_convert_toggle_objects.lua create mode 100644 tests/launcher_mods_shadow_copy_bug801_834_test.lua create mode 100644 tests/rom_importer_cursor_bug781_test.lua create mode 100644 tests/save_convert_yellow_bug838_test.lua diff --git a/data/scripts/story.lua b/data/scripts/story.lua index cc540257..3b114d6b 100644 --- a/data/scripts/story.lua +++ b/data/scripts/story.lua @@ -800,9 +800,13 @@ M.SILPH_CO_11F = { -- line) would touch, and the whole Silph ending -- the flag, the Master -- Ball, the Saffron streets clearing -- silently never happened. -- - -- engageTrainer shows TEXT_SILPHCO11F_GIOVANNI as the battle text and, - -- via victories.lua OPP_GIOVANNI#2, sets the event on a win; a loss - -- sets nothing, so the trigger re-arms exactly as vanilla does. + -- SilphCo11FDefaultScript orders it DisplayTextID TEXT_SILPHCO11F_GIOVANNI + -- FIRST, then MoveSprite .GiovanniMovement: he speaks from behind the desk + -- and only then walks the three tiles down. Moving him before the box made + -- him cross the room in silence and deliver the speech point-blank (#869), + -- so the box comes first here and engageTrainer skips its own battle text. + -- victories.lua OPP_GIOVANNI#2 sets the event on a win; a loss sets + -- nothing, so the trigger re-arms exactly as vanilla does. onStep = function(game, ow, x, y) if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then return false end if not ((x == 6 and y == 13) or (x == 7 and y == 12)) then return false end @@ -811,21 +815,28 @@ M.SILPH_CO_11F = { if npc.def and npc.def.name == "SILPHCO11F_GIOVANNI" then gio = npc break end end if not gio or ow:trainerDefeated(gio) then return false end - ow:scriptMove(gio, "down", 3, function() - gio:facePlayer(ow.player) - ow:engageTrainer(gio, function() - -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" speech, - -- then SilphCo11FTeamRocketLeavesScript behind a fade so every Silph - -- rocket leaves off-screen (the street rockets are handled by - -- M.SAFFRON_CITY.onEnter in story4.lua). Queued, not run here: the - -- battle's own callbacks are still unwinding, so queueScript starts - -- it on the first idle overworld frame -- after the end-battle - -- "Arrgh!!" box victories.lua OPP_GIOVANNI#2 pushes (#722). - if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then - ow:queueScript(silphAftermathRows()) - end - end) - end) + local TextBox = require("src.render.TextBox") + game.stack:push(TextBox.new(game, + game.data.text._SilphCo11FGiovanniText + or "Ah {PLAYER}!\nSo we meet again!", + function() + ow:scriptMove(gio, "down", 3, function() + gio:facePlayer(ow.player) + ow:engageTrainer(gio, function() + -- SilphCo11FGiovanniAfterBattleScript: the "Blast it all!" + -- speech, then SilphCo11FTeamRocketLeavesScript behind a fade so + -- every Silph rocket leaves off-screen (the street rockets are + -- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not + -- run here: the battle's own callbacks are still unwinding, so + -- queueScript starts it on the first idle overworld frame -- + -- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2 + -- pushes (#722). + if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then + ow:queueScript(silphAftermathRows()) + end + end, nil, true) + end) + end)) return true end, onEnter = function(game, ow) diff --git a/data/scripts/story3.lua b/data/scripts/story3.lua index 8cca471b..4f0c507e 100644 --- a/data/scripts/story3.lua +++ b/data/scripts/story3.lua @@ -151,9 +151,27 @@ M.POKEMON_TOWER_6F = { -- trick, and the speedrun route this bot follows depends on it. if result == "win" or battle.pokeDollEscape then game.save.flags.EVENT_BEAT_GHOST_MAROWAK = true - game.stack:push(TextBox.new(game, - t._PokemonTower6FSoulWasCalmedText - or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!")) + -- PokemonTower6FMarowakDepartedText (scripts/PokemonTower6F.asm) + -- is two texts, not one: the CUBONE's-mother line first, then + -- PlayCry RESTLESS_SOUL (EQU MAROWAK, constants/pokemon_constants + -- .asm:209) + WaitForSoundToFinish + DelayFrames 30 before the + -- calmed line; the port dropped the first text and the cry + -- (#867). play_cry arms the next show_text, so the cry rides + -- the calmed box's open with the button prompt kept, and the + -- wait row stands in for the asm's 30-frame gap. + local rows = { + { "show_text", t._PokemonTower6FGhostWasCubonesMotherText + or "The GHOST was the\nrestless soul of\vCUBONE's mother!" }, + { "play_cry", "MAROWAK", true }, + { "wait", 30 }, + { "show_text", t._PokemonTower6FSoulWasCalmedText + or "The mother's soul\nwas calmed.\012It departed to\nthe afterlife!" }, + } + if ow.runner then + ow.runner:run(rows) + elseif ow.queueScript then + ow:queueScript(rows) + end elseif result ~= "lose" then -- .did_not_defeat: one simulated step right, off the trigger, -- so fleeing does not leave you standing on a cell that diff --git a/main.lua b/main.lua index f5dbc4d7..ab3c8623 100644 --- a/main.lua +++ b/main.lua @@ -667,7 +667,33 @@ function love.wheelmoved(x, y) Game:wheelmoved(x, y) end +-- #781: Linux X11 multi-monitor with the primary display away from desktop +-- (0,0): SDL's polled mouse state can come back in desktop-virtual +-- coordinates while the event stream stays window-relative, which strands +-- every polled consumer (launcher Kit rising-edge clicks, the pad-cursor +-- motion yield, PadCursor) on coordinates no hit test can match. Sanitize +-- the poll once here: remember the last window-relative event coordinates +-- and substitute them whenever the polled value falls outside the window. +-- Linux only -- macOS / Windows / mobile keep the stock function, and the +-- NX launcher shim still composes because it captures whatever +-- love.mouse.getPosition is at bridge time (_ensureNxPointerBridge). +local eventMouseX, eventMouseY +if love.system and love.system.getOS() == "Linux" + and love.mouse and love.mouse.getPosition then + local polledGetPosition = love.mouse.getPosition + love.mouse.getPosition = function() + local x, y = polledGetPosition() + local w, h = love.graphics.getDimensions() + if x < 0 or y < 0 or x > w or y > h then + if eventMouseX then return eventMouseX, eventMouseY end + return math.max(0, math.min(x, w)), math.max(0, math.min(y, h)) + end + return x, y + end +end + function love.mousepressed(x, y, button, istouch) + if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then -- Android primary touch already arrived via love.touchpressed; a second -- mouse path would double-fire Done / begin a second drag. @@ -720,6 +746,7 @@ function love.mousereleased(x, y, button, istouch) end function love.mousemoved(x, y, dx, dy, istouch) + if not istouch then eventMouseX, eventMouseY = x, y end if TouchEditor then if love.system.getOS() == "Android" then return end return TouchEditor.mousemoved(x, y) @@ -748,7 +775,13 @@ local quitToLauncher = false function love.quit() if editorMode and EditorApp.quit then - return EditorApp.quit() -- return true to abort quit + -- true blocks the quit (unsaved-changes prompt). A quit that proceeds + -- must fall through to the worker shutdowns below instead of returning: + -- the bundled editor opens from a live launcher whose update-check and + -- fetch-pool workers are still parked in Channel:demand(), and returning + -- here skipped their "quit" push, so the process outlived the closed + -- window and kept the install folder locked on Windows (#727). + if EditorApp.quit() then return true end end -- Closing the window of a running game returns to the launcher instead of -- exiting the app, so testing a mod does not need a relaunch every time diff --git a/mobile/android/love/src/jni/love/src/common/android.cpp b/mobile/android/love/src/jni/love/src/common/android.cpp index 3c2153fc..6cba2fb5 100644 --- a/mobile/android/love/src/jni/love/src/common/android.cpp +++ b/mobile/android/love/src/jni/love/src/common/android.cpp @@ -37,6 +37,13 @@ #include "filesystem/physfs/PhysfsIo.h" +// #604 / #839: the SAF bridges below must hand GameActivity the exact +// directory physfs mounted as the save dir -- the same contract the iOS +// GRPickerBridge already gets (mobile/ios/patch_love_src.py, +// gr_saveDirectory) -- instead of letting Java recompute the root on its +// own, which can name a different volume on merged / adopted-SD storage. +#include "filesystem/Filesystem.h" + namespace love { namespace android @@ -183,6 +190,19 @@ void vibrate(double seconds) env->DeleteLocalRef(activity); } +// The directory physfs actually mounted as the save dir, or "" before the +// filesystem module is up. GameActivity must copy SAF picks HERE: its own +// getExternalFilesDir(null) recomputation can disagree with the mounted +// root on merged / adopted-SD storage (#604, #839). +static const char *bridgeSaveDirectory() +{ + auto fs = Module::getInstance(Module::M_FILESYSTEM); + if (fs == nullptr) + return ""; + const char *dir = fs->getSaveDirectory(); + return dir != nullptr ? dir : ""; +} + bool showFilePicker(const char *destFilename) { if (destFilename == nullptr || destFilename[0] == '\0') @@ -192,9 +212,11 @@ bool showFilePicker(const char *destFilename) jclass activity = env->FindClass("org/love2d/android/GameActivity"); jmethodID method = env->GetStaticMethodID(activity, "showFilePicker", - "(Ljava/lang/String;)Z"); + "(Ljava/lang/String;Ljava/lang/String;)Z"); jstring jname = env->NewStringUTF(destFilename); - jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir); + env->DeleteLocalRef(jsavedir); env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); @@ -210,9 +232,11 @@ bool showCreateDocument(const char *suggestedName) jclass activity = env->FindClass("org/love2d/android/GameActivity"); jmethodID method = env->GetStaticMethodID(activity, "showCreateDocument", - "(Ljava/lang/String;)Z"); + "(Ljava/lang/String;Ljava/lang/String;)Z"); jstring jname = env->NewStringUTF(suggestedName); - jboolean result = env->CallStaticBooleanMethod(activity, method, jname); + jstring jsavedir = env->NewStringUTF(bridgeSaveDirectory()); + jboolean result = env->CallStaticBooleanMethod(activity, method, jname, jsavedir); + env->DeleteLocalRef(jsavedir); env->DeleteLocalRef(jname); env->DeleteLocalRef(activity); diff --git a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java index 04c8a827..1d70c7fa 100644 --- a/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java +++ b/mobile/android/love/src/main/java/org/love2d/android/GameActivity.java @@ -116,6 +116,18 @@ public class GameActivity extends SDLActivity { // bad ROM instead of installing it (#553). private String pendingPickFilename = PICKED_ROM_FILENAME; private static final String STATE_PENDING_PICK = "pendingPickFilename"; + // Absolute save directory physfs actually mounted, as reported by the + // native bridge call that opened the picker (love/src/common/android.cpp, + // bridgeSaveDirectory). This activity used to recompute + // getExternalFilesDir(null)/save/ on its own at result time; on + // merged / adopted-SD storage that can name a different volume than the + // one LOVE mounted, so the copied pick (and pick_error.flag) landed where + // Lua never scans -- the launcher then "did nothing" after a pick (#604) + // and the folders a file manager can browse stayed empty while the game + // saved fine elsewhere (#839). Empty string means "not told yet": fall + // back to the historical computation. + private String pendingPickSaveDir = ""; + private static final String STATE_PENDING_PICK_DIR = "pendingPickSaveDir"; private static final String STATE_PENDING_CREATE = "pendingCreateSuggestedName"; // Suggested download name for the in-flight SAF create (set by showCreateDocument). private String pendingCreateSuggestedName = "export.sav"; @@ -186,6 +198,8 @@ public class GameActivity extends SDLActivity { // a recreated activity still lands under the basename it asked for. String pick = savedInstanceState.getString(STATE_PENDING_PICK); if (pick != null) pendingPickFilename = pick; + String pickDir = savedInstanceState.getString(STATE_PENDING_PICK_DIR); + if (pickDir != null) pendingPickSaveDir = pickDir; String create = savedInstanceState.getString(STATE_PENDING_CREATE); if (create != null) pendingCreateSuggestedName = create; } @@ -467,13 +481,23 @@ public class GameActivity extends SDLActivity { * @param destFilename basename under the app save identity (e.g. * picked_rom.gb, picked_mod.zip, picked_save.sav) */ + /** Legacy single-argument entry; resolves the save dir itself. */ @Keep public static boolean showFilePicker(String destFilename) { + return showFilePicker(destFilename, null); + } + + @Keep + public static boolean showFilePicker(String destFilename, String saveDir) { GameActivity self = (GameActivity) mSingleton; if (self == null) return false; if (destFilename == null || destFilename.length() == 0) { destFilename = PICKED_ROM_FILENAME; } + // Remember where LOVE's filesystem is really mounted so + // onActivityResult copies the pick there, not into a recomputed + // (possibly different-volume) root (#604, #839). + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; // Reject path separators so a hostile JNI caller cannot escape the // save identity directory. if (destFilename.indexOf('/') >= 0 || destFilename.indexOf('\\') >= 0) { @@ -644,8 +668,14 @@ public class GameActivity extends SDLActivity { * return degrades on the Lua side (RomImporter export) to "Exported * inside the app folder", which is the correct pre-KitKat behavior. */ + /** Legacy single-argument entry; resolves the save dir itself. */ @Keep public static boolean showCreateDocument(String suggestedName) { + return showCreateDocument(suggestedName, null); + } + + @Keep + public static boolean showCreateDocument(String suggestedName, String saveDir) { if (android.os.Build.VERSION.SDK_INT < 19) return false; // (see showFilePicker for why the import side got a pre-19 path) GameActivity self = (GameActivity) mSingleton; @@ -657,9 +687,12 @@ public class GameActivity extends SDLActivity { Log.d("GameActivity", "refusing unsafe create name: " + suggestedName); return false; } - File source = new File( - new File(self.getExternalFilesDir(null), "save"), - ROM_SAVE_IDENTITY + "/" + PENDING_EXPORT_FILENAME); + // Route through the mounted save dir (#604, #839): Lua staged + // pending_export.sav where physfs writes, which is not necessarily + // where a fresh getExternalFilesDir(null) points on merged / + // adopted-SD storage. + self.pendingPickSaveDir = (saveDir != null) ? saveDir : ""; + File source = new File(self.saveIdentityDir(), PENDING_EXPORT_FILENAME); if (!source.isFile()) { Log.d("GameActivity", "no pending export at " + source); return false; @@ -680,7 +713,21 @@ public class GameActivity extends SDLActivity { } private File saveIdentityDir() { - return new File(new File(getExternalFilesDir(null), "save"), ROM_SAVE_IDENTITY); + // Prefer the mounted save dir the last bridge call reported: the + // recomputation below can name a different volume than the one LOVE + // mounted on merged / adopted-SD storage (#604, #839). + if (pendingPickSaveDir != null && pendingPickSaveDir.length() > 0) { + return new File(pendingPickSaveDir); + } + File ext = getExternalFilesDir(null); + if (ext == null) { + // Shared storage unavailable (ejected / mid-adoption): without + // this guard File(null, "save") silently built the RELATIVE + // path save/, mkdirs() failed against "/", and the + // pick was dropped with no message at all (#604). + ext = getFilesDir(); + } + return new File(new File(ext, "save"), ROM_SAVE_IDENTITY); } /** Drops a small flag file in the save identity for Lua to consume on focus. */ @@ -875,6 +922,7 @@ public class GameActivity extends SDLActivity { protected void onSaveInstanceState(Bundle outState) { super.onSaveInstanceState(outState); outState.putString(STATE_PENDING_PICK, pendingPickFilename); + outState.putString(STATE_PENDING_PICK_DIR, pendingPickSaveDir); outState.putString(STATE_PENDING_CREATE, pendingCreateSuggestedName); } diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 86c404fb..37d600a5 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -28,6 +28,24 @@ function SafeArea.rect() return 0, 0, ww, wh end + -- A safe rect that cannot fit the window's unit space is a backend + -- reporting framebuffer PIXELS -- the iOS build (LOVE 12 + SDL3) did this + -- in portrait on iOS 16, and clamping it as-is kept a DPI-inflated top + -- inset that pushed the whole launcher a band down the screen (#810). + -- Convert back to units with per-axis ratios; the axes can disagree on + -- forced-rotation devices (see displayMetrics in src/render/Renderer.lua, + -- #208). + if (w > ww + 0.5 or h > wh + 0.5) + and love.graphics.getPixelDimensions then + local pw, ph = love.graphics.getPixelDimensions() + local dx = (pw and pw > 0) and (pw / ww) or 1 + local dy = (ph and ph > 0) and (ph / wh) or 1 + if dx > 1.01 or dy > 1.01 then + x, w = x / dx, w / dx + y, h = y / dy, h / dy + end + end + -- Clamp to the drawable window so a bad / mid-rotation backend cannot -- push layout outside the surface. x = math.max(0, math.min(x, ww)) diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index d9c0569d..882897bb 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -417,6 +417,18 @@ function SaveData.saveOptions(opts, fs) #encoded, type(wrote) == "string" and tostring(#wrote) or "nothing") return nil end + -- #828: roll the backup FORWARD to the bytes just verified. The + -- pre-write roll above only preserves the previous file for a death + -- during this rewrite; at rest the backup must hold the newest verified + -- state, because the hard teardown out of a game session (HostShell's + -- restartApp kill on Android, execv on a SteamOS AppImage) can eat the + -- main file outright and loadOptions then promotes this copy. The + -- encoder is key-sorted, so the follow-up rewrites a play session makes + -- (play()'s lastVersion stamp, the in-game save flush) are byte-identical + -- and skip the conditional roll -- without this line the backup still + -- held the file from BEFORE the launcher's change, and recovery reverted + -- the just-changed setting (BATTLE LAYOUT back to OG). + fs.write(OPTIONS_BACKUP_FILENAME, encoded) -- the staged witness has served its purpose; the main file is verified remove(fs, OPTIONS_TMP_FILENAME) return opts diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index 0ae0d5b0..6b9e5c9b 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -2200,7 +2200,15 @@ end -- pinned Play block used to walk up over the cards on a short window, which -- is unusable, and the footer simply lives below the fold until scrolled to. local function minPanelHeight(m) - return math.floor(460 * m.s) + -- One column stacks the actions card, the slot card and the pinned Play + -- block in a single pile, so it needs more room than the side-by-side + -- layout: 460 was tuned for two columns, and on a squat one-column window + -- (a 4:3 device, a phone held upright) it left the slot card clipped + -- inert against the pinned buttons -- Kit's clip bounds hit-testing, so + -- no slot could be picked at all (#852). 660 fits the actions card, one + -- slot row with its pager and New button, and the pinned block; whatever + -- the window cannot show, the page scroll above reaches. + return math.floor((m.twoCol and 460 or 660) * m.s) end function LauncherView.draw(imp) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index eb46ad29..50cae927 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -2409,10 +2409,18 @@ function RomImporter:runActions(queue) end -- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded --- mousepressed stays inert so Android's synthesized mouse path cannot --- double-fire a tap (#553). Touch move/press/release must still reach +-- mousepressed mints no click, so Android's synthesized mouse path cannot +-- double-fire a tap (#553). It DOES hand the pointer back from the pad +-- cursor (#781): a Linux boot with a joystick present arms it (see the +-- getJoystickCount block in new()), and while it is active +-- LauncherView.update refuses to mint mouse clicks, so a real press must +-- win the pointer back even when the polled motion yield misses (X11 +-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for +-- the overlay hosts. Touch move/press/release must still reach -- FlexLove.touch* or scroll containers never drag on phones. -function RomImporter:mousepressed() end +function RomImporter:mousepressed() + self._padCursorActive = false +end function RomImporter:touchpressed(id, x, y, dx, dy, pressure) if not self._flex then return end diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index b2834028..e078b232 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -474,6 +474,33 @@ local function removeTree(path) fs.remove(path) end +-- Every mods/ folder currently holding this id, plus the bare mods/ tree +-- even when its manifest is missing or unreadable. Second return: whether any +-- of them carries a manifest the panel can actually list. An install names +-- its dest after the manifest id, but a hand-unzipped copy keeps whatever +-- folder name the archive carried, and discover()'s first-id-wins rule means +-- whichever folder physfs happens to enumerate first is the one the panel and +-- the loader really use. Replacing only mods/ let an update report +-- success while the old copy kept winning that race (#801); and a +-- manifest-less mods/ left by an interrupted copy blocked every re-import +-- as "already installed" while showing nowhere the player could see (#834). +local function sameIdTrees(fs, id) + local out, installed = {}, false + if not fs.getInfo("mods") then return out, installed end + for _, name in ipairs(fs.getDirectoryItems("mods")) do + local path = "mods/" .. name + local raw = fs.read(path .. "/manifest.json") + local manifest = raw and decodeManifest(raw, path) + if manifest and manifest.id == id then + out[#out + 1] = path + installed = true + elseif name == id and fs.getInfo(path) then + out[#out + 1] = path + end + end + return out, installed +end + -- ------- strays: mods dropped beside the game that it cannot see -- love.filesystem looks in two places for "mods/": the save directory, and -- @@ -666,16 +693,21 @@ function LauncherMods._installZipInner(source, opts) end local dest = "mods/" .. manifest.id - if fs.getInfo(dest) then - if not opts.replace then - cleanup() - return nil, "a mod named '" .. manifest.id .. "' is already installed" - end - -- drop the old tree before copy; enable-flag is preserved (uninstall - -- would clear it, which would surprise an update) + local existing, installedSomewhere = sameIdTrees(fs, manifest.id) + if installedSomewhere and not opts.replace then + cleanup() + return nil, "a mod named '" .. manifest.id .. "' is already installed" + end + if #existing > 0 then + -- drop every old tree before copy -- mods/ and any same-id folder + -- under another name, or the survivor keeps winning discover()'s + -- first-id-wins race after the "successful" update (#801). A tree with + -- no readable manifest is debris from an interrupted copy: it never + -- refuses the install, it only gets cleared (#834). Enable-flag is + -- preserved (uninstall would clear it, which would surprise an update). local savedPrefix = CacheFs.prefix CacheFs.prefix = "" - removeTree(dest) + for _, path in ipairs(existing) do removeTree(path) end CacheFs.prefix = savedPrefix end @@ -789,14 +821,17 @@ function LauncherMods.uninstall(id) return nil, "mod uninstall needs LOVE" end local fs = love.filesystem - local dest = "mods/" .. id - if not fs.getInfo(dest) then + local trees = sameIdTrees(fs, id) + if #trees == 0 then return nil, "mod '" .. id .. "' is not installed" end - -- same root pin as installZip: the mods tree is not version-prefixed (#330) + -- same root pin as installZip: the mods tree is not version-prefixed (#330). + -- Every same-id tree goes, folder name notwithstanding, so Delete works on a + -- hand-unzipped copy too and cannot leave a shadow copy for discover()'s + -- first-id-wins rule to resurrect on the next boot (#801) local savedPrefix = CacheFs.prefix CacheFs.prefix = "" - removeTree(dest) + for _, path in ipairs(trees) do removeTree(path) end CacheFs.prefix = savedPrefix -- Drop the enable flag so a reinstall of the same id starts from the -- loader's default (enabled) rather than a stale false. diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 69f2bd3f..3b69e2d0 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -820,8 +820,13 @@ function Renderer:endFrame(zones, worldZones) -- non-battle state that opts in) uses the paper shade. "world" never -- reaches here -- it makes the battle non-opaque, so the world pass is -- active and this whole branch is skipped. + -- FAITHFUL RATIO's mobile lock promises the display outside the GB + -- screen stays black (src/core/FaithfulRes.lua); the paper surround + -- painted the whole phone white on New Game and in battle (#864), so + -- the lock keeps the default black bars. if state and state.letterboxWhite - and not (state.bgMode and state:bgMode() == "black") then + and not (state.bgMode and state:bgMode() == "black") + and not FaithfulRes.scaleCap() then clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data) end end @@ -1029,7 +1034,17 @@ function Renderer:endFrame(zones, worldZones) local veil = self.screenVeil if veil and veil[2] > 0 then love.graphics.setColor(veil[1], veil[1], veil[1], veil[2]) - love.graphics.rectangle("fill", 0, 0, ww, wh) + -- FAITHFUL RATIO's mobile lock: the surface the player sees is the + -- locked viewport and the bars around it are dead display, not screen + -- (src/core/FaithfulRes.lua). A whole-window veil lit the entire phone + -- for the battle flash and the post-battle fade (#864), so under the + -- lock the veil stops at the letterbox. The desktop lock is unaffected: + -- there the window IS the viewport. + if FaithfulRes.scaleCap() then + love.graphics.rectangle("fill", ox, oy, vpw, vph) + else + love.graphics.rectangle("fill", 0, 0, ww, wh) + end love.graphics.setColor(1, 1, 1, 1) end diff --git a/src/save_convert/GenSave.lua b/src/save_convert/GenSave.lua index 9f1c3274..6a465acf 100644 --- a/src/save_convert/GenSave.lua +++ b/src/save_convert/GenSave.lua @@ -93,6 +93,12 @@ O.statusFlags1 = O.townVisited + 29 -- 1B O.statusFlags4 = O.townVisited + 35 -- 1B O.elite4Flags = O.townVisited + 41 -- 1B O.tradeFlags = O.townVisited + 44 -- 2B (flag_array NUM_NPC_TRADES) +-- wToggleableObjectFlags (ram/wram.asm, flag_array $100): the ShowObject/ +-- HideObject persistence, one bit per data/maps/toggleable_objects.asm entry, +-- set = hidden (engine/overworld/toggleable_objects.asm IsObjectHidden). +-- Sits 2 bytes (wPlayerCoins) past O.coins per the walk above; absolute +-- 0x2852 (#763, #857). +O.toggleObjectFlags = O.coins + 2 -- 32B -- Play time (wPlayTimeHours/Maxed/Minutes/Seconds/Frames) lives INSIDE the -- sMainData window (wMainDataStart..wMainDataEnd is copied verbatim into -- SRAM), 1866 bytes past wMainDataStart -- reached from the checksum-verified @@ -107,6 +113,15 @@ O.playTimeMaxed = O.mainData + 1867 -- 1B (set once past 2 O.playTimeMinutes = O.mainData + 1868 -- 1B (0-59) O.playTimeSeconds = O.mainData + 1869 -- 1B (0-59) O.playTimeFrames = O.mainData + 1870 -- 1B (0-59, 1/60s ticks) +-- wPikachuHappiness, Yellow only (pret/pokeyellow ram/wram.asm; no local +-- pokeyellow checkout, so verified against the pokeyellow symbol file +-- instead: d46f - wMainDataStart d2f6 = 377, the well-known absolute +-- 0x271C). In Red/Blue this byte is current-map scratch the game +-- regenerates on load, so the codec touches it only when the crosswalk +-- data set names the game "yellow" (#763, #838). Every other modeled +-- offset is identical between pokered and pokeyellow (same sram.asm, same +-- wMainData field spacing per both symbol files). +O.pikachuHappiness = O.mainData + 377 O.mainDataSize = 1929 -- wMainDataEnd - wMainDataStart O.spriteData = O.mainData + O.mainDataSize @@ -729,6 +744,25 @@ function GenSave.decode(bytes, data, opts) if save.flags[vanillaName] then save.flags[portName] = true end end + -- wToggleableObjectFlags -> save.objectToggles (bit set = hidden). A few + -- of these are re-derived from flags on map entry (#106/#234 onEnter + -- re-applies), but most ShowObject/HideObject state -- the Mt Moon + -- fossils, the Cerulean guard swap -- has no flag to re-derive from, so + -- an import that drops the array resurrects taken fossils and blocking + -- guards (#763, #857). + local toggles = data.toggleObjects + if toggles then + save.objectToggles = {} + for bitIdx, e in pairs(toggles.byBit) do + local mapToggles = save.objectToggles[e[1]] + if not mapToggles then + mapToggles = {} + save.objectToggles[e[1]] = mapToggles + end + mapToggles[e[2]] = not bitGet(bytes, O.toggleObjectFlags, bitIdx) + end + end + -- FLY destinations. wTownVisitedFlag's bit index IS the town's map index: -- engine/items/town_map.asm BuildFlyLocationsList loads the 16-bit value -- into de and rotates it right one bit per iteration with b counting up @@ -774,6 +808,15 @@ function GenSave.decode(bytes, data, opts) + u8(bytes, O.playTimeSeconds) + u8(bytes, O.playTimeFrames) / 60 + -- Yellow starter friendship (save.pikachuHappiness, + -- src/world/PikachuFollower.lua reads it; pokeyellow's + -- init_player_data.asm seeds 90 on a new game), gated on the data set's + -- game because the byte is map scratch in Red/Blue (see + -- O.pikachuHappiness) (#763, #838). + if data.gameVersion == "yellow" then + save.pikachuHappiness = u8(bytes, O.pikachuHappiness) + end + save.warnings = warnings save.rawImport = bytes -- template for a later encode(); see file header return save @@ -868,6 +911,41 @@ function GenSave.encode(save, data, template) end end + -- wToggleableObjectFlags, written both ways like the #396 extras: this + -- port's save is the authority, and vanilla folds three stores this port + -- keeps separate into these same bits -- script ShowObject/HideObject + -- (save.objectToggles), taken overworld items (engine/events/ + -- pick_up_item.asm -> save.itemsTaken) and beaten static encounters + -- (home/trainers.asm HideObject after battle -> save.defeatedTrainers) -- + -- so all three fold back in here or an exported save resurrects them + -- (#763, #857). + local toggleData = data.toggleObjects + if toggleData then + local objectToggles = save.objectToggles or {} + local itemsTaken = save.itemsTaken or {} + local beaten = save.defeatedTrainers or {} + for bitIdx, e in pairs(toggleData.byBit) do + local mapId, objName, visible = e[1], e[2], e[3] + local mapToggles = objectToggles[mapId] + if mapToggles and mapToggles[objName] ~= nil then + visible = mapToggles[objName] + end + if visible and data.maps and data.maps[mapId] then + for _, obj in ipairs(data.maps[mapId].objects or {}) do + if obj.name == objName then + local key = mapId .. "_obj_" .. obj.index + if (obj.item and itemsTaken[key]) + or (obj.pokemon and beaten[key]) then + visible = false + end + break + end + end + end + bitSet(buf, O.toggleObjectFlags, bitIdx, not visible) + end + end + -- FLY destinations back into wTownVisitedFlag (see the decode note), so a -- save exported from this port is flyable on hardware (#263). A save -- table with no `visited` key at all says nothing about the set, so leave @@ -967,6 +1045,16 @@ function GenSave.encode(save, data, template) setByte(buf, O.playTimeFrames, rem - secs * 60) end + -- Yellow starter friendship back out (see O.pikachuHappiness); Red/Blue + -- data sets never reach this write. 90 is the fresh-game seed the + -- follower system itself uses when the save has never tracked it. + -- Placed before the checksum pass so the byte is covered by the + -- main-data checksum automatically (#763, #838). + if data.gameVersion == "yellow" then + local h = tonumber(save.pikachuHappiness) or 90 + setByte(buf, O.pikachuHappiness, math.max(0, math.min(255, math.floor(h)))) + end + local out = table.concat(buf) -- checksums, computed last over the now-final bytes local outBuf = {} diff --git a/src/save_convert/SaveConvert.lua b/src/save_convert/SaveConvert.lua index f6ca12d8..b4dc9d34 100644 --- a/src/save_convert/SaveConvert.lua +++ b/src/save_convert/SaveConvert.lua @@ -44,6 +44,19 @@ local DATA_MODULES = { maps = { "data.generated.maps", "data/generated/maps.lua" }, charmap = { "src.save_convert.data.charmap", "src/save_convert/data/charmap.lua" }, eventFlags = { "src.save_convert.data.event_flags", "src/save_convert/data/event_flags.lua" }, + toggleObjects = { "src.save_convert.data.toggle_objects", "src/save_convert/data/toggle_objects.lua" }, +} + +-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm +-- inserts events pokered does not have (the Jessie & James fights, catch +-- training, the Officer Jenny Squirtle) and shifts the Mt Moon 3 / Silph Co +-- 11F block, so writing a Yellow save through the Red table lands bits on the +-- wrong events and drops every Yellow-only flag. Kept outside DATA_MODULES so +-- the ensureData loop never loads it as a crosswalk of its own -- it +-- substitutes for `eventFlags` when the caller names Yellow (#838). +local YELLOW_EVENT_FLAGS = { + "src.save_convert.data.event_flags_yellow", + "src/save_convert/data/event_flags_yellow.lua", } local function loadTable(requirePath, filePath) @@ -112,6 +125,9 @@ local function ensureData(gameVersion) local data = {} for name, spec in pairs(DATA_MODULES) do if name ~= "charmap" then + if name == "eventFlags" and gameVersion == "yellow" then + spec = YELLOW_EVENT_FLAGS -- Yellow's bit numbering differs (#838) + end local mod = loadCacheTable(gameVersion, spec[2]) if not mod then local e @@ -121,6 +137,10 @@ local function ensureData(gameVersion) data[name] = mod end end + -- record which game's tables these are: the codec gates Yellow-only + -- bytes (wPikachuFriendship) on it, since those offsets are map + -- scratch in Red/Blue (#763, #838) + data.gameVersion = gameVersion crosswalks[key] = data end if not charmapReady then diff --git a/src/save_convert/data/event_flags_yellow.lua b/src/save_convert/data/event_flags_yellow.lua new file mode 100644 index 00000000..5b65b445 --- /dev/null +++ b/src/save_convert/data/event_flags_yellow.lua @@ -0,0 +1,1057 @@ +-- Generated by tools/build_data.py. DO NOT EDIT. +-- wEventFlags bit index <-> EVENT_* name (pokeyellow numbering; see +-- pokeyellow ram/wram.asm wEventFlags, a flat NUM_EVENTS-bit +-- array). byBit only has entries for bits with a name -- +-- reserved/padding bits are intentionally absent. +return { + byBit = { + [0] = "EVENT_FOLLOWED_OAK_INTO_LAB", + [3] = "EVENT_HALL_OF_FAME_DEX_RATING", + [5] = "EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN", + [6] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS", + [24] = "EVENT_GOT_TOWN_MAP", + [25] = "EVENT_ENTERED_BLUES_HOUSE", + [26] = "EVENT_DAISY_WALKING", + [32] = "EVENT_FOLLOWED_OAK_INTO_LAB_2", + [33] = "EVENT_OAK_ASKED_TO_CHOOSE_MON", + [34] = "EVENT_GOT_STARTER", + [35] = "EVENT_BATTLED_RIVAL_IN_OAKS_LAB", + [36] = "EVENT_GOT_POKEBALLS_FROM_OAK", + [37] = "EVENT_GOT_POKEDEX", + [38] = "EVENT_PALLET_AFTER_GETTING_POKEBALLS_2", + [39] = "EVENT_OAK_APPEARED_IN_PALLET", + [40] = "EVENT_VIRIDIAN_GYM_OPEN", + [41] = "EVENT_GOT_TM42", + [44] = "EVENT_SPAWNED_OLD_MAN_1", + [45] = "EVENT_COMPLETED_CATCH_TRAINING", + [46] = "EVENT_COMPLETED_CATCH_TRAINING_AGAIN", + [47] = "EVENT_INITIAL_CATCH_TRAINING", + [56] = "EVENT_OAK_GOT_PARCEL", + [57] = "EVENT_GOT_OAKS_PARCEL", + [80] = "EVENT_GOT_TM27", + [81] = "EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI", + [82] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0", + [83] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1", + [84] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2", + [85] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3", + [86] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4", + [87] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5", + [88] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6", + [89] = "EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7", + [104] = "EVENT_BOUGHT_MUSEUM_TICKET", + [105] = "EVENT_GOT_OLD_AMBER", + [114] = "EVENT_BEAT_PEWTER_GYM_TRAINER_0", + [118] = "EVENT_GOT_TM34", + [119] = "EVENT_BEAT_BROCK", + [152] = "EVENT_BEAT_CERULEAN_RIVAL", + [167] = "EVENT_BEAT_CERULEAN_ROCKET_THIEF", + [168] = "EVENT_GOT_BULBASAUR_IN_CERULEAN", + [186] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_0", + [187] = "EVENT_BEAT_CERULEAN_GYM_TRAINER_1", + [190] = "EVENT_GOT_TM11", + [191] = "EVENT_BEAT_MISTY", + [192] = "EVENT_GOT_BICYCLE", + [238] = "EVENT_POKEMON_TOWER_RIVAL_ON_LEFT", + [239] = "EVENT_BEAT_POKEMON_TOWER_RIVAL", + [241] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_0", + [242] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_1", + [243] = "EVENT_BEAT_POKEMONTOWER_3_TRAINER_2", + [249] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_0", + [250] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_1", + [251] = "EVENT_BEAT_POKEMONTOWER_4_TRAINER_2", + [258] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_0", + [259] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_1", + [260] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_2", + [261] = "EVENT_BEAT_POKEMONTOWER_5_TRAINER_3", + [263] = "EVENT_IN_PURIFIED_ZONE", + [265] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_0", + [266] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_1", + [267] = "EVENT_BEAT_POKEMONTOWER_6_TRAINER_2", + [271] = "EVENT_BEAT_GHOST_MAROWAK", + [273] = "EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES", + [274] = "EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT", + [279] = "EVENT_RESCUED_MR_FUJI_2", + [296] = "EVENT_GOT_POKE_FLUTE", + [327] = "EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY", + [337] = "EVENT_GOT_BIKE_VOUCHER", + [338] = "EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER", + [342] = "EVENT_SEEL_FAN_BOAST", + [343] = "EVENT_PIKACHU_FAN_BOAST", + [352] = "EVENT_2ND_LOCK_OPENED", + [353] = "EVENT_1ST_LOCK_OPENED", + [354] = "EVENT_BEAT_VERMILION_GYM_TRAINER_0", + [355] = "EVENT_BEAT_VERMILION_GYM_TRAINER_1", + [356] = "EVENT_BEAT_VERMILION_GYM_TRAINER_2", + [358] = "EVENT_GOT_TM24", + [359] = "EVENT_BEAT_LT_SURGE", + [384] = "EVENT_GOT_TM41", + [396] = "EVENT_GOT_TM13", + [397] = "EVENT_GOT_TM48", + [398] = "EVENT_GOT_TM49", + [399] = "EVENT_GOT_TM18", + [424] = "EVENT_GOT_TM21", + [425] = "EVENT_BEAT_ERIKA", + [426] = "EVENT_BEAT_CELADON_GYM_TRAINER_0", + [427] = "EVENT_BEAT_CELADON_GYM_TRAINER_1", + [428] = "EVENT_BEAT_CELADON_GYM_TRAINER_2", + [429] = "EVENT_BEAT_CELADON_GYM_TRAINER_3", + [430] = "EVENT_BEAT_CELADON_GYM_TRAINER_4", + [431] = "EVENT_BEAT_CELADON_GYM_TRAINER_5", + [432] = "EVENT_BEAT_CELADON_GYM_TRAINER_6", + [440] = "EVENT_1B8", + [441] = "EVENT_FOUND_ROCKET_HIDEOUT", + [442] = "EVENT_GOT_10_COINS", + [443] = "EVENT_GOT_20_COINS", + [444] = "EVENT_GOT_20_COINS_2", + [447] = "EVENT_1BF", + [480] = "EVENT_GOT_COIN_CASE", + [568] = "EVENT_GOT_HM04", + [569] = "EVENT_GAVE_GOLD_TEETH", + [590] = "EVENT_SAFARI_GAME_OVER", + [591] = "EVENT_IN_SAFARI_ZONE", + [600] = "EVENT_GOT_TM06", + [601] = "EVENT_BEAT_KOGA", + [602] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_0", + [603] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_1", + [604] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_2", + [605] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_3", + [606] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_4", + [607] = "EVENT_BEAT_FUCHSIA_GYM_TRAINER_5", + [632] = "EVENT_MANSION_SWITCH_ON", + [649] = "EVENT_BEAT_MANSION_1_TRAINER_0", + [664] = "EVENT_GOT_TM38", + [665] = "EVENT_BEAT_BLAINE", + [666] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_0", + [667] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_1", + [668] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_2", + [669] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_3", + [670] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_4", + [671] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_5", + [672] = "EVENT_BEAT_CINNABAR_GYM_TRAINER_6", + [679] = "EVENT_2A7", + [680] = "EVENT_CINNABAR_GYM_GATE0_UNLOCKED", + [681] = "EVENT_CINNABAR_GYM_GATE1_UNLOCKED", + [682] = "EVENT_CINNABAR_GYM_GATE2_UNLOCKED", + [683] = "EVENT_CINNABAR_GYM_GATE3_UNLOCKED", + [684] = "EVENT_CINNABAR_GYM_GATE4_UNLOCKED", + [685] = "EVENT_CINNABAR_GYM_GATE5_UNLOCKED", + [686] = "EVENT_CINNABAR_GYM_GATE6_UNLOCKED", + [727] = "EVENT_GOT_TM35", + [736] = "EVENT_GAVE_FOSSIL_TO_LAB", + [737] = "EVENT_LAB_STILL_REVIVING_FOSSIL", + [738] = "EVENT_LAB_HANDING_OVER_FOSSIL_MON", + [832] = "EVENT_GOT_TM31", + [848] = "EVENT_DEFEATED_FIGHTING_DOJO", + [849] = "EVENT_BEAT_KARATE_MASTER", + [850] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_0", + [851] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_1", + [852] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_2", + [853] = "EVENT_BEAT_FIGHTING_DOJO_TRAINER_3", + [854] = "EVENT_GOT_HITMONLEE", + [855] = "EVENT_GOT_HITMONCHAN", + [864] = "EVENT_GOT_TM46", + [865] = "EVENT_BEAT_SABRINA", + [866] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_0", + [867] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_1", + [868] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_2", + [869] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_3", + [870] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_4", + [871] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_5", + [872] = "EVENT_BEAT_SAFFRON_GYM_TRAINER_6", + [919] = "EVENT_SILPH_CO_RECEPTIONIST_AT_DESK", + [944] = "EVENT_GOT_TM29", + [960] = "EVENT_GOT_POTION_SAMPLE", + [984] = "EVENT_GOT_HM05", + [994] = "EVENT_BEAT_ROUTE_3_TRAINER_0", + [995] = "EVENT_BEAT_ROUTE_3_TRAINER_1", + [996] = "EVENT_BEAT_ROUTE_3_TRAINER_2", + [997] = "EVENT_BEAT_ROUTE_3_TRAINER_3", + [998] = "EVENT_BEAT_ROUTE_3_TRAINER_4", + [999] = "EVENT_BEAT_ROUTE_3_TRAINER_5", + [1000] = "EVENT_BEAT_ROUTE_3_TRAINER_6", + [1001] = "EVENT_BEAT_ROUTE_3_TRAINER_7", + [1010] = "EVENT_BEAT_ROUTE_4_TRAINER_0", + [1023] = "EVENT_BOUGHT_MAGIKARP", + [1041] = "EVENT_BEAT_ROUTE_6_TRAINER_0", + [1042] = "EVENT_BEAT_ROUTE_6_TRAINER_1", + [1043] = "EVENT_BEAT_ROUTE_6_TRAINER_2", + [1044] = "EVENT_BEAT_ROUTE_6_TRAINER_3", + [1045] = "EVENT_BEAT_ROUTE_6_TRAINER_4", + [1046] = "EVENT_BEAT_ROUTE_6_TRAINER_5", + [1073] = "EVENT_BEAT_ROUTE_8_TRAINER_0", + [1074] = "EVENT_BEAT_ROUTE_8_TRAINER_1", + [1075] = "EVENT_BEAT_ROUTE_8_TRAINER_2", + [1076] = "EVENT_BEAT_ROUTE_8_TRAINER_3", + [1077] = "EVENT_BEAT_ROUTE_8_TRAINER_4", + [1078] = "EVENT_BEAT_ROUTE_8_TRAINER_5", + [1079] = "EVENT_BEAT_ROUTE_8_TRAINER_6", + [1080] = "EVENT_BEAT_ROUTE_8_TRAINER_7", + [1081] = "EVENT_BEAT_ROUTE_8_TRAINER_8", + [1089] = "EVENT_BEAT_ROUTE_9_TRAINER_0", + [1090] = "EVENT_BEAT_ROUTE_9_TRAINER_1", + [1091] = "EVENT_BEAT_ROUTE_9_TRAINER_2", + [1092] = "EVENT_BEAT_ROUTE_9_TRAINER_3", + [1093] = "EVENT_BEAT_ROUTE_9_TRAINER_4", + [1094] = "EVENT_BEAT_ROUTE_9_TRAINER_5", + [1095] = "EVENT_BEAT_ROUTE_9_TRAINER_6", + [1096] = "EVENT_BEAT_ROUTE_9_TRAINER_7", + [1097] = "EVENT_BEAT_ROUTE_9_TRAINER_8", + [1105] = "EVENT_BEAT_ROUTE_10_TRAINER_0", + [1106] = "EVENT_BEAT_ROUTE_10_TRAINER_1", + [1107] = "EVENT_BEAT_ROUTE_10_TRAINER_2", + [1108] = "EVENT_BEAT_ROUTE_10_TRAINER_3", + [1109] = "EVENT_BEAT_ROUTE_10_TRAINER_4", + [1110] = "EVENT_BEAT_ROUTE_10_TRAINER_5", + [1113] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0", + [1114] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1", + [1115] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2", + [1116] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3", + [1117] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4", + [1118] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5", + [1119] = "EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6", + [1121] = "EVENT_BEAT_POWER_PLANT_VOLTORB_0", + [1122] = "EVENT_BEAT_POWER_PLANT_VOLTORB_1", + [1123] = "EVENT_BEAT_POWER_PLANT_VOLTORB_2", + [1124] = "EVENT_BEAT_POWER_PLANT_VOLTORB_3", + [1125] = "EVENT_BEAT_POWER_PLANT_VOLTORB_4", + [1126] = "EVENT_BEAT_POWER_PLANT_VOLTORB_5", + [1127] = "EVENT_BEAT_POWER_PLANT_VOLTORB_6", + [1128] = "EVENT_BEAT_POWER_PLANT_VOLTORB_7", + [1129] = "EVENT_BEAT_ZAPDOS", + [1137] = "EVENT_BEAT_ROUTE_11_TRAINER_0", + [1138] = "EVENT_BEAT_ROUTE_11_TRAINER_1", + [1139] = "EVENT_BEAT_ROUTE_11_TRAINER_2", + [1140] = "EVENT_BEAT_ROUTE_11_TRAINER_3", + [1141] = "EVENT_BEAT_ROUTE_11_TRAINER_4", + [1142] = "EVENT_BEAT_ROUTE_11_TRAINER_5", + [1143] = "EVENT_BEAT_ROUTE_11_TRAINER_6", + [1144] = "EVENT_BEAT_ROUTE_11_TRAINER_7", + [1145] = "EVENT_BEAT_ROUTE_11_TRAINER_8", + [1146] = "EVENT_BEAT_ROUTE_11_TRAINER_9", + [1151] = "EVENT_GOT_ITEMFINDER", + [1152] = "EVENT_GOT_TM39", + [1154] = "EVENT_BEAT_ROUTE_12_TRAINER_0", + [1155] = "EVENT_BEAT_ROUTE_12_TRAINER_1", + [1156] = "EVENT_BEAT_ROUTE_12_TRAINER_2", + [1157] = "EVENT_BEAT_ROUTE_12_TRAINER_3", + [1158] = "EVENT_BEAT_ROUTE_12_TRAINER_4", + [1159] = "EVENT_BEAT_ROUTE_12_TRAINER_5", + [1160] = "EVENT_BEAT_ROUTE_12_TRAINER_6", + [1166] = "EVENT_FIGHT_ROUTE12_SNORLAX", + [1167] = "EVENT_BEAT_ROUTE12_SNORLAX", + [1169] = "EVENT_BEAT_ROUTE_13_TRAINER_0", + [1170] = "EVENT_BEAT_ROUTE_13_TRAINER_1", + [1171] = "EVENT_BEAT_ROUTE_13_TRAINER_2", + [1172] = "EVENT_BEAT_ROUTE_13_TRAINER_3", + [1173] = "EVENT_BEAT_ROUTE_13_TRAINER_4", + [1174] = "EVENT_BEAT_ROUTE_13_TRAINER_5", + [1175] = "EVENT_BEAT_ROUTE_13_TRAINER_6", + [1176] = "EVENT_BEAT_ROUTE_13_TRAINER_7", + [1177] = "EVENT_BEAT_ROUTE_13_TRAINER_8", + [1178] = "EVENT_BEAT_ROUTE_13_TRAINER_9", + [1185] = "EVENT_BEAT_ROUTE_14_TRAINER_0", + [1186] = "EVENT_BEAT_ROUTE_14_TRAINER_1", + [1187] = "EVENT_BEAT_ROUTE_14_TRAINER_2", + [1188] = "EVENT_BEAT_ROUTE_14_TRAINER_3", + [1189] = "EVENT_BEAT_ROUTE_14_TRAINER_4", + [1190] = "EVENT_BEAT_ROUTE_14_TRAINER_5", + [1191] = "EVENT_BEAT_ROUTE_14_TRAINER_6", + [1192] = "EVENT_BEAT_ROUTE_14_TRAINER_7", + [1193] = "EVENT_BEAT_ROUTE_14_TRAINER_8", + [1194] = "EVENT_BEAT_ROUTE_14_TRAINER_9", + [1200] = "EVENT_GOT_EXP_ALL", + [1201] = "EVENT_BEAT_ROUTE_15_TRAINER_0", + [1202] = "EVENT_BEAT_ROUTE_15_TRAINER_1", + [1203] = "EVENT_BEAT_ROUTE_15_TRAINER_2", + [1204] = "EVENT_BEAT_ROUTE_15_TRAINER_3", + [1205] = "EVENT_BEAT_ROUTE_15_TRAINER_4", + [1206] = "EVENT_BEAT_ROUTE_15_TRAINER_5", + [1207] = "EVENT_BEAT_ROUTE_15_TRAINER_6", + [1208] = "EVENT_BEAT_ROUTE_15_TRAINER_7", + [1209] = "EVENT_BEAT_ROUTE_15_TRAINER_8", + [1210] = "EVENT_BEAT_ROUTE_15_TRAINER_9", + [1217] = "EVENT_BEAT_ROUTE_16_TRAINER_0", + [1218] = "EVENT_BEAT_ROUTE_16_TRAINER_1", + [1219] = "EVENT_BEAT_ROUTE_16_TRAINER_2", + [1220] = "EVENT_BEAT_ROUTE_16_TRAINER_3", + [1221] = "EVENT_BEAT_ROUTE_16_TRAINER_4", + [1222] = "EVENT_BEAT_ROUTE_16_TRAINER_5", + [1224] = "EVENT_FIGHT_ROUTE16_SNORLAX", + [1225] = "EVENT_BEAT_ROUTE16_SNORLAX", + [1230] = "EVENT_GOT_HM02", + [1231] = "EVENT_RESCUED_MR_FUJI", + [1233] = "EVENT_BEAT_ROUTE_17_TRAINER_0", + [1234] = "EVENT_BEAT_ROUTE_17_TRAINER_1", + [1235] = "EVENT_BEAT_ROUTE_17_TRAINER_2", + [1236] = "EVENT_BEAT_ROUTE_17_TRAINER_3", + [1237] = "EVENT_BEAT_ROUTE_17_TRAINER_4", + [1238] = "EVENT_BEAT_ROUTE_17_TRAINER_5", + [1239] = "EVENT_BEAT_ROUTE_17_TRAINER_6", + [1240] = "EVENT_BEAT_ROUTE_17_TRAINER_7", + [1241] = "EVENT_BEAT_ROUTE_17_TRAINER_8", + [1242] = "EVENT_BEAT_ROUTE_17_TRAINER_9", + [1249] = "EVENT_BEAT_ROUTE_18_TRAINER_0", + [1250] = "EVENT_BEAT_ROUTE_18_TRAINER_1", + [1251] = "EVENT_BEAT_ROUTE_18_TRAINER_2", + [1265] = "EVENT_BEAT_ROUTE_19_TRAINER_0", + [1266] = "EVENT_BEAT_ROUTE_19_TRAINER_1", + [1267] = "EVENT_BEAT_ROUTE_19_TRAINER_2", + [1268] = "EVENT_BEAT_ROUTE_19_TRAINER_3", + [1269] = "EVENT_BEAT_ROUTE_19_TRAINER_4", + [1270] = "EVENT_BEAT_ROUTE_19_TRAINER_5", + [1271] = "EVENT_BEAT_ROUTE_19_TRAINER_6", + [1272] = "EVENT_BEAT_ROUTE_19_TRAINER_7", + [1273] = "EVENT_BEAT_ROUTE_19_TRAINER_8", + [1274] = "EVENT_BEAT_ROUTE_19_TRAINER_9", + [1280] = "EVENT_IN_SEAFOAM_ISLANDS", + [1281] = "EVENT_BEAT_ROUTE_20_TRAINER_0", + [1282] = "EVENT_BEAT_ROUTE_20_TRAINER_1", + [1283] = "EVENT_BEAT_ROUTE_20_TRAINER_2", + [1284] = "EVENT_BEAT_ROUTE_20_TRAINER_3", + [1285] = "EVENT_BEAT_ROUTE_20_TRAINER_4", + [1286] = "EVENT_BEAT_ROUTE_20_TRAINER_5", + [1287] = "EVENT_BEAT_ROUTE_20_TRAINER_6", + [1288] = "EVENT_BEAT_ROUTE_20_TRAINER_7", + [1289] = "EVENT_BEAT_ROUTE_20_TRAINER_8", + [1290] = "EVENT_BEAT_ROUTE_20_TRAINER_9", + [1294] = "EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE", + [1295] = "EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE", + [1297] = "EVENT_BEAT_ROUTE_21_TRAINER_0", + [1298] = "EVENT_BEAT_ROUTE_21_TRAINER_1", + [1299] = "EVENT_BEAT_ROUTE_21_TRAINER_2", + [1300] = "EVENT_BEAT_ROUTE_21_TRAINER_3", + [1301] = "EVENT_BEAT_ROUTE_21_TRAINER_4", + [1302] = "EVENT_BEAT_ROUTE_21_TRAINER_5", + [1303] = "EVENT_BEAT_ROUTE_21_TRAINER_6", + [1304] = "EVENT_BEAT_ROUTE_21_TRAINER_7", + [1305] = "EVENT_BEAT_ROUTE_21_TRAINER_8", + [1312] = "EVENT_1ST_ROUTE22_RIVAL_BATTLE", + [1313] = "EVENT_2ND_ROUTE22_RIVAL_BATTLE", + [1317] = "EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE", + [1318] = "EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE", + [1319] = "EVENT_ROUTE22_RIVAL_WANTS_BATTLE", + [1328] = "EVENT_PASSED_CASCADEBADGE_CHECK", + [1329] = "EVENT_PASSED_THUNDERBADGE_CHECK", + [1330] = "EVENT_PASSED_RAINBOWBADGE_CHECK", + [1331] = "EVENT_PASSED_SOULBADGE_CHECK", + [1332] = "EVENT_PASSED_MARSHBADGE_CHECK", + [1333] = "EVENT_PASSED_VOLCANOBADGE_CHECK", + [1334] = "EVENT_PASSED_EARTHBADGE_CHECK", + [1336] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1", + [1337] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0", + [1338] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1", + [1339] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2", + [1340] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3", + [1341] = "EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4", + [1342] = "EVENT_BEAT_MOLTRES", + [1343] = "EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2", + [1344] = "EVENT_GOT_NUGGET", + [1345] = "EVENT_BEAT_ROUTE24_ROCKET", + [1346] = "EVENT_BEAT_ROUTE_24_TRAINER_0", + [1347] = "EVENT_BEAT_ROUTE_24_TRAINER_1", + [1348] = "EVENT_BEAT_ROUTE_24_TRAINER_2", + [1349] = "EVENT_BEAT_ROUTE_24_TRAINER_3", + [1350] = "EVENT_BEAT_ROUTE_24_TRAINER_4", + [1351] = "EVENT_BEAT_ROUTE_24_TRAINER_5", + [1353] = "EVENT_NUGGET_REWARD_AVAILABLE", + [1359] = "EVENT_54F", + [1360] = "EVENT_MET_BILL", + [1361] = "EVENT_BEAT_ROUTE_25_TRAINER_0", + [1362] = "EVENT_BEAT_ROUTE_25_TRAINER_1", + [1363] = "EVENT_BEAT_ROUTE_25_TRAINER_2", + [1364] = "EVENT_BEAT_ROUTE_25_TRAINER_3", + [1365] = "EVENT_BEAT_ROUTE_25_TRAINER_4", + [1366] = "EVENT_BEAT_ROUTE_25_TRAINER_5", + [1367] = "EVENT_BEAT_ROUTE_25_TRAINER_6", + [1368] = "EVENT_BEAT_ROUTE_25_TRAINER_7", + [1369] = "EVENT_BEAT_ROUTE_25_TRAINER_8", + [1371] = "EVENT_USED_CELL_SEPARATOR_ON_BILL", + [1372] = "EVENT_GOT_SS_TICKET", + [1373] = "EVENT_MET_BILL_2", + [1374] = "EVENT_BILL_SAID_USE_CELL_SEPARATOR", + [1375] = "EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING", + [1378] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0", + [1379] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1", + [1380] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2", + [1381] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3", + [1382] = "EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4", + [1393] = "EVENT_BEAT_MT_MOON_1_TRAINER_0", + [1394] = "EVENT_BEAT_MT_MOON_1_TRAINER_1", + [1395] = "EVENT_BEAT_MT_MOON_1_TRAINER_2", + [1396] = "EVENT_BEAT_MT_MOON_1_TRAINER_3", + [1397] = "EVENT_BEAT_MT_MOON_1_TRAINER_4", + [1398] = "EVENT_BEAT_MT_MOON_1_TRAINER_5", + [1399] = "EVENT_BEAT_MT_MOON_1_TRAINER_6", + [1400] = "EVENT_GOT_DOME_FOSSIL", + [1401] = "EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD", + [1402] = "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES", + [1403] = "EVENT_BEAT_MT_MOON_3_TRAINER_0", + [1404] = "EVENT_BEAT_MT_MOON_3_TRAINER_1", + [1405] = "EVENT_BEAT_MT_MOON_3_TRAINER_2", + [1406] = "EVENT_57E", + [1407] = "EVENT_GOT_HELIX_FOSSIL", + [1476] = "EVENT_BEAT_SS_ANNE_5_TRAINER_0", + [1477] = "EVENT_BEAT_SS_ANNE_5_TRAINER_1", + [1504] = "EVENT_GOT_HM01", + [1505] = "EVENT_RUBBED_CAPTAINS_BACK", + [1506] = "EVENT_SS_ANNE_LEFT", + [1507] = "EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT", + [1508] = "EVENT_STARTED_WALKING_OUT_OF_DOCK", + [1509] = "EVENT_WALKED_OUT_OF_DOCK", + [1521] = "EVENT_BEAT_SS_ANNE_8_TRAINER_0", + [1522] = "EVENT_BEAT_SS_ANNE_8_TRAINER_1", + [1523] = "EVENT_BEAT_SS_ANNE_8_TRAINER_2", + [1524] = "EVENT_BEAT_SS_ANNE_8_TRAINER_3", + [1537] = "EVENT_BEAT_SS_ANNE_9_TRAINER_0", + [1538] = "EVENT_BEAT_SS_ANNE_9_TRAINER_1", + [1539] = "EVENT_BEAT_SS_ANNE_9_TRAINER_2", + [1540] = "EVENT_BEAT_SS_ANNE_9_TRAINER_3", + [1553] = "EVENT_BEAT_SS_ANNE_10_TRAINER_0", + [1554] = "EVENT_BEAT_SS_ANNE_10_TRAINER_1", + [1555] = "EVENT_BEAT_SS_ANNE_10_TRAINER_2", + [1556] = "EVENT_BEAT_SS_ANNE_10_TRAINER_3", + [1557] = "EVENT_BEAT_SS_ANNE_10_TRAINER_4", + [1558] = "EVENT_BEAT_SS_ANNE_10_TRAINER_5", + [1632] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1", + [1633] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0", + [1634] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1", + [1635] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2", + [1636] = "EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3", + [1638] = "EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2", + [1649] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0", + [1650] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1", + [1651] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2", + [1652] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3", + [1653] = "EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4", + [1655] = "EVENT_ENTERED_ROCKET_HIDEOUT", + [1663] = "EVENT_67F", + [1665] = "EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0", + [1681] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0", + [1682] = "EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1", + [1696] = "EVENT_6A0", + [1698] = "EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES", + [1699] = "EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT", + [1700] = "EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2", + [1701] = "EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED", + [1702] = "EVENT_ROCKET_DROPPED_LIFT_KEY", + [1703] = "EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI", + [1778] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_0", + [1779] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_1", + [1780] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_2", + [1781] = "EVENT_BEAT_SILPH_CO_2F_TRAINER_3", + [1789] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR1", + [1790] = "EVENT_SILPH_CO_2_UNLOCKED_DOOR2", + [1791] = "EVENT_GOT_TM36", + [1794] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_0", + [1795] = "EVENT_BEAT_SILPH_CO_3F_TRAINER_1", + [1800] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR1", + [1801] = "EVENT_SILPH_CO_3_UNLOCKED_DOOR2", + [1810] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_0", + [1811] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_1", + [1812] = "EVENT_BEAT_SILPH_CO_4F_TRAINER_2", + [1816] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR1", + [1817] = "EVENT_SILPH_CO_4_UNLOCKED_DOOR2", + [1826] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_0", + [1827] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_1", + [1828] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_2", + [1829] = "EVENT_BEAT_SILPH_CO_5F_TRAINER_3", + [1832] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR1", + [1833] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR2", + [1834] = "EVENT_SILPH_CO_5_UNLOCKED_DOOR3", + [1846] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_0", + [1847] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_1", + [1848] = "EVENT_BEAT_SILPH_CO_6F_TRAINER_2", + [1855] = "EVENT_SILPH_CO_6_UNLOCKED_DOOR", + [1856] = "EVENT_BEAT_SILPH_CO_RIVAL", + [1861] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_0", + [1862] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_1", + [1863] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_2", + [1864] = "EVENT_BEAT_SILPH_CO_7F_TRAINER_3", + [1868] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR1", + [1869] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR2", + [1870] = "EVENT_SILPH_CO_7_UNLOCKED_DOOR3", + [1874] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_0", + [1875] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_1", + [1876] = "EVENT_BEAT_SILPH_CO_8F_TRAINER_2", + [1880] = "EVENT_SILPH_CO_8_UNLOCKED_DOOR", + [1890] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_0", + [1891] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_1", + [1892] = "EVENT_BEAT_SILPH_CO_9F_TRAINER_2", + [1896] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR1", + [1897] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR2", + [1898] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR3", + [1899] = "EVENT_SILPH_CO_9_UNLOCKED_DOOR4", + [1905] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_0", + [1906] = "EVENT_BEAT_SILPH_CO_10F_TRAINER_1", + [1912] = "EVENT_SILPH_CO_10_UNLOCKED_DOOR", + [1920] = "EVENT_780", + [1921] = "EVENT_781", + [1922] = "EVENT_782", + [1924] = "EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES", + [1925] = "EVENT_BEAT_SILPH_CO_11F_TRAINER_0", + [1928] = "EVENT_SILPH_CO_11_UNLOCKED_DOOR", + [1933] = "EVENT_GOT_MASTER_BALL", + [1935] = "EVENT_BEAT_SILPH_CO_GIOVANNI", + [2049] = "EVENT_BEAT_MANSION_2_TRAINER_0", + [2065] = "EVENT_BEAT_MANSION_3_TRAINER_0", + [2066] = "EVENT_BEAT_MANSION_3_TRAINER_1", + [2081] = "EVENT_BEAT_MANSION_4_TRAINER_0", + [2082] = "EVENT_BEAT_MANSION_4_TRAINER_1", + [2176] = "EVENT_GOT_HM03", + [2241] = "EVENT_BEAT_MEWTWO", + [2273] = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", + [2278] = "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM", + [2281] = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", + [2286] = "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM", + [2289] = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", + [2294] = "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM", + [2297] = "EVENT_BEAT_LANCES_ROOM_TRAINER_0", + [2302] = "EVENT_BEAT_LANCE", + [2303] = "EVENT_LANCES_ROOM_LOCK_DOOR", + [2305] = "EVENT_BEAT_CHAMPION_RIVAL", + [2321] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0", + [2322] = "EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1", + [2327] = "EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH", + [2481] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0", + [2482] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1", + [2483] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2", + [2484] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3", + [2485] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4", + [2486] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5", + [2487] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6", + [2488] = "EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7", + [2496] = "EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE", + [2497] = "EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE", + [2504] = "EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE", + [2505] = "EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE", + [2512] = "EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE", + [2513] = "EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE", + [2522] = "EVENT_BEAT_ARTICUNO", + }, + byName = { + EVENT_1B8 = 440, + EVENT_1BF = 447, + EVENT_1ST_LOCK_OPENED = 353, + EVENT_1ST_ROUTE22_RIVAL_BATTLE = 1312, + EVENT_2A7 = 679, + EVENT_2ND_LOCK_OPENED = 352, + EVENT_2ND_ROUTE22_RIVAL_BATTLE = 1313, + EVENT_54F = 1359, + EVENT_57E = 1406, + EVENT_67F = 1663, + EVENT_6A0 = 1696, + EVENT_780 = 1920, + EVENT_781 = 1921, + EVENT_782 = 1922, + EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = 2294, + EVENT_AUTOWALKED_INTO_BRUNOS_ROOM = 2286, + EVENT_AUTOWALKED_INTO_LORELEIS_ROOM = 2278, + EVENT_BATTLED_RIVAL_IN_OAKS_LAB = 35, + EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = 2289, + EVENT_BEAT_ARTICUNO = 2522, + EVENT_BEAT_BLAINE = 665, + EVENT_BEAT_BROCK = 119, + EVENT_BEAT_BRUNOS_ROOM_TRAINER_0 = 2281, + EVENT_BEAT_CELADON_GYM_TRAINER_0 = 426, + EVENT_BEAT_CELADON_GYM_TRAINER_1 = 427, + EVENT_BEAT_CELADON_GYM_TRAINER_2 = 428, + EVENT_BEAT_CELADON_GYM_TRAINER_3 = 429, + EVENT_BEAT_CELADON_GYM_TRAINER_4 = 430, + EVENT_BEAT_CELADON_GYM_TRAINER_5 = 431, + EVENT_BEAT_CELADON_GYM_TRAINER_6 = 432, + EVENT_BEAT_CERULEAN_GYM_TRAINER_0 = 186, + EVENT_BEAT_CERULEAN_GYM_TRAINER_1 = 187, + EVENT_BEAT_CERULEAN_RIVAL = 152, + EVENT_BEAT_CERULEAN_ROCKET_THIEF = 167, + EVENT_BEAT_CHAMPION_RIVAL = 2305, + EVENT_BEAT_CINNABAR_GYM_TRAINER_0 = 666, + EVENT_BEAT_CINNABAR_GYM_TRAINER_1 = 667, + EVENT_BEAT_CINNABAR_GYM_TRAINER_2 = 668, + EVENT_BEAT_CINNABAR_GYM_TRAINER_3 = 669, + EVENT_BEAT_CINNABAR_GYM_TRAINER_4 = 670, + EVENT_BEAT_CINNABAR_GYM_TRAINER_5 = 671, + EVENT_BEAT_CINNABAR_GYM_TRAINER_6 = 672, + EVENT_BEAT_ERIKA = 425, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_0 = 850, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_1 = 851, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_2 = 852, + EVENT_BEAT_FIGHTING_DOJO_TRAINER_3 = 853, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_0 = 602, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_1 = 603, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_2 = 604, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_3 = 605, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_4 = 606, + EVENT_BEAT_FUCHSIA_GYM_TRAINER_5 = 607, + EVENT_BEAT_GHOST_MAROWAK = 271, + EVENT_BEAT_KARATE_MASTER = 849, + EVENT_BEAT_KOGA = 601, + EVENT_BEAT_LANCE = 2302, + EVENT_BEAT_LANCES_ROOM_TRAINER_0 = 2297, + EVENT_BEAT_LORELEIS_ROOM_TRAINER_0 = 2273, + EVENT_BEAT_LT_SURGE = 359, + EVENT_BEAT_MANSION_1_TRAINER_0 = 649, + EVENT_BEAT_MANSION_2_TRAINER_0 = 2049, + EVENT_BEAT_MANSION_3_TRAINER_0 = 2065, + EVENT_BEAT_MANSION_3_TRAINER_1 = 2066, + EVENT_BEAT_MANSION_4_TRAINER_0 = 2081, + EVENT_BEAT_MANSION_4_TRAINER_1 = 2082, + EVENT_BEAT_MEWTWO = 2241, + EVENT_BEAT_MISTY = 191, + EVENT_BEAT_MOLTRES = 1342, + EVENT_BEAT_MT_MOON_1_TRAINER_0 = 1393, + EVENT_BEAT_MT_MOON_1_TRAINER_1 = 1394, + EVENT_BEAT_MT_MOON_1_TRAINER_2 = 1395, + EVENT_BEAT_MT_MOON_1_TRAINER_3 = 1396, + EVENT_BEAT_MT_MOON_1_TRAINER_4 = 1397, + EVENT_BEAT_MT_MOON_1_TRAINER_5 = 1398, + EVENT_BEAT_MT_MOON_1_TRAINER_6 = 1399, + EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = 1402, + EVENT_BEAT_MT_MOON_3_TRAINER_0 = 1403, + EVENT_BEAT_MT_MOON_3_TRAINER_1 = 1404, + EVENT_BEAT_MT_MOON_3_TRAINER_2 = 1405, + EVENT_BEAT_MT_MOON_EXIT_SUPER_NERD = 1401, + EVENT_BEAT_PEWTER_GYM_TRAINER_0 = 114, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_0 = 241, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_1 = 242, + EVENT_BEAT_POKEMONTOWER_3_TRAINER_2 = 243, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_0 = 249, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_1 = 250, + EVENT_BEAT_POKEMONTOWER_4_TRAINER_2 = 251, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_0 = 258, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_1 = 259, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_2 = 260, + EVENT_BEAT_POKEMONTOWER_5_TRAINER_3 = 261, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_0 = 265, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_1 = 266, + EVENT_BEAT_POKEMONTOWER_6_TRAINER_2 = 267, + EVENT_BEAT_POKEMONTOWER_7_JESSIE_JAMES = 273, + EVENT_BEAT_POKEMON_TOWER_RIVAL = 239, + EVENT_BEAT_POWER_PLANT_VOLTORB_0 = 1121, + EVENT_BEAT_POWER_PLANT_VOLTORB_1 = 1122, + EVENT_BEAT_POWER_PLANT_VOLTORB_2 = 1123, + EVENT_BEAT_POWER_PLANT_VOLTORB_3 = 1124, + EVENT_BEAT_POWER_PLANT_VOLTORB_4 = 1125, + EVENT_BEAT_POWER_PLANT_VOLTORB_5 = 1126, + EVENT_BEAT_POWER_PLANT_VOLTORB_6 = 1127, + EVENT_BEAT_POWER_PLANT_VOLTORB_7 = 1128, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_0 = 1649, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_1 = 1650, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_2 = 1651, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_3 = 1652, + EVENT_BEAT_ROCKET_HIDEOUT_1_TRAINER_4 = 1653, + EVENT_BEAT_ROCKET_HIDEOUT_2_TRAINER_0 = 1665, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_0 = 1681, + EVENT_BEAT_ROCKET_HIDEOUT_3_TRAINER_1 = 1682, + EVENT_BEAT_ROCKET_HIDEOUT_4_JESSIE_JAMES = 1698, + EVENT_BEAT_ROCKET_HIDEOUT_4_TRAINER_2 = 1700, + EVENT_BEAT_ROCKET_HIDEOUT_GIOVANNI = 1703, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_0 = 1113, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_1 = 1114, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_2 = 1115, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_3 = 1116, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_4 = 1117, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_5 = 1118, + EVENT_BEAT_ROCK_TUNNEL_1_TRAINER_6 = 1119, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_0 = 2481, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_1 = 2482, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_2 = 2483, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_3 = 2484, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_4 = 2485, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_5 = 2486, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_6 = 2487, + EVENT_BEAT_ROCK_TUNNEL_2_TRAINER_7 = 2488, + EVENT_BEAT_ROUTE12_SNORLAX = 1167, + EVENT_BEAT_ROUTE16_SNORLAX = 1225, + EVENT_BEAT_ROUTE22_RIVAL_1ST_BATTLE = 1317, + EVENT_BEAT_ROUTE22_RIVAL_2ND_BATTLE = 1318, + EVENT_BEAT_ROUTE24_ROCKET = 1345, + EVENT_BEAT_ROUTE_10_TRAINER_0 = 1105, + EVENT_BEAT_ROUTE_10_TRAINER_1 = 1106, + EVENT_BEAT_ROUTE_10_TRAINER_2 = 1107, + EVENT_BEAT_ROUTE_10_TRAINER_3 = 1108, + EVENT_BEAT_ROUTE_10_TRAINER_4 = 1109, + EVENT_BEAT_ROUTE_10_TRAINER_5 = 1110, + EVENT_BEAT_ROUTE_11_TRAINER_0 = 1137, + EVENT_BEAT_ROUTE_11_TRAINER_1 = 1138, + EVENT_BEAT_ROUTE_11_TRAINER_2 = 1139, + EVENT_BEAT_ROUTE_11_TRAINER_3 = 1140, + EVENT_BEAT_ROUTE_11_TRAINER_4 = 1141, + EVENT_BEAT_ROUTE_11_TRAINER_5 = 1142, + EVENT_BEAT_ROUTE_11_TRAINER_6 = 1143, + EVENT_BEAT_ROUTE_11_TRAINER_7 = 1144, + EVENT_BEAT_ROUTE_11_TRAINER_8 = 1145, + EVENT_BEAT_ROUTE_11_TRAINER_9 = 1146, + EVENT_BEAT_ROUTE_12_TRAINER_0 = 1154, + EVENT_BEAT_ROUTE_12_TRAINER_1 = 1155, + EVENT_BEAT_ROUTE_12_TRAINER_2 = 1156, + EVENT_BEAT_ROUTE_12_TRAINER_3 = 1157, + EVENT_BEAT_ROUTE_12_TRAINER_4 = 1158, + EVENT_BEAT_ROUTE_12_TRAINER_5 = 1159, + EVENT_BEAT_ROUTE_12_TRAINER_6 = 1160, + EVENT_BEAT_ROUTE_13_TRAINER_0 = 1169, + EVENT_BEAT_ROUTE_13_TRAINER_1 = 1170, + EVENT_BEAT_ROUTE_13_TRAINER_2 = 1171, + EVENT_BEAT_ROUTE_13_TRAINER_3 = 1172, + EVENT_BEAT_ROUTE_13_TRAINER_4 = 1173, + EVENT_BEAT_ROUTE_13_TRAINER_5 = 1174, + EVENT_BEAT_ROUTE_13_TRAINER_6 = 1175, + EVENT_BEAT_ROUTE_13_TRAINER_7 = 1176, + EVENT_BEAT_ROUTE_13_TRAINER_8 = 1177, + EVENT_BEAT_ROUTE_13_TRAINER_9 = 1178, + EVENT_BEAT_ROUTE_14_TRAINER_0 = 1185, + EVENT_BEAT_ROUTE_14_TRAINER_1 = 1186, + EVENT_BEAT_ROUTE_14_TRAINER_2 = 1187, + EVENT_BEAT_ROUTE_14_TRAINER_3 = 1188, + EVENT_BEAT_ROUTE_14_TRAINER_4 = 1189, + EVENT_BEAT_ROUTE_14_TRAINER_5 = 1190, + EVENT_BEAT_ROUTE_14_TRAINER_6 = 1191, + EVENT_BEAT_ROUTE_14_TRAINER_7 = 1192, + EVENT_BEAT_ROUTE_14_TRAINER_8 = 1193, + EVENT_BEAT_ROUTE_14_TRAINER_9 = 1194, + EVENT_BEAT_ROUTE_15_TRAINER_0 = 1201, + EVENT_BEAT_ROUTE_15_TRAINER_1 = 1202, + EVENT_BEAT_ROUTE_15_TRAINER_2 = 1203, + EVENT_BEAT_ROUTE_15_TRAINER_3 = 1204, + EVENT_BEAT_ROUTE_15_TRAINER_4 = 1205, + EVENT_BEAT_ROUTE_15_TRAINER_5 = 1206, + EVENT_BEAT_ROUTE_15_TRAINER_6 = 1207, + EVENT_BEAT_ROUTE_15_TRAINER_7 = 1208, + EVENT_BEAT_ROUTE_15_TRAINER_8 = 1209, + EVENT_BEAT_ROUTE_15_TRAINER_9 = 1210, + EVENT_BEAT_ROUTE_16_TRAINER_0 = 1217, + EVENT_BEAT_ROUTE_16_TRAINER_1 = 1218, + EVENT_BEAT_ROUTE_16_TRAINER_2 = 1219, + EVENT_BEAT_ROUTE_16_TRAINER_3 = 1220, + EVENT_BEAT_ROUTE_16_TRAINER_4 = 1221, + EVENT_BEAT_ROUTE_16_TRAINER_5 = 1222, + EVENT_BEAT_ROUTE_17_TRAINER_0 = 1233, + EVENT_BEAT_ROUTE_17_TRAINER_1 = 1234, + EVENT_BEAT_ROUTE_17_TRAINER_2 = 1235, + EVENT_BEAT_ROUTE_17_TRAINER_3 = 1236, + EVENT_BEAT_ROUTE_17_TRAINER_4 = 1237, + EVENT_BEAT_ROUTE_17_TRAINER_5 = 1238, + EVENT_BEAT_ROUTE_17_TRAINER_6 = 1239, + EVENT_BEAT_ROUTE_17_TRAINER_7 = 1240, + EVENT_BEAT_ROUTE_17_TRAINER_8 = 1241, + EVENT_BEAT_ROUTE_17_TRAINER_9 = 1242, + EVENT_BEAT_ROUTE_18_TRAINER_0 = 1249, + EVENT_BEAT_ROUTE_18_TRAINER_1 = 1250, + EVENT_BEAT_ROUTE_18_TRAINER_2 = 1251, + EVENT_BEAT_ROUTE_19_TRAINER_0 = 1265, + EVENT_BEAT_ROUTE_19_TRAINER_1 = 1266, + EVENT_BEAT_ROUTE_19_TRAINER_2 = 1267, + EVENT_BEAT_ROUTE_19_TRAINER_3 = 1268, + EVENT_BEAT_ROUTE_19_TRAINER_4 = 1269, + EVENT_BEAT_ROUTE_19_TRAINER_5 = 1270, + EVENT_BEAT_ROUTE_19_TRAINER_6 = 1271, + EVENT_BEAT_ROUTE_19_TRAINER_7 = 1272, + EVENT_BEAT_ROUTE_19_TRAINER_8 = 1273, + EVENT_BEAT_ROUTE_19_TRAINER_9 = 1274, + EVENT_BEAT_ROUTE_20_TRAINER_0 = 1281, + EVENT_BEAT_ROUTE_20_TRAINER_1 = 1282, + EVENT_BEAT_ROUTE_20_TRAINER_2 = 1283, + EVENT_BEAT_ROUTE_20_TRAINER_3 = 1284, + EVENT_BEAT_ROUTE_20_TRAINER_4 = 1285, + EVENT_BEAT_ROUTE_20_TRAINER_5 = 1286, + EVENT_BEAT_ROUTE_20_TRAINER_6 = 1287, + EVENT_BEAT_ROUTE_20_TRAINER_7 = 1288, + EVENT_BEAT_ROUTE_20_TRAINER_8 = 1289, + EVENT_BEAT_ROUTE_20_TRAINER_9 = 1290, + EVENT_BEAT_ROUTE_21_TRAINER_0 = 1297, + EVENT_BEAT_ROUTE_21_TRAINER_1 = 1298, + EVENT_BEAT_ROUTE_21_TRAINER_2 = 1299, + EVENT_BEAT_ROUTE_21_TRAINER_3 = 1300, + EVENT_BEAT_ROUTE_21_TRAINER_4 = 1301, + EVENT_BEAT_ROUTE_21_TRAINER_5 = 1302, + EVENT_BEAT_ROUTE_21_TRAINER_6 = 1303, + EVENT_BEAT_ROUTE_21_TRAINER_7 = 1304, + EVENT_BEAT_ROUTE_21_TRAINER_8 = 1305, + EVENT_BEAT_ROUTE_24_TRAINER_0 = 1346, + EVENT_BEAT_ROUTE_24_TRAINER_1 = 1347, + EVENT_BEAT_ROUTE_24_TRAINER_2 = 1348, + EVENT_BEAT_ROUTE_24_TRAINER_3 = 1349, + EVENT_BEAT_ROUTE_24_TRAINER_4 = 1350, + EVENT_BEAT_ROUTE_24_TRAINER_5 = 1351, + EVENT_BEAT_ROUTE_25_TRAINER_0 = 1361, + EVENT_BEAT_ROUTE_25_TRAINER_1 = 1362, + EVENT_BEAT_ROUTE_25_TRAINER_2 = 1363, + EVENT_BEAT_ROUTE_25_TRAINER_3 = 1364, + EVENT_BEAT_ROUTE_25_TRAINER_4 = 1365, + EVENT_BEAT_ROUTE_25_TRAINER_5 = 1366, + EVENT_BEAT_ROUTE_25_TRAINER_6 = 1367, + EVENT_BEAT_ROUTE_25_TRAINER_7 = 1368, + EVENT_BEAT_ROUTE_25_TRAINER_8 = 1369, + EVENT_BEAT_ROUTE_3_TRAINER_0 = 994, + EVENT_BEAT_ROUTE_3_TRAINER_1 = 995, + EVENT_BEAT_ROUTE_3_TRAINER_2 = 996, + EVENT_BEAT_ROUTE_3_TRAINER_3 = 997, + EVENT_BEAT_ROUTE_3_TRAINER_4 = 998, + EVENT_BEAT_ROUTE_3_TRAINER_5 = 999, + EVENT_BEAT_ROUTE_3_TRAINER_6 = 1000, + EVENT_BEAT_ROUTE_3_TRAINER_7 = 1001, + EVENT_BEAT_ROUTE_4_TRAINER_0 = 1010, + EVENT_BEAT_ROUTE_6_TRAINER_0 = 1041, + EVENT_BEAT_ROUTE_6_TRAINER_1 = 1042, + EVENT_BEAT_ROUTE_6_TRAINER_2 = 1043, + EVENT_BEAT_ROUTE_6_TRAINER_3 = 1044, + EVENT_BEAT_ROUTE_6_TRAINER_4 = 1045, + EVENT_BEAT_ROUTE_6_TRAINER_5 = 1046, + EVENT_BEAT_ROUTE_8_TRAINER_0 = 1073, + EVENT_BEAT_ROUTE_8_TRAINER_1 = 1074, + EVENT_BEAT_ROUTE_8_TRAINER_2 = 1075, + EVENT_BEAT_ROUTE_8_TRAINER_3 = 1076, + EVENT_BEAT_ROUTE_8_TRAINER_4 = 1077, + EVENT_BEAT_ROUTE_8_TRAINER_5 = 1078, + EVENT_BEAT_ROUTE_8_TRAINER_6 = 1079, + EVENT_BEAT_ROUTE_8_TRAINER_7 = 1080, + EVENT_BEAT_ROUTE_8_TRAINER_8 = 1081, + EVENT_BEAT_ROUTE_9_TRAINER_0 = 1089, + EVENT_BEAT_ROUTE_9_TRAINER_1 = 1090, + EVENT_BEAT_ROUTE_9_TRAINER_2 = 1091, + EVENT_BEAT_ROUTE_9_TRAINER_3 = 1092, + EVENT_BEAT_ROUTE_9_TRAINER_4 = 1093, + EVENT_BEAT_ROUTE_9_TRAINER_5 = 1094, + EVENT_BEAT_ROUTE_9_TRAINER_6 = 1095, + EVENT_BEAT_ROUTE_9_TRAINER_7 = 1096, + EVENT_BEAT_ROUTE_9_TRAINER_8 = 1097, + EVENT_BEAT_SABRINA = 865, + EVENT_BEAT_SAFFRON_GYM_TRAINER_0 = 866, + EVENT_BEAT_SAFFRON_GYM_TRAINER_1 = 867, + EVENT_BEAT_SAFFRON_GYM_TRAINER_2 = 868, + EVENT_BEAT_SAFFRON_GYM_TRAINER_3 = 869, + EVENT_BEAT_SAFFRON_GYM_TRAINER_4 = 870, + EVENT_BEAT_SAFFRON_GYM_TRAINER_5 = 871, + EVENT_BEAT_SAFFRON_GYM_TRAINER_6 = 872, + EVENT_BEAT_SILPH_CO_10F_TRAINER_0 = 1905, + EVENT_BEAT_SILPH_CO_10F_TRAINER_1 = 1906, + EVENT_BEAT_SILPH_CO_11F_JESSIE_JAMES = 1924, + EVENT_BEAT_SILPH_CO_11F_TRAINER_0 = 1925, + EVENT_BEAT_SILPH_CO_2F_TRAINER_0 = 1778, + EVENT_BEAT_SILPH_CO_2F_TRAINER_1 = 1779, + EVENT_BEAT_SILPH_CO_2F_TRAINER_2 = 1780, + EVENT_BEAT_SILPH_CO_2F_TRAINER_3 = 1781, + EVENT_BEAT_SILPH_CO_3F_TRAINER_0 = 1794, + EVENT_BEAT_SILPH_CO_3F_TRAINER_1 = 1795, + EVENT_BEAT_SILPH_CO_4F_TRAINER_0 = 1810, + EVENT_BEAT_SILPH_CO_4F_TRAINER_1 = 1811, + EVENT_BEAT_SILPH_CO_4F_TRAINER_2 = 1812, + EVENT_BEAT_SILPH_CO_5F_TRAINER_0 = 1826, + EVENT_BEAT_SILPH_CO_5F_TRAINER_1 = 1827, + EVENT_BEAT_SILPH_CO_5F_TRAINER_2 = 1828, + EVENT_BEAT_SILPH_CO_5F_TRAINER_3 = 1829, + EVENT_BEAT_SILPH_CO_6F_TRAINER_0 = 1846, + EVENT_BEAT_SILPH_CO_6F_TRAINER_1 = 1847, + EVENT_BEAT_SILPH_CO_6F_TRAINER_2 = 1848, + EVENT_BEAT_SILPH_CO_7F_TRAINER_0 = 1861, + EVENT_BEAT_SILPH_CO_7F_TRAINER_1 = 1862, + EVENT_BEAT_SILPH_CO_7F_TRAINER_2 = 1863, + EVENT_BEAT_SILPH_CO_7F_TRAINER_3 = 1864, + EVENT_BEAT_SILPH_CO_8F_TRAINER_0 = 1874, + EVENT_BEAT_SILPH_CO_8F_TRAINER_1 = 1875, + EVENT_BEAT_SILPH_CO_8F_TRAINER_2 = 1876, + EVENT_BEAT_SILPH_CO_9F_TRAINER_0 = 1890, + EVENT_BEAT_SILPH_CO_9F_TRAINER_1 = 1891, + EVENT_BEAT_SILPH_CO_9F_TRAINER_2 = 1892, + EVENT_BEAT_SILPH_CO_GIOVANNI = 1935, + EVENT_BEAT_SILPH_CO_RIVAL = 1856, + EVENT_BEAT_SS_ANNE_10_TRAINER_0 = 1553, + EVENT_BEAT_SS_ANNE_10_TRAINER_1 = 1554, + EVENT_BEAT_SS_ANNE_10_TRAINER_2 = 1555, + EVENT_BEAT_SS_ANNE_10_TRAINER_3 = 1556, + EVENT_BEAT_SS_ANNE_10_TRAINER_4 = 1557, + EVENT_BEAT_SS_ANNE_10_TRAINER_5 = 1558, + EVENT_BEAT_SS_ANNE_5_TRAINER_0 = 1476, + EVENT_BEAT_SS_ANNE_5_TRAINER_1 = 1477, + EVENT_BEAT_SS_ANNE_8_TRAINER_0 = 1521, + EVENT_BEAT_SS_ANNE_8_TRAINER_1 = 1522, + EVENT_BEAT_SS_ANNE_8_TRAINER_2 = 1523, + EVENT_BEAT_SS_ANNE_8_TRAINER_3 = 1524, + EVENT_BEAT_SS_ANNE_9_TRAINER_0 = 1537, + EVENT_BEAT_SS_ANNE_9_TRAINER_1 = 1538, + EVENT_BEAT_SS_ANNE_9_TRAINER_2 = 1539, + EVENT_BEAT_SS_ANNE_9_TRAINER_3 = 1540, + EVENT_BEAT_VERMILION_GYM_TRAINER_0 = 354, + EVENT_BEAT_VERMILION_GYM_TRAINER_1 = 355, + EVENT_BEAT_VERMILION_GYM_TRAINER_2 = 356, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_0 = 2321, + EVENT_BEAT_VICTORY_ROAD_1_TRAINER_1 = 2322, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_0 = 1337, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_1 = 1338, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_2 = 1339, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_3 = 1340, + EVENT_BEAT_VICTORY_ROAD_2_TRAINER_4 = 1341, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_0 = 1633, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_1 = 1634, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_2 = 1635, + EVENT_BEAT_VICTORY_ROAD_3_TRAINER_3 = 1636, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_0 = 1378, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_1 = 1379, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_2 = 1380, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_3 = 1381, + EVENT_BEAT_VIRIDIAN_FOREST_TRAINER_4 = 1382, + EVENT_BEAT_VIRIDIAN_GYM_GIOVANNI = 81, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_0 = 82, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_1 = 83, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_2 = 84, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_3 = 85, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_4 = 86, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_5 = 87, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_6 = 88, + EVENT_BEAT_VIRIDIAN_GYM_TRAINER_7 = 89, + EVENT_BEAT_ZAPDOS = 1129, + EVENT_BILL_SAID_USE_CELL_SEPARATOR = 1374, + EVENT_BOUGHT_MAGIKARP = 1023, + EVENT_BOUGHT_MUSEUM_TICKET = 104, + EVENT_CINNABAR_GYM_GATE0_UNLOCKED = 680, + EVENT_CINNABAR_GYM_GATE1_UNLOCKED = 681, + EVENT_CINNABAR_GYM_GATE2_UNLOCKED = 682, + EVENT_CINNABAR_GYM_GATE3_UNLOCKED = 683, + EVENT_CINNABAR_GYM_GATE4_UNLOCKED = 684, + EVENT_CINNABAR_GYM_GATE5_UNLOCKED = 685, + EVENT_CINNABAR_GYM_GATE6_UNLOCKED = 686, + EVENT_COMPLETED_CATCH_TRAINING = 45, + EVENT_COMPLETED_CATCH_TRAINING_AGAIN = 46, + EVENT_DAISY_WALKING = 26, + EVENT_DEFEATED_FIGHTING_DOJO = 848, + EVENT_ENTERED_BLUES_HOUSE = 25, + EVENT_ENTERED_ROCKET_HIDEOUT = 1655, + EVENT_FIGHT_ROUTE12_SNORLAX = 1166, + EVENT_FIGHT_ROUTE16_SNORLAX = 1224, + EVENT_FOLLOWED_OAK_INTO_LAB = 0, + EVENT_FOLLOWED_OAK_INTO_LAB_2 = 32, + EVENT_FOUND_ROCKET_HIDEOUT = 441, + EVENT_GAVE_FOSSIL_TO_LAB = 736, + EVENT_GAVE_GOLD_TEETH = 569, + EVENT_GOT_10_COINS = 442, + EVENT_GOT_20_COINS = 443, + EVENT_GOT_20_COINS_2 = 444, + EVENT_GOT_BICYCLE = 192, + EVENT_GOT_BIKE_VOUCHER = 337, + EVENT_GOT_BULBASAUR_IN_CERULEAN = 168, + EVENT_GOT_COIN_CASE = 480, + EVENT_GOT_DOME_FOSSIL = 1400, + EVENT_GOT_EXP_ALL = 1200, + EVENT_GOT_HELIX_FOSSIL = 1407, + EVENT_GOT_HITMONCHAN = 855, + EVENT_GOT_HITMONLEE = 854, + EVENT_GOT_HM01 = 1504, + EVENT_GOT_HM02 = 1230, + EVENT_GOT_HM03 = 2176, + EVENT_GOT_HM04 = 568, + EVENT_GOT_HM05 = 984, + EVENT_GOT_ITEMFINDER = 1151, + EVENT_GOT_MASTER_BALL = 1933, + EVENT_GOT_NUGGET = 1344, + EVENT_GOT_OAKS_PARCEL = 57, + EVENT_GOT_OLD_AMBER = 105, + EVENT_GOT_POKEBALLS_FROM_OAK = 36, + EVENT_GOT_POKEDEX = 37, + EVENT_GOT_POKE_FLUTE = 296, + EVENT_GOT_POTION_SAMPLE = 960, + EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY = 327, + EVENT_GOT_SS_TICKET = 1372, + EVENT_GOT_STARTER = 34, + EVENT_GOT_TM06 = 600, + EVENT_GOT_TM11 = 190, + EVENT_GOT_TM13 = 396, + EVENT_GOT_TM18 = 399, + EVENT_GOT_TM21 = 424, + EVENT_GOT_TM24 = 358, + EVENT_GOT_TM27 = 80, + EVENT_GOT_TM29 = 944, + EVENT_GOT_TM31 = 832, + EVENT_GOT_TM34 = 118, + EVENT_GOT_TM35 = 727, + EVENT_GOT_TM36 = 1791, + EVENT_GOT_TM38 = 664, + EVENT_GOT_TM39 = 1152, + EVENT_GOT_TM41 = 384, + EVENT_GOT_TM42 = 41, + EVENT_GOT_TM46 = 864, + EVENT_GOT_TM48 = 397, + EVENT_GOT_TM49 = 398, + EVENT_GOT_TOWN_MAP = 24, + EVENT_HALL_OF_FAME_DEX_RATING = 3, + EVENT_INITIAL_CATCH_TRAINING = 47, + EVENT_IN_PURIFIED_ZONE = 263, + EVENT_IN_SAFARI_ZONE = 591, + EVENT_IN_SEAFOAM_ISLANDS = 1280, + EVENT_LAB_HANDING_OVER_FOSSIL_MON = 738, + EVENT_LAB_STILL_REVIVING_FOSSIL = 737, + EVENT_LANCES_ROOM_LOCK_DOOR = 2303, + EVENT_LEFT_BILLS_HOUSE_AFTER_HELPING = 1375, + EVENT_LEFT_FANCLUB_AFTER_BIKE_VOUCHER = 338, + EVENT_MANSION_SWITCH_ON = 632, + EVENT_MET_BILL = 1360, + EVENT_MET_BILL_2 = 1373, + EVENT_NUGGET_REWARD_AVAILABLE = 1353, + EVENT_OAK_APPEARED_IN_PALLET = 39, + EVENT_OAK_ASKED_TO_CHOOSE_MON = 33, + EVENT_OAK_GOT_PARCEL = 56, + EVENT_PALLET_AFTER_GETTING_POKEBALLS = 6, + EVENT_PALLET_AFTER_GETTING_POKEBALLS_2 = 38, + EVENT_PASSED_CASCADEBADGE_CHECK = 1328, + EVENT_PASSED_EARTHBADGE_CHECK = 1334, + EVENT_PASSED_MARSHBADGE_CHECK = 1332, + EVENT_PASSED_RAINBOWBADGE_CHECK = 1330, + EVENT_PASSED_SOULBADGE_CHECK = 1331, + EVENT_PASSED_THUNDERBADGE_CHECK = 1329, + EVENT_PASSED_VOLCANOBADGE_CHECK = 1333, + EVENT_PIKACHU_FAN_BOAST = 343, + EVENT_PLAYER_AT_RIGHT_EXIT_TO_PALLET_TOWN = 5, + EVENT_POKEMONTOWER_7_JESSIE_JAMES_ON_LEFT = 274, + EVENT_POKEMON_TOWER_RIVAL_ON_LEFT = 238, + EVENT_RESCUED_MR_FUJI = 1231, + EVENT_RESCUED_MR_FUJI_2 = 279, + EVENT_ROCKET_DROPPED_LIFT_KEY = 1702, + EVENT_ROCKET_HIDEOUT_4_DOOR_UNLOCKED = 1701, + EVENT_ROCKET_HIDEOUT_4_JESSIE_JAMES_ON_LEFT = 1699, + EVENT_ROUTE22_RIVAL_WANTS_BATTLE = 1319, + EVENT_RUBBED_CAPTAINS_BACK = 1505, + EVENT_SAFARI_GAME_OVER = 590, + EVENT_SEAFOAM1_BOULDER1_DOWN_HOLE = 1294, + EVENT_SEAFOAM1_BOULDER2_DOWN_HOLE = 1295, + EVENT_SEAFOAM2_BOULDER1_DOWN_HOLE = 2496, + EVENT_SEAFOAM2_BOULDER2_DOWN_HOLE = 2497, + EVENT_SEAFOAM3_BOULDER1_DOWN_HOLE = 2504, + EVENT_SEAFOAM3_BOULDER2_DOWN_HOLE = 2505, + EVENT_SEAFOAM4_BOULDER1_DOWN_HOLE = 2512, + EVENT_SEAFOAM4_BOULDER2_DOWN_HOLE = 2513, + EVENT_SEEL_FAN_BOAST = 342, + EVENT_SILPH_CO_10_UNLOCKED_DOOR = 1912, + EVENT_SILPH_CO_11_UNLOCKED_DOOR = 1928, + EVENT_SILPH_CO_2_UNLOCKED_DOOR1 = 1789, + EVENT_SILPH_CO_2_UNLOCKED_DOOR2 = 1790, + EVENT_SILPH_CO_3_UNLOCKED_DOOR1 = 1800, + EVENT_SILPH_CO_3_UNLOCKED_DOOR2 = 1801, + EVENT_SILPH_CO_4_UNLOCKED_DOOR1 = 1816, + EVENT_SILPH_CO_4_UNLOCKED_DOOR2 = 1817, + EVENT_SILPH_CO_5_UNLOCKED_DOOR1 = 1832, + EVENT_SILPH_CO_5_UNLOCKED_DOOR2 = 1833, + EVENT_SILPH_CO_5_UNLOCKED_DOOR3 = 1834, + EVENT_SILPH_CO_6_UNLOCKED_DOOR = 1855, + EVENT_SILPH_CO_7_UNLOCKED_DOOR1 = 1868, + EVENT_SILPH_CO_7_UNLOCKED_DOOR2 = 1869, + EVENT_SILPH_CO_7_UNLOCKED_DOOR3 = 1870, + EVENT_SILPH_CO_8_UNLOCKED_DOOR = 1880, + EVENT_SILPH_CO_9_UNLOCKED_DOOR1 = 1896, + EVENT_SILPH_CO_9_UNLOCKED_DOOR2 = 1897, + EVENT_SILPH_CO_9_UNLOCKED_DOOR3 = 1898, + EVENT_SILPH_CO_9_UNLOCKED_DOOR4 = 1899, + EVENT_SILPH_CO_RECEPTIONIST_AT_DESK = 919, + EVENT_SPAWNED_OLD_MAN_1 = 44, + EVENT_SS_ANNE_LEFT = 1506, + EVENT_STARTED_WALKING_OUT_OF_DOCK = 1508, + EVENT_USED_CELL_SEPARATOR_ON_BILL = 1371, + EVENT_VICTORY_ROAD_1_BOULDER_ON_SWITCH = 2327, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH1 = 1336, + EVENT_VICTORY_ROAD_2_BOULDER_ON_SWITCH2 = 1343, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH1 = 1632, + EVENT_VICTORY_ROAD_3_BOULDER_ON_SWITCH2 = 1638, + EVENT_VIRIDIAN_GYM_OPEN = 40, + EVENT_WALKED_OUT_OF_DOCK = 1509, + EVENT_WALKED_PAST_GUARD_AFTER_SS_ANNE_LEFT = 1507, + }, + count = 2560, + source = "pokeyellow constants/event_constants.asm", +} diff --git a/src/save_convert/data/toggle_objects.lua b/src/save_convert/data/toggle_objects.lua new file mode 100644 index 00000000..639b44ba --- /dev/null +++ b/src/save_convert/data/toggle_objects.lua @@ -0,0 +1,243 @@ +-- wToggleableObjectFlags bit index -> { map id, object_event name, default +-- visible } for the Gen1 save codec (src/save_convert/GenSave.lua). +-- Derived entry by entry from ../pokered/data/maps/toggleable_objects.asm +-- (ToggleableObjectStates: three bytes per entry, blocks laid out in map-id +-- order, so an entry's position in the table IS its wToggleableObjectFlags +-- bit -- constants/toggle_constants.asm numbers the same list), with the +-- default taken from each row's ON/OFF state. Bit set = hidden +-- (engine/overworld/toggleable_objects.asm IsObjectHidden). Object names +-- match data/generated/maps.lua object_event names one for one; the two +-- placeholder bits with no object_event in this port stay as comments so +-- the numbering remains auditable (#763, #857). +return { + byBit = { + [0] = { "PALLET_TOWN", "PALLETTOWN_OAK", false }, + [1] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN_SLEEPY", true }, + [2] = { "VIRIDIAN_CITY", "VIRIDIANCITY_OLD_MAN", false }, + [3] = { "PEWTER_CITY", "PEWTERCITY_SUPER_NERD1", true }, + [4] = { "PEWTER_CITY", "PEWTERCITY_YOUNGSTER", true }, + [5] = { "CERULEAN_CITY", "CERULEANCITY_RIVAL", false }, + [6] = { "CERULEAN_CITY", "CERULEANCITY_ROCKET", true }, + [7] = { "CERULEAN_CITY", "CERULEANCITY_GUARD1", false }, + [8] = { "CERULEAN_CITY", "CERULEANCITY_SUPER_NERD3", true }, + [9] = { "CERULEAN_CITY", "CERULEANCITY_GUARD2", true }, + [10] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET1", true }, + [11] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET2", true }, + [12] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET3", true }, + [13] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET4", true }, + [14] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET5", true }, + [15] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET6", true }, + [16] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET7", true }, + [17] = { "SAFFRON_CITY", "SAFFRONCITY_SCIENTIST", false }, + [18] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_M", false }, + [19] = { "SAFFRON_CITY", "SAFFRONCITY_SILPH_WORKER_F", false }, + [20] = { "SAFFRON_CITY", "SAFFRONCITY_GENTLEMAN", false }, + [21] = { "SAFFRON_CITY", "SAFFRONCITY_PIDGEOT", false }, + [22] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKER", false }, + [23] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET8", true }, + [24] = { "SAFFRON_CITY", "SAFFRONCITY_ROCKET9", false }, + [25] = { "ROUTE_2", "ROUTE2_MOON_STONE", true }, + [26] = { "ROUTE_2", "ROUTE2_HP_UP", true }, + [27] = { "ROUTE_4", "ROUTE4_TM_WHIRLWIND", true }, + [28] = { "ROUTE_9", "ROUTE9_TM_TELEPORT", true }, + [29] = { "ROUTE_12", "ROUTE12_SNORLAX", true }, + [30] = { "ROUTE_12", "ROUTE12_TM_PAY_DAY", true }, + [31] = { "ROUTE_12", "ROUTE12_IRON", true }, + [32] = { "ROUTE_15", "ROUTE15_TM_RAGE", true }, + [33] = { "ROUTE_16", "ROUTE16_SNORLAX", true }, + [34] = { "ROUTE_22", "ROUTE22_RIVAL1", false }, + [35] = { "ROUTE_22", "ROUTE22_RIVAL2", false }, + [36] = { "ROUTE_24", "ROUTE24_COOLTRAINER_M1", true }, + [37] = { "ROUTE_24", "ROUTE24_TM_THUNDER_WAVE", true }, + [38] = { "ROUTE_25", "ROUTE25_TM_SEISMIC_TOSS", true }, + [39] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY1", true }, + [40] = { "BLUES_HOUSE", "BLUESHOUSE_DAISY2", false }, + [41] = { "BLUES_HOUSE", "BLUESHOUSE_TOWN_MAP", true }, + [42] = { "OAKS_LAB", "OAKSLAB_RIVAL", true }, + [43] = { "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL", true }, + [44] = { "OAKS_LAB", "OAKSLAB_SQUIRTLE_POKE_BALL", true }, + [45] = { "OAKS_LAB", "OAKSLAB_BULBASAUR_POKE_BALL", true }, + [46] = { "OAKS_LAB", "OAKSLAB_OAK1", false }, + [47] = { "OAKS_LAB", "OAKSLAB_POKEDEX1", true }, + [48] = { "OAKS_LAB", "OAKSLAB_POKEDEX2", true }, + [49] = { "OAKS_LAB", "OAKSLAB_OAK2", false }, + [50] = { "VIRIDIAN_GYM", "VIRIDIANGYM_GIOVANNI", true }, + [51] = { "VIRIDIAN_GYM", "VIRIDIANGYM_REVIVE", true }, + [52] = { "MUSEUM_1F", "MUSEUM1F_OLD_AMBER", true }, + [53] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_FULL_RESTORE", true }, + [54] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_MAX_ELIXER", true }, + [55] = { "CERULEAN_CAVE_1F", "CERULEANCAVE1F_NUGGET", true }, + [56] = { "POKEMON_TOWER_2F", "POKEMONTOWER2F_RIVAL", true }, + [57] = { "POKEMON_TOWER_3F", "POKEMONTOWER3F_ESCAPE_ROPE", true }, + [58] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_ELIXER", true }, + [59] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_AWAKENING", true }, + [60] = { "POKEMON_TOWER_4F", "POKEMONTOWER4F_HP_UP", true }, + [61] = { "POKEMON_TOWER_5F", "POKEMONTOWER5F_NUGGET", true }, + [62] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_RARE_CANDY", true }, + [63] = { "POKEMON_TOWER_6F", "POKEMONTOWER6F_X_ACCURACY", true }, + [64] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET1", true }, + [65] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET2", true }, + [66] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_ROCKET3", true }, + [67] = { "POKEMON_TOWER_7F", "POKEMONTOWER7F_MR_FUJI", true }, + [68] = { "MR_FUJIS_HOUSE", "MRFUJISHOUSE_MR_FUJI", false }, + [69] = { "CELADON_MANSION_ROOF_HOUSE", "CELADONMANSION_ROOF_HOUSE_EEVEE_POKEBALL", true }, + [70] = { "GAME_CORNER", "GAMECORNER_ROCKET", true }, + [71] = { "WARDENS_HOUSE", "WARDENSHOUSE_RARE_CANDY", true }, + [72] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_ESCAPE_ROPE", true }, + [73] = { "POKEMON_MANSION_1F", "POKEMONMANSION1F_CARBOS", true }, + [74] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONLEE_POKE_BALL", true }, + [75] = { "FIGHTING_DOJO", "FIGHTINGDOJO_HITMONCHAN_POKE_BALL", true }, + [76] = { "SILPH_CO_1F", "SILPHCO1F_LINK_RECEPTIONIST", false }, + [77] = { "POWER_PLANT", "POWERPLANT_VOLTORB1", true }, + [78] = { "POWER_PLANT", "POWERPLANT_VOLTORB2", true }, + [79] = { "POWER_PLANT", "POWERPLANT_VOLTORB3", true }, + [80] = { "POWER_PLANT", "POWERPLANT_ELECTRODE1", true }, + [81] = { "POWER_PLANT", "POWERPLANT_VOLTORB4", true }, + [82] = { "POWER_PLANT", "POWERPLANT_VOLTORB5", true }, + [83] = { "POWER_PLANT", "POWERPLANT_ELECTRODE2", true }, + [84] = { "POWER_PLANT", "POWERPLANT_VOLTORB6", true }, + [85] = { "POWER_PLANT", "POWERPLANT_ZAPDOS", true }, + [86] = { "POWER_PLANT", "POWERPLANT_CARBOS", true }, + [87] = { "POWER_PLANT", "POWERPLANT_HP_UP", true }, + [88] = { "POWER_PLANT", "POWERPLANT_RARE_CANDY", true }, + [89] = { "POWER_PLANT", "POWERPLANT_TM_THUNDER", true }, + [90] = { "POWER_PLANT", "POWERPLANT_TM_REFLECT", true }, + [91] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_MOLTRES", true }, + [92] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_SUBMISSION", true }, + [93] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_FULL_HEAL", true }, + [94] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_TM_MEGA_KICK", true }, + [95] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_GUARD_SPEC", true }, + [96] = { "VICTORY_ROAD_2F", "VICTORYROAD2F_BOULDER3", true }, + [97] = { "BILLS_HOUSE", "BILLSHOUSE_BILL_POKEMON", true }, + [98] = { "BILLS_HOUSE", "BILLSHOUSE_BILL1", false }, + [99] = { "BILLS_HOUSE", "BILLSHOUSE_BILL2", false }, + [100] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_ANTIDOTE", true }, + [101] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POTION", true }, + [102] = { "VIRIDIAN_FOREST", "VIRIDIANFOREST_POKE_BALL", true }, + [103] = { "MT_MOON_1F", "MTMOON1F_POTION1", true }, + [104] = { "MT_MOON_1F", "MTMOON1F_MOON_STONE", true }, + [105] = { "MT_MOON_1F", "MTMOON1F_RARE_CANDY", true }, + [106] = { "MT_MOON_1F", "MTMOON1F_ESCAPE_ROPE", true }, + [107] = { "MT_MOON_1F", "MTMOON1F_POTION2", true }, + [108] = { "MT_MOON_1F", "MTMOON1F_TM_WATER_GUN", true }, + [109] = { "MT_MOON_B2F", "MTMOONB2F_DOME_FOSSIL", true }, + [110] = { "MT_MOON_B2F", "MTMOONB2F_HELIX_FOSSIL", true }, + [111] = { "MT_MOON_B2F", "MTMOONB2F_HP_UP", true }, + [112] = { "MT_MOON_B2F", "MTMOONB2F_TM_MEGA_PUNCH", true }, + [113] = { "SS_ANNE_2F", "SSANNE2F_RIVAL", false }, + [114] = { "SS_ANNE_1F_ROOMS", "SSANNE1FROOMS_TM_BODY_SLAM", true }, + [115] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_MAX_ETHER", true }, + [116] = { "SS_ANNE_2F_ROOMS", "SSANNE2FROOMS_RARE_CANDY", true }, + [117] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_ETHER", true }, + [118] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_TM_REST", true }, + [119] = { "SS_ANNE_B1F_ROOMS", "SSANNEB1FROOMS_MAX_POTION", true }, + [120] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_MAX_REVIVE", true }, + [121] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_TM_EXPLOSION", true }, + [122] = { "VICTORY_ROAD_3F", "VICTORYROAD3F_BOULDER4", true }, + [123] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_ESCAPE_ROPE", true }, + [124] = { "ROCKET_HIDEOUT_B1F", "ROCKETHIDEOUTB1F_HYPER_POTION", true }, + [125] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_MOON_STONE", true }, + [126] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_NUGGET", true }, + [127] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_TM_HORN_DRILL", true }, + [128] = { "ROCKET_HIDEOUT_B2F", "ROCKETHIDEOUTB2F_SUPER_POTION", true }, + [129] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_TM_DOUBLE_EDGE", true }, + [130] = { "ROCKET_HIDEOUT_B3F", "ROCKETHIDEOUTB3F_RARE_CANDY", true }, + [131] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_GIOVANNI", true }, + [132] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_HP_UP", true }, + [133] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_TM_RAZOR_WIND", true }, + [134] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_IRON", true }, + [135] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_SILPH_SCOPE", false }, + [136] = { "ROCKET_HIDEOUT_B4F", "ROCKETHIDEOUTB4F_LIFT_KEY", false }, + [137] = { "SILPH_CO_2F", "SILPHCO2F_SILPH_WORKER_F", true }, + [138] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST1", true }, + [139] = { "SILPH_CO_2F", "SILPHCO2F_SCIENTIST2", true }, + [140] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET1", true }, + [141] = { "SILPH_CO_2F", "SILPHCO2F_ROCKET2", true }, + [142] = { "SILPH_CO_3F", "SILPHCO3F_ROCKET", true }, + [143] = { "SILPH_CO_3F", "SILPHCO3F_SCIENTIST", true }, + [144] = { "SILPH_CO_3F", "SILPHCO3F_HYPER_POTION", true }, + [145] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET1", true }, + [146] = { "SILPH_CO_4F", "SILPHCO4F_SCIENTIST", true }, + [147] = { "SILPH_CO_4F", "SILPHCO4F_ROCKET2", true }, + [148] = { "SILPH_CO_4F", "SILPHCO4F_FULL_HEAL", true }, + [149] = { "SILPH_CO_4F", "SILPHCO4F_MAX_REVIVE", true }, + [150] = { "SILPH_CO_4F", "SILPHCO4F_ESCAPE_ROPE", true }, + [151] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET1", true }, + [152] = { "SILPH_CO_5F", "SILPHCO5F_SCIENTIST", true }, + [153] = { "SILPH_CO_5F", "SILPHCO5F_ROCKER", true }, + [154] = { "SILPH_CO_5F", "SILPHCO5F_ROCKET2", true }, + [155] = { "SILPH_CO_5F", "SILPHCO5F_TM_TAKE_DOWN", true }, + [156] = { "SILPH_CO_5F", "SILPHCO5F_PROTEIN", true }, + [157] = { "SILPH_CO_5F", "SILPHCO5F_CARD_KEY", true }, + [158] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET1", true }, + [159] = { "SILPH_CO_6F", "SILPHCO6F_SCIENTIST", true }, + [160] = { "SILPH_CO_6F", "SILPHCO6F_ROCKET2", true }, + [161] = { "SILPH_CO_6F", "SILPHCO6F_HP_UP", true }, + [162] = { "SILPH_CO_6F", "SILPHCO6F_X_ACCURACY", true }, + [163] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET1", true }, + [164] = { "SILPH_CO_7F", "SILPHCO7F_SCIENTIST", true }, + [165] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET2", true }, + [166] = { "SILPH_CO_7F", "SILPHCO7F_ROCKET3", true }, + [167] = { "SILPH_CO_7F", "SILPHCO7F_RIVAL", true }, + [168] = { "SILPH_CO_7F", "SILPHCO7F_CALCIUM", true }, + [169] = { "SILPH_CO_7F", "SILPHCO7F_TM_SWORDS_DANCE", true }, + -- [170] SILPH_CO_7F (SILPHCO7F_UNUSED): placeholder entry, no object_event in this port + [171] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET1", true }, + [172] = { "SILPH_CO_8F", "SILPHCO8F_SCIENTIST", true }, + [173] = { "SILPH_CO_8F", "SILPHCO8F_ROCKET2", true }, + [174] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET1", true }, + [175] = { "SILPH_CO_9F", "SILPHCO9F_SCIENTIST", true }, + [176] = { "SILPH_CO_9F", "SILPHCO9F_ROCKET2", true }, + [177] = { "SILPH_CO_10F", "SILPHCO10F_ROCKET", true }, + [178] = { "SILPH_CO_10F", "SILPHCO10F_SCIENTIST", true }, + [179] = { "SILPH_CO_10F", "SILPHCO10F_SILPH_WORKER_F", true }, + [180] = { "SILPH_CO_10F", "SILPHCO10F_TM_EARTHQUAKE", true }, + [181] = { "SILPH_CO_10F", "SILPHCO10F_RARE_CANDY", true }, + [182] = { "SILPH_CO_10F", "SILPHCO10F_CARBOS", true }, + [183] = { "SILPH_CO_11F", "SILPHCO11F_GIOVANNI", true }, + [184] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET1", true }, + [185] = { "SILPH_CO_11F", "SILPHCO11F_ROCKET2", true }, + -- [186] UNUSED_MAP_F4 ($02): placeholder entry, no object_event in this port + [187] = { "POKEMON_MANSION_2F", "POKEMONMANSION2F_CALCIUM", true }, + [188] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_MAX_POTION", true }, + [189] = { "POKEMON_MANSION_3F", "POKEMONMANSION3F_IRON", true }, + [190] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_RARE_CANDY", true }, + [191] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_FULL_RESTORE", true }, + [192] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_BLIZZARD", true }, + [193] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_TM_SOLARBEAM", true }, + [194] = { "POKEMON_MANSION_B1F", "POKEMONMANSIONB1F_SECRET_KEY", true }, + [195] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_FULL_RESTORE", true }, + [196] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_MAX_RESTORE", true }, + [197] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_CARBOS", true }, + [198] = { "SAFARI_ZONE_EAST", "SAFARIZONEEAST_TM_EGG_BOMB", true }, + [199] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_PROTEIN", true }, + [200] = { "SAFARI_ZONE_NORTH", "SAFARIZONENORTH_TM_SKULL_BASH", true }, + [201] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_POTION", true }, + [202] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_TM_DOUBLE_TEAM", true }, + [203] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_MAX_REVIVE", true }, + [204] = { "SAFARI_ZONE_WEST", "SAFARIZONEWEST_GOLD_TEETH", true }, + [205] = { "SAFARI_ZONE_CENTER", "SAFARIZONECENTER_NUGGET", true }, + [206] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_PP_UP", true }, + [207] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_ULTRA_BALL", true }, + [208] = { "CERULEAN_CAVE_2F", "CERULEANCAVE2F_FULL_RESTORE", true }, + [209] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MEWTWO", true }, + [210] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_ULTRA_BALL", true }, + [211] = { "CERULEAN_CAVE_B1F", "CERULEANCAVEB1F_MAX_REVIVE", true }, + [212] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_TM_SKY_ATTACK", true }, + [213] = { "VICTORY_ROAD_1F", "VICTORYROAD1F_RARE_CANDY", true }, + [214] = { "CHAMPIONS_ROOM", "CHAMPIONSROOM_OAK", false }, + [215] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER1", true }, + [216] = { "SEAFOAM_ISLANDS_1F", "SEAFOAMISLANDS1F_BOULDER2", true }, + [217] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER1", false }, + [218] = { "SEAFOAM_ISLANDS_B1F", "SEAFOAMISLANDSB1F_BOULDER2", false }, + [219] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER1", false }, + [220] = { "SEAFOAM_ISLANDS_B2F", "SEAFOAMISLANDSB2F_BOULDER2", false }, + [221] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER2", true }, + [222] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER3", true }, + [223] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER5", false }, + [224] = { "SEAFOAM_ISLANDS_B3F", "SEAFOAMISLANDSB3F_BOULDER6", false }, + [225] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER1", false }, + [226] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_BOULDER2", false }, + [227] = { "SEAFOAM_ISLANDS_B4F", "SEAFOAMISLANDSB4F_ARTICUNO", true }, + }, +} diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 9f293f42..f7d3ffe5 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -68,7 +68,14 @@ function TitleState:sgbPalettes(game) local top = game.stack and game.stack:top() local box = top and top.titleUiBox if box then - z[#z + 1] = P.trueColorZone(box[1], box[2], box[3], box[4]) + -- A DMG-grays zone, not the trueColor opt-out: through the shade-remap + -- shader GRAYS is the identity for the box's four shades, so SGB / + -- ADVANCED / OG modes keep #133's white paper and black ink exactly, + -- while effectiveColors still substitutes the mono and inverted display + -- modes -- a trueColor rect skipped the shader entirely, leaving the + -- main menu and CONTINUE info box a raw white hole over a CLASSIC + -- pea-green title instead of matching it like the START menu does (#870). + z[#z + 1] = P.zone(P.GRAYS, box[1], box[2], box[3], box[4]) end return z[3] and z or nil end diff --git a/src/update/check_worker.lua b/src/update/check_worker.lua index 24ed03a7..32388b82 100644 --- a/src/update/check_worker.lua +++ b/src/update/check_worker.lua @@ -234,6 +234,11 @@ local function launchDownload(url, partAbs, doneAbs) local batRel = "updates/dl.bat" love.filesystem.write(batRel, "@echo off\r\n" + -- start /b hands the child our cwd, the install folder, and the + -- detached cmd.exe held that folder un-movable for the rest of the + -- transfer after the game exited (#727). Every path below is + -- absolute, so park the child in its own directory (the save dir). + .. "cd /d \"%~dp0\"\r\n" .. "curl -fsSL --connect-timeout 15 --max-time 900 -o \"" .. partAbs .. "\" \"" .. url .. "\"\r\n" .. "type nul > \"" .. doneAbs .. "\"\r\n") @@ -271,6 +276,14 @@ local function doDownload() -- stalled or run-away transfer breaks out and lets verification fail cleanly local waited, lastSize, lastChange = 0, -1, 0 while true do + -- A queued quit means the window already closed. Bail so the join in + -- Check.shutdown does not hold the dead window's process (and, on + -- Windows, its folder) open for up to the whole transfer (#727). The + -- quit stays on the channel for the command loop; the detached curl + -- times out on its own and the next launch's doCheck verifies and + -- re-offers whatever landed. + local peeked = cmdCh:peek() + if type(peeked) == "table" and peeked.cmd == "quit" then return end if love.filesystem.getInfo(doneRel) then break end local pinfo = love.filesystem.getInfo(partRel) local cur = (pinfo and pinfo.size) or 0 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index ff56ae22..d6fcdb56 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -440,8 +440,10 @@ function OverworldState:setMap(mapId, x, y, facing, opts) self.entities = { self.player } for _, n in ipairs(self.npcs) do table.insert(self.entities, n) end -- Yellow's companion Pikachu trails the player (never in - -- self.entities: it does not block movement, pikachu_follow.asm) - require("src.world.PikachuFollower").onMapEntered(Game, self, opts) + -- self.entities: it does not block movement, pikachu_follow.asm). + -- true = fresh map entry: the follower spawns under the player and + -- walks out of the warp, not beside him (#863) + require("src.world.PikachuFollower").onMapEntered(Game, self, opts, true) -- opts.keepMusic: the Oak-escort warp keeps MUSIC_MEET_PROF_OAK -- playing into the lab (BIT_NO_MAP_MUSIC in wStatusFlags7); @@ -1925,7 +1927,14 @@ function OverworldState:tryHiddenObject(fx, fy) save.hiddenTaken = save.hiddenTaken or {} if save.hiddenTaken[key] then return false end if not require("src.inventory.Bag").add(save, h.item, 1, Game.data) then - Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!"))) + -- hidden_items.asm FoundHiddenItemText: the find is announced first, + -- then GiveItem's .bagFull branch prints _HiddenItemBagFullText and + -- leaves the spot unfound; _CantCarryMoreText is the Toss line (#872) + local name = Game.data.items[h.item] and Game.data.items[h.item].name or h.item + Game.stack:push(TextBox.new(Game, + Strings("%s found\n%s!", save.player.name, name) .. "\f" + .. romText(Game.data, "_HiddenItemBagFullText", + "But, {PLAYER} has\nno more room for\vother items!"))) return true end save.hiddenTaken[key] = true @@ -2570,7 +2579,17 @@ function OverworldState:talkTo(npc) -- the string "0" as truthy, so screen it out and fall through to text. if d.item and d.item ~= "0" and d.item ~= 0 then if not require("src.inventory.Bag").add(Game.save, d.item, 1, Game.data) then - Game.stack:push(TextBox.new(Game, romText(Game.data, "_CantCarryMoreText", "You can't carry\nany more items!"))) + -- pick_up_item.asm .BagFull prints _NoMoreRoomForItemText, not the + -- Toss-screen _CantCarryMoreText; Yellow announces the find first, + -- then the refusal (#872) + local noRoom = romText(Game.data, "_NoMoreRoomForItemText", + "No more room for\nitems!") + if GameVersion.isYellow() then + local name = Game.data.items[d.item] and Game.data.items[d.item].name or d.item + noRoom = Strings("%s found\n%s!", Game.save.player.name, name) + .. "\f" .. noRoom + end + Game.stack:push(TextBox.new(Game, noRoom)) return end Game.save.itemsTaken = Game.save.itemsTaken or {} @@ -2984,7 +3003,12 @@ local function meetTrainerTheme(cls) end -- Run the pre-battle text -> battle -> won text -> flags sequence. -function OverworldState:engageTrainer(npc, onDone, endBattleText) +-- skipBattleText is for map scripts shaped like SilphCo11FDefaultScript +-- (scripts/SilphCo11F.asm), which DisplayTextID the challenge line BEFORE +-- the approach walk and then EngageMapTrainer with no further text: the +-- caller already showed the box, so the battle starts without a second +-- one (#869). +function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText) local d = npc.def Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass, partyIndex = d.trainerParty }) @@ -3005,7 +3029,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText) or (header and header.won and Game.data.text[header.won]) local BattleState = require("src.battle.BattleState") - Game.stack:push(TextBox.new(Game, battleText, function() + local function startBattle() -- TalkToTrainer (home/trainers.asm:88) prints the before-battle text -- FIRST and only then runs `call EngageMapTrainer` / `jp -- StartTrainerBattle`, so a trainer challenged on foot gets the sting @@ -3048,7 +3072,12 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText) end end self:pushBattle(battle) - end)) + end + if skipBattleText then + startBattle() + else + Game.stack:push(TextBox.new(Game, battleText, startBattle)) + end end -- Shared GiveItem step for the victory rewards (pokered home/give.asm): @@ -4470,7 +4499,13 @@ function OverworldState:drawWorld() -- ghost NPCs on neighbor maps, y-sorted among themselves table.sort(self.ghosts, function(a, b) return a.npc.py + a.oy < b.npc.py + b.oy end) - table.sort(self.entities, function(a, b) return a.py < b.py end) + table.sort(self.entities, function(a, b) + if a.py ~= b.py then return a.py < b.py end + -- a fresh warp spawn parks the follower on the player's own cell + -- until it trails out; the tie must draw it under him, never on + -- top (#863) + return a.pikachuFollower == true and b.pikachuFollower ~= true + end) -- === shared FX draw bodies ========================================== -- Each draws at flat world-canvas offsets; the tilt path wraps the diff --git a/src/world/PikachuFollower.lua b/src/world/PikachuFollower.lua index 708f386b..b7c517f7 100644 --- a/src/world/PikachuFollower.lua +++ b/src/world/PikachuFollower.lua @@ -188,7 +188,7 @@ function PikachuFollower.current(ow) return npc end -function PikachuFollower.onMapEntered(game, ow, opts) +function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad) -- Bill's House owns a short scripted scene that deliberately keeps -- Pikachu off the normal trailing loop. A new map instance ends it. ow.pikachuBillsScene = nil @@ -200,7 +200,8 @@ function PikachuFollower.onMapEntered(game, ow, opts) -- takes .normal_spawn_state -- map coords rebased, sprite data and -- follow command buffer left alone. Re-list the same instance and let -- rebase() shift its cell; a warp arrives without it and respawns - -- behind the player, the full spawn path of that same routine. + -- under the player, the full spawn path of that same routine (the + -- viaMapLoad spawn below, #863). local keep = opts and opts.keepPikachu if keep then table.insert(ow.npcs, keep) @@ -208,6 +209,12 @@ function PikachuFollower.onMapEntered(game, ow, opts) return end local x, y = spawnCell(ow) + -- a fresh map entry (warp, boot) parks the follower ON the player's + -- cell instead: it stays hidden under him (the draw-sort tie-break in + -- OverworldController) and walks out of the warp behind him as the + -- trail opens up. Mid-map respawns (bike dismount, revive) keep the + -- behind-the-facing cell (#863) + if viaMapLoad then x, y = ow.player.cellX, ow.player.cellY end local npc = makeFollower(game, ow, x, y, ow.player.facing) table.insert(ow.npcs, npc) -- entities is the draw list; passable keeps it out of collision diff --git a/tests/drivers/bag_full_pickup_bug872_test.lua b/tests/drivers/bag_full_pickup_bug872_test.lua new file mode 100644 index 00000000..2d04abbd --- /dev/null +++ b/tests/drivers/bag_full_pickup_bug872_test.lua @@ -0,0 +1,222 @@ +-- Manual check of both bag-full pickup refusals (#872): an item ball must +-- refuse with _NoMoreRoomForItemText (pokered scripts/pick_up_item.asm +-- .BagFull), never the Toss-screen _CantCarryMoreText, and a hidden item +-- announces the find first, then _HiddenItemBagFullText (hidden_items.asm). +-- Run without POKEPORT_SPEED -- the box paging under test is timing-honest. +-- POKEPORT_DRIVER=tests/drivers/bag_full_pickup_bug872_test.lua POKEPORT_IDENTITY=bug872 POKEPORT_TOUCH=0 love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local TextBox = require("src.render.TextBox") + local Bag = require("src.inventory.Bag") + local GameVersion = require("src.core.GameVersion") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local MAP = "VIRIDIAN_FOREST" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- neither refusal plays a jingle, but the human will want the normal + -- pickup sound as a contrast, so warn when it would be muted + local opts = game.save.options or {} + if (opts.sfxVol or 7) == 0 then + U.log("note: sfxVol is 0, so a successful pickup afterwards will be", + "silent; the refusal boxes themselves are unaffected") + end + + -- a real fresh game, so the save has a player name and clean flags + U.newGame(game) + local save = game.save + + -- Empty the bag, then refill to exactly capacity with ids that are NOT + -- POTION: Bag.add succeeds by quantity for an id already in a slot + -- (src/inventory/Bag.lua), which would mask the bug, and both test + -- targets below hand out POTION. Badges share save.inventory but are + -- not bag slots, so they are left alone. + for _, id in ipairs({ unpack(Bag.order(save)) }) do + Bag.remove(save, id, save.inventory[id] or 1) + end + local ids = {} + for id in pairs(game.data.items) do + if not Bag.isBadge(id) and id ~= "POTION" then ids[#ids + 1] = id end + end + table.sort(ids) + for _, id in ipairs(ids) do + if Bag.slots(save) >= Bag.capacity(game.data) then break end + Bag.add(save, id, 1, game.data) + end + check(("bag is full (%d/%d slots) and holds no POTION") + :format(Bag.slots(save), Bag.capacity(game.data)), + Bag.slots(save) >= Bag.capacity(game.data) + and not save.inventory.POTION) + + -- first target: the Potion item ball. pokered + -- data/maps/objects/ViridianForest.asm:36 puts it at walk cell (12, 29) + -- (object_event 12, 29, SPRITE_POKE_BALL ... POTION), free floor below. + local BALL = { x = 12, y = 29 } + U.teleport(game, MAP, BALL.x, BALL.y + 1, "left") + U.wait(10) + + local function isBall(n) + return n and n.def and n.def.item and n.def.item ~= "0" and n.def.item ~= 0 + end + local ow = game.overworld + local ball = ow:npcAtCell(BALL.x, BALL.y) + if not isBall(ball) then + -- a map edit or mod moved the object: take any item ball on the map + -- and stand on a free walkable neighbour instead + ball = nil + for _, n in ipairs(ow.npcs or {}) do + if isBall(n) then ball = n break end + end + if ball then + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = ball.cellX + s[1], ball.cellY + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("ball not at (%d, %d); using the one at (%d, %d)") + :format(BALL.x, BALL.y, ball.cellX, ball.cellY)) + -- teleport facing away, the tap below still does the turn + U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left") + U.wait(10) + BALL.x, BALL.y = ball.cellX, ball.cellY + break + end + end + end + end + check("an item ball is loaded on " .. MAP, ball ~= nil) + local ballItem = ball and ball.def.item or "POTION" + + -- turn toward the ball ourselves (a one-frame press only turns when the + -- player faces elsewhere, src/world/Player.lua), then trigger the talk + ow = game.overworld + local dx, dy = BALL.x - ow.player.cellX, BALL.y - ow.player.cellY + local dir = (dy < 0 and "up") or (dy > 0 and "down") + or (dx < 0 and "left") or "right" + U.tap(game, dir) + U.wait(10) + local fx, fy = game.overworld.player:facingCell() + check("player turned to face the ball", + game.overworld:npcAtCell(fx, fy) == ball) + U.tap(game, "a") + U.wait(30) + + local function readPages() + local top = game.stack:top() + if getmetatable(top) ~= TextBox then return nil end + local pages = {} + for _, page in ipairs(top.pages or {}) do + pages[#pages + 1] = table.concat(page, " / ") + end + return pages + end + local function closeBox() + for _ = 1, 8 do + if getmetatable(game.stack:top()) ~= TextBox then break end + U.tap(game, "a") + U.wait(25) + end + end + + local pages = readPages() + check("A on the full-bag ball opened a text box", pages ~= nil) + if pages then + local all = table.concat(pages, " || ") + U.log("ball refusal reads:", all) + check("it is the pickup refusal, not the Toss line", + all:find("No more room", 1, true) ~= nil + and all:find("can't carry", 1, true) == nil) + if GameVersion.isYellow() then + check("Yellow announces the find, then refuses on page 2", + #pages == 2 + and pages[1]:find(ballItem, 1, true) ~= nil + and pages[2]:find("No more room", 1, true) ~= nil) + else + check("Red/Blue refuse in one page with no found line", + #pages == 1 and pages[1]:find("found", 1, true) == nil) + end + U.shot(game, SHOT_DIR .. "/bug872_ball_refusal.png") + end + closeBox() + + -- the refusal must leave the world untouched so the pickup can be + -- retried after tossing something + check("the ball is still standing there", + game.overworld:npcAtCell(BALL.x, BALL.y) == ball) + check("itemsTaken was not marked", + not (save.itemsTaken and ball and save.itemsTaken[ball.id])) + check("the item stayed out of the bag", not save.inventory[ballItem]) + + -- second target: the hidden POTION. pokered + -- data/events/hidden_item_coords.asm:8 puts it at (x=1, y=18) on + -- VIRIDIAN_FOREST; Game.data.field.hiddenItems carries the same spot. + local hidden + local list = (game.data.field.hiddenItems or {})[MAP] or {} + for _, h in ipairs(list) do + if h.x == 1 and h.y == 18 then hidden = h break end + end + hidden = hidden or list[1] + check("a hidden item exists on " .. MAP, hidden ~= nil) + + local stood = false + if hidden then + -- {dx, dy, facing} from the hidden cell to a stand cell that looks + -- back at it; the spot itself is usually an unwalkable tree tile + local sides = { + { 0, 1, "up" }, { 0, -1, "down" }, { 1, 0, "left" }, { -1, 0, "right" }, + } + for _, s in ipairs(sides) do + local cx, cy = hidden.x + s[1], hidden.y + s[2] + local m = game.overworld.map + if m:isWalkableCell(cx, cy) and not game.overworld:npcAtCell(cx, cy) then + U.teleport(game, MAP, cx, cy, s[3] == "left" and "right" or "left") + U.wait(10) + U.tap(game, s[3]) + U.wait(10) + local hfx, hfy = game.overworld.player:facingCell() + if hfx == hidden.x and hfy == hidden.y then stood = true break end + end + end + end + check("standing against the hidden spot", stood) + + U.tap(game, "a") + U.wait(30) + pages = readPages() + check("A on the full-bag hidden spot opened a text box", pages ~= nil) + if pages then + local all = table.concat(pages, " || ") + U.log("hidden refusal reads:", all) + check("the found line comes first", + #pages == 2 and pages[1]:find("found", 1, true) ~= nil + and (not hidden or pages[1]:find( + (game.data.items[hidden.item] or {}).name or hidden.item, + 1, true) ~= nil)) + check("then the hidden-item bag-full line, not the Toss line", + #pages == 2 + and pages[2]:find("no more room", 1, true) ~= nil + and pages[2]:find("other items", 1, true) ~= nil + and all:find("can't carry", 1, true) == nil) + U.shot(game, SHOT_DIR .. "/bug872_hidden_refusal.png") + end + closeBox() + + local key = hidden and (MAP .. "_" .. hidden.x .. "_" .. hidden.y) + check("the hidden spot can still be prompted again", + not (key and save.hiddenTaken and save.hiddenTaken[key])) + check("the hidden item stayed out of the bag", + not (hidden and save.inventory[hidden.item])) + + U.log("You are still facing the hidden spot with a full bag; pressing A") + U.log("should say the item was found, then that there is no more room for") + U.log("other items, and never the bag screen's \"can't carry\" wording.") + U.log("Toss something and both spots should hand their item over normally.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/faithful_res_mobile_veil_bug864_test.lua b/tests/drivers/faithful_res_mobile_veil_bug864_test.lua new file mode 100644 index 00000000..d176de08 --- /dev/null +++ b/tests/drivers/faithful_res_mobile_veil_bug864_test.lua @@ -0,0 +1,296 @@ +-- Eye check: FAITHFUL RATIO's mobile scale lock keeps the display outside the +-- locked 160x144 viewport black through the pre-battle flash (pokered +-- BattleTransition_FlashScreen_, engine/battle/battle_transitions.asm), the +-- post-battle fade (GBFadeInFromWhite, home/fade.asm) and Oak speech (#864). +-- POKEPORT_DRIVER=tests/drivers/faithful_res_mobile_veil_bug864_test.lua POKEPORT_FORCE_MOBILE=1 POKEPORT_IDENTITY=bug864 POKEPORT_TOUCH=0 POKEPORT_VERSION=red SHOT_DIR=/tmp/shots love . +-- No POKEPORT_SPEED anywhere: the flash and the fade ARE the frames under test. +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Renderer = require("src.render.Renderer") + local FaithfulRes = require("src.core.FaithfulRes") + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- FaithfulRes.isMobile reads the env per call, so the desktop build only + -- takes the scale-cap branch when the launcher command set it. Without it + -- every check below tests the ordinary letterbox and proves nothing. + if not check("POKEPORT_FORCE_MOBILE=1 is set (the branch under test)", + os.getenv("POKEPORT_FORCE_MOBILE") == "1") then + U.log("Re-run with POKEPORT_FORCE_MOBILE=1; nothing below is meaningful.") + while true do coroutine.yield() end + end + + -- A phone-shaped window, so the locked viewport (160x144 at the largest + -- whole multiple, here 3x = 480x432) leaves tall bars above and below -- + -- the "dead display" FaithfulRes.lua's contract says must stay black. + -- 480x960 keeps the width an exact 3x so the bars are purely vertical. + if love.window and love.window.setMode then + love.window.setMode(480, 960, { resizable = true, + minwidth = FaithfulRes.MIN_W, + minheight = FaithfulRes.MIN_H }) + end + U.wait(3) + + -- New Game replaces game.save (and Game:applyOptions re-applies its fresh + -- options, which releases the lock), so re-arm before every shot rather + -- than trusting one application to survive the whole run. + local function lock() + game.save.options = game.save.options or {} + game.save.options.faithfulRes = 1 + game.save.options.battleBg = "black" + FaithfulRes.applyOptions(game.save.options) + return FaithfulRes.scaleCap() + end + check("the mobile scale lock engaged (FaithfulRes.scaleCap ~= nil)", + lock() ~= nil) + + local opts = game.save.options + if (opts.musicVol or 0) == 0 or (opts.sfxVol or 0) == 0 then + U.log("WARN music/sfx volume is zero; the flash's battle theme will be silent") + end + + -- Load a captured PNG back as ImageData; love.image cannot read absolute + -- paths, so go through io.open + newFileData. + local function loadShot(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + return ok and img or nil + end + + local function regionMean(img, x, y, w, h) + local sum, n = 0, 0 + local x2 = math.min(x + w, img:getWidth()) - 1 + local y2 = math.min(y + h, img:getHeight()) - 1 + for yy = math.max(y, 0), y2 do + for xx = math.max(x, 0), x2 do + local r, g, b = img:getPixel(xx, yy) + sum = sum + (r + g + b) / 3 + n = n + 1 + end + end + return n > 0 and sum / n or 0, n + end + + -- The locked viewport in framebuffer pixels: the same uiSize * fitScale + -- centring endFrame uses for ox/oy/vpw/vph, which is the rectangle the + -- veil is clamped to under the lock. Screenshots are framebuffer-sized, + -- so no dpi divide. + local function viewBox(img) + local pw, ph = img:getWidth(), img:getHeight() + local uiw, uih = Renderer:uiSize() + local S = Renderer:fitScale() + local bw, bh = uiw * S, uih * S + return math.floor((pw - bw) / 2), math.floor((ph - bh) / 2), bw, bh, pw, ph + end + + -- Mean over every bar strip the window has (top/bottom always here, + -- left/right only if the width is not an exact multiple). Inset by 2px so + -- the viewport's own edge pixels cannot bleed into the bar sample. + local function barMean(img) + local bx, by, bw, bh, pw, ph = viewBox(img) + local sum, n = 0, 0 + local function add(x, y, w, h) + if w < 1 or h < 1 then return end + local m, c = regionMean(img, x, y, w, h) + sum, n = sum + m * c, n + c + end + if by >= 8 then + add(0, 0, pw, by - 2) + add(0, by + bh + 2, pw, ph - (by + bh) - 2) + end + if bx >= 8 then + add(0, by, bx - 2, bh) + add(bx + bw + 2, by, bx - 2, bh) + end + return n > 0 and sum / n or -1, n + end + + local function innerMean(img) + local bx, by, bw, bh = viewBox(img) + return regionMean(img, bx + math.floor(bw / 4), by + math.floor(bh / 4), + math.floor(bw / 2), math.floor(bh / 2)) + end + + -- shot + the two-sided assertion every moment shares: bars dead black, + -- viewport interior at least `bright` (the effect visibly inside the frame) + local function shotAndCheck(name, bright) + check("lock still held at " .. name, lock() ~= nil) + local path = DIR .. "/bug864_" .. name .. ".png" + U.shot(game, path) + local img = loadShot(path) + if not check(name .. " shot decoded", img ~= nil) then return nil end + local bars, n = barMean(img) + local inner = innerMean(img) + U.log((" %s: bar mean %.3f over %d px, viewport interior %.3f") + :format(name, bars, n, inner)) + check(name .. ": window has bars to sample", n > 0) + check(name .. ": bars stay dead black (#864)", n > 0 and bars < 0.05) + check(name .. ": the effect still lights the viewport", inner > bright) + return img + end + + -- ---- (3rd symptom first: it is where a boot starts) New Game ----------- + -- OakSpeech sets letterboxWhite; before #864 that painted the WHOLE phone + -- paper white, leaving the locked frame indistinguishable from its bars. + U.wait(5) + U.tap(game, "start") -- skip intro movie + U.wait(10) + U.tap(game, "a") -- title -> menu + U.wait(5) + U.tap(game, "a") -- NEW GAME (POKEPORT_IDENTITY=bug864 has no save) + local oak + for _ = 1, 300 do + for i = #game.stack.states, 1, -1 do + local s = game.stack.states[i] + if s and s.letterboxWhite then oak = s break end + end + if oak then break end + U.tap(game, "a") + U.wait(2) + end + check("Oak speech reached (a letterboxWhite state is on the stack)", + oak ~= nil) + U.wait(40) -- let Oak's pic and a line of text land inside the frame + shotAndCheck("oakspeech", 0.5) + + -- mash through the rest of the speech into the overworld; the naming + -- screens and the closing shrink-away beat (~103 unskippable frames) eat + -- most of this, so the headroom is generous on purpose + for _ = 1, 900 do + U.tap(game, "a") + U.wait(2) + if game.overworld and game.stack:top() == game.overworld then break end + end + check("New Game landed in the overworld", + game.overworld ~= nil and game.stack:top() == game.overworld) + + -- ---- the pre-battle flash ---------------------------------------------- + -- pokered data/maps/objects/Route1.asm puts its youngsters at (5,24) and + -- (15,13) and the sign at (9,27), so the top of the road is empty; the + -- battle is pushed straight in, the cell is only somewhere to stand. + local MAP = "ROUTE_1" + local STAND = { x = 5, y = 6, facing = "down" } + + game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12) } + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map.id == MAP) + if ow and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the road: any free neighbour serves, the cell is not + -- itself under test + for _, d in ipairs({ {0,1}, {0,-1}, {1,0}, {-1,0} }) do + local cx, cy = STAND.x + d[1], STAND.y + d[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + U.log(("stand cell (%d, %d) blocked, using (%d, %d)") + :format(STAND.x, STAND.y, cx, cy)) + U.teleport(game, MAP, cx, cy, STAND.facing) + U.wait(10) + ow = game.overworld + break + end + end + end + + U.shot(game, DIR .. "/bug864_route1_base.png") -- unlit reference frame + lock() + + -- wild, weaker (L5 vs the L12 lead), not a dungeon map: the 3-bit select + -- (battle_transitions.asm) lands on %000 doublecircle, one of the two + -- wipes that call BattleTransition_FlashScreen first + local battle = BattleState.newWild(game, "RATTATA", 5) + ow:pushBattle(battle) + local trans = game.stack:top() + check("the transition is a flashing wipe (doublecircle)", + trans ~= nil and trans.def ~= nil and trans.def.flash == true) + + -- screenVeil is written during draw and cleared at beginFrame, so at + -- update time it holds the LAST rendered frame's veil; catch the white + -- peak (shade 1, near-full alpha) and shoot the very next frames while + -- the 2-frame palette holds keep it bright + local caught = false + for _ = 1, 400 do + local v = game.renderer and game.renderer.screenVeil + if v and v[1] == 1 and v[2] >= 0.9 then caught = true break end + U.wait(1) + end + check("caught the flash at its white peak", caught) + local flashImg = shotAndCheck("flash", 0.5) + local baseImg = loadShot(DIR .. "/bug864_route1_base.png") + if flashImg and baseImg then + local lit, plain = innerMean(flashImg), innerMean(baseImg) + U.log((" viewport interior %.3f unlit -> %.3f mid-flash") + :format(plain, lit)) + check("the flash visibly brightens the viewport over the base frame", + lit > plain + 0.15) + end + + -- ---- the post-battle fade in from white -------------------------------- + for _ = 1, 600 do + if game.stack:top() == battle then break end + U.wait(1) + end + check("the battle reached the screen", game.stack:top() == battle) + for _ = 1, 200 do + if battle.phase == "menu" then break end + U.tap(game, "a") + U.wait(6) + end + check("the battle reached its FIGHT/PKMN/ITEM/RUN menu", + battle.phase == "menu") + + -- run away (down+right lands on RUN from anywhere in the 2x2 grid; the + -- L12 lead outspeeds the L5 wild mon, so the escape always succeeds), + -- then watch for BattleReturn's white veil -- battle over, shade 1, + -- alpha 1 through its hold frames + local sawReturn = false + for _ = 1, 1500 do + local top = game.stack:top() + local v = game.renderer and game.renderer.screenVeil + if top ~= battle and v and v[1] == 1 and v[2] >= 0.9 then + sawReturn = true + break + end + if top == battle then + if battle.phase == "menu" then + U.tap(game, "down") + U.wait(1) + U.tap(game, "right") + U.wait(1) + U.tap(game, "a") + U.wait(2) + else + U.tap(game, "a") + U.wait(3) + end + else + U.wait(1) + end + end + check("caught the post-battle fade in from white", sawReturn) + shotAndCheck("return", 0.8) + + -- ---- over to you -------------------------------------------------------- + U.log("You are back on Route 1 with the picture locked to a 480x432 frame in") + U.log("the middle of a tall window. Open the three shots in " .. DIR .. ":") + U.log("bug864_oakspeech, bug864_flash and bug864_return should each show a lit") + U.log("frame -- paper, white flash, white fade -- with dead-black bars above") + U.log("and below. Before #864 the white spilled over the whole window and the") + U.log("frame had no edge at all. Walking into grass here replays the flash live.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/giovanni_silph11f_bug869_test.lua b/tests/drivers/giovanni_silph11f_bug869_test.lua new file mode 100644 index 00000000..16d70fcd --- /dev/null +++ b/tests/drivers/giovanni_silph11f_bug869_test.lua @@ -0,0 +1,224 @@ +-- Giovanni's Silph Co 11F coordinate trigger speaks BEFORE he walks (#869). +-- pokered scripts/SilphCo11F.asm SilphCo11FDefaultScript: DisplayTextID +-- TEXT_SILPHCO11F_GIOVANNI first, then MoveSprite .GiovanniMovement (3x down) +-- and EngageMapTrainer with no second box. Do not set POKEPORT_SPEED: the +-- box-vs-walk ordering is exactly the moment under test. +-- POKEPORT_DRIVER=tests/drivers/giovanni_silph11f_bug869_test.lua POKEPORT_IDENTITY=bug869 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local TextBox = require("src.render.TextBox") + local BattleTransition = require("src.render.BattleTransition") + local BattleState = require("src.battle.BattleState") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + -- Positions from ../pokered/data/maps/objects/SilphCo11F.asm: Giovanni + -- object_event (6, 9), SILPHCO11F_ROCKET1 (3, 16). Trigger tiles from + -- ../pokered/scripts/SilphCo11F.asm .PlayerCoordsArray: (6, 13) and + -- (7, 12). data/generated/maps.lua stores the same cells 1:1. + local MAP = "SILPH_CO_11F" + local GIO_HOME = { x = 6, y = 9 } + local GIO_STOP = { x = 6, y = 12 } -- home + 3x NPC_MOVEMENT_DOWN + + local failed = 0 + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then failed = failed + 1 end + return ok + end + + -- party strong enough that the human can win the OPP_GIOVANNI#2 fight + -- and watch the unchanged aftermath (victories.lua "Arrgh!!", then the + -- "Blast it all!" speech and the rockets leaving) + game.save.party = { + Pokemon.new(game.data, "MEWTWO", 80), + Pokemon.new(game.data, "SNORLAX", 77), + Pokemon.new(game.data, "CHARIZARD", 70), + } + game.save.player.name = "RED" + + -- (6,13) and (7,13) sit in the card-key doorway of the boss room: block + -- (3,6) stays the closed id 32 until EVENT_SILPH_CO_11_UNLOCKED_DOOR is + -- set (stampClosedDoors, mirroring pokered engine/events/card_key.asm), + -- and a closed door refuses the step this test needs. A real player has + -- opened it before the trigger can fire, so open it here too. + game.save.flags.EVENT_SILPH_CO_11_UNLOCKED_DOOR = true + + check("EVENT_BEAT_SILPH_CO_GIOVANNI starts unset -- trigger is armed", + not game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI) + local opts = game.save.options or {} + if (opts.sfxVol or 0) == 0 then + U.log("sfxVol is 0 -- the evil-trainer sting will be inaudible") + end + if (opts.musicVol or 0) == 0 then + U.log("musicVol is 0 -- the encounter sting and battle theme are muted") + end + + local function findNpc(ow, name) + for _, n in ipairs(ow.npcs or {}) do + if n.def and n.def.name == name then return n end + end + return nil + end + + local function topBox() + local t = game.stack:top() + if getmetatable(t) == TextBox then return t end + return nil + end + + local function boxText(box) + local shown = {} + for _, page in ipairs(box.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + return table.concat(shown, " / ") + end + + -- Stand next to (tx, ty) and take one real walking step onto it; onStep + -- hooks fire on a finished step, so a bare teleport onto the tile would + -- prove nothing. `sides` are {dx, dy, facing} in preference order, each + -- checked for walkability so a map edit only degrades to the next side. + local function stepOnto(tx, ty, sides) + U.teleport(game, MAP, tx + sides[1][1], ty + sides[1][2], sides[1][3]) + U.wait(10) + local ow = game.overworld + for _, s in ipairs(sides) do + local cx, cy = tx + s[1], ty + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow:npcAtCell(cx, cy) then + if ow.player.cellX ~= cx or ow.player.cellY ~= cy then + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + end + U.hold(game, s[3], 24) + U.wait(10) + return true + end + end + return false + end + + -- Regression on the shared engageTrainer path first: an ordinary trainer + -- with no skipBattleText must still get its normal pre-battle box. + -- ROCKET1 faces up, so talk to him from above. + check("regression: reached ROCKET1's cell", + stepOnto(3, 15, { { 0, 1, "up" }, { 0, -1, "down" }, + { 1, 0, "left" }, { -1, 0, "right" } })) + do + local ow = game.overworld + local rocket = findNpc(ow, "SILPHCO11F_ROCKET1") + check("regression: ROCKET1 object loaded", rocket ~= nil) + if rocket then + -- walk down one so we face him from (3, 15) + local fx, fy = ow.player:facingCell() + if ow:npcAtCell(fx, fy) ~= rocket then + -- stepOnto left us adjacent to (3, 15); face the rocket directly + local dx = rocket.cellX - ow.player.cellX + local dy = rocket.cellY - ow.player.cellY + local face = (dy > 0 and "down") or (dy < 0 and "up") + or (dx > 0 and "right") or "left" + U.tap(game, face) + U.wait(10) + end + U.tap(game, "a") + U.wait(30) + local box = topBox() + check("regression: talking to ROCKET1 still opens the pre-battle box", + box ~= nil) + if box then + local t = boxText(box) + U.log("rocket box reads:", t) + check("regression: it is his battle line (\"Stop right there!\")", + t:find("Stop right there", 1, true) ~= nil) + end + end + end + + -- Both trigger tiles must fire with Giovanni still at his desk. The + -- (7, 12) probe is abandoned before the box is dismissed (the teleport + -- rebuilds the map state), so the trigger re-arms for the main run -- + -- vanilla re-arms too, since only a win sets the event flag. + do + check("(7,12) approach: stepped onto the trigger from the right", + stepOnto(7, 12, { { 1, 0, "left" }, { 0, 1, "up" }, + { -1, 0, "right" } })) + local box = topBox() + check("(7,12) approach: intro box opened", box ~= nil) + local gio = findNpc(game.overworld, "SILPHCO11F_GIOVANNI") + check("(7,12) approach: Giovanni is still at his desk (6,9)", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + end + + -- Main run, the route the issue screenshots show: (6,15) facing up, two + -- steps onto (6,13). + U.teleport(game, MAP, 6, 15, "up") + U.wait(10) + U.hold(game, "up", 24) + U.hold(game, "up", 24) + U.wait(15) + if not topBox() then + -- blocked approach fallback: one step onto (6,13) from any free side + stepOnto(6, 13, { { 0, 1, "up" }, { -1, 0, "right" }, { 1, 0, "left" } }) + end + + local ow = game.overworld + local gio = findNpc(ow, "SILPHCO11F_GIOVANNI") + check("Giovanni object loaded on " .. MAP, gio ~= nil) + local box = topBox() + check("stepping onto (6,13) opened a text box", box ~= nil) + if box then + local t = boxText(box) + U.log("intro box reads:", t) + check("it is the Giovanni intro (\"So we meet again!\")", + t:find("So we meet again", 1, true) ~= nil) + end + check("the box opened with Giovanni STILL at his desk (6,9) -- the fix", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + U.shot(game, SHOT_DIR .. "/bug869_box_before_walk.png") + U.wait(30) + check("he holds the desk for the whole box, not just its first frame", + gio ~= nil and gio.cellX == GIO_HOME.x and gio.cellY == GIO_HOME.y) + + -- dismiss every page; the walk and the battle must follow with NO + -- further dialogue box in between (EngageMapTrainer runs bare in the + -- original, so engageTrainer is called with skipBattleText here) + for _ = 1, 300 do + if not topBox() then break end + U.tap(game, "a") + U.wait(5) + end + check("intro box dismissed", topBox() == nil) + local sawSecondBox, battleReached = false, false + for _ = 1, 900 do + local t = game.stack:top() + local mt = getmetatable(t) + if mt == TextBox and not sawSecondBox then + sawSecondBox = true + U.log("unexpected box reads:", boxText(t)) + end + if mt == BattleTransition or mt == BattleState then + battleReached = true + break + end + U.wait(1) + end + check("battle wipe started after the box", battleReached) + check("no second dialogue box between the walk and the battle", not sawSecondBox) + check("Giovanni walked the three tiles down to (6,12) first", + gio ~= nil and gio.cellX == GIO_STOP.x and gio.cellY == GIO_STOP.y) + + U.log(failed == 0 and "PASS all machine checks clean" + or ("FAIL " .. failed .. " machine check(s) above")) + + U.log("The battle wipe is running now; take the pad and win the fight.") + U.log("Right looks like what just played: his speech opened while he was") + U.log("behind the desk, then he walked down and the fight began straight") + U.log("away, with the evil-trainer sting and no extra dialogue. After the") + U.log("win you should get \"Arrgh!!\" on the battle screen, the \"Blast it") + U.log("all!\" speech, a fade, and every rocket gone. Wrong is the old bug:") + U.log("he crosses the room in silence and only then talks, point-blank.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/marowak_departed_bug867_test.lua b/tests/drivers/marowak_departed_bug867_test.lua new file mode 100644 index 00000000..ae9bbbc0 --- /dev/null +++ b/tests/drivers/marowak_departed_bug867_test.lua @@ -0,0 +1,188 @@ +-- Manual check of the ghost MAROWAK send-off on POKEMON_TOWER_6F (#867). +-- PokemonTower6FMarowakDepartedText (pokered scripts/PokemonTower6F.asm) is +-- two texts: the CUBONE's-mother line, then PlayCry RESTLESS_SOUL + 30 frames +-- before the calmed line; the port showed only the calmed line and no cry. +-- Never under POKEPORT_SPEED -- the cry-then-text beat is the thing under test. +-- POKEPORT_DRIVER=tests/drivers/marowak_departed_bug867_test.lua POKEPORT_IDENTITY=bug867 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local TextBox = require("src.render.TextBox") + + local pass, fail = 0, 0 + local function check(label, ok) + if ok then pass = pass + 1 else fail = fail + 1 end + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + -- ---- machine checks ---------------------------------------------------- + -- Both keys come out of the stock extractor (data/generated/text.lua); a + -- rename there would drop story3.lua onto its hand fallbacks, which still + -- displays but is worth knowing about. + local mother = game.data.text._PokemonTower6FGhostWasCubonesMotherText + local calmed = game.data.text._PokemonTower6FSoulWasCalmedText + check("_PokemonTower6FGhostWasCubonesMotherText extracted", + type(mother) == "string" and mother ~= "") + check("_PokemonTower6FSoulWasCalmedText extracted", + type(calmed) == "string" and calmed ~= "") + if type(mother) == "string" then + U.log("first line reads:", (mother:gsub("[\n\011\012]", " / "))) + end + if type(calmed) == "string" then + U.log("second line reads:", (calmed:gsub("[\n\011\012]", " / "))) + end + check("MAROWAK is a real species (RESTLESS_SOUL EQU MAROWAK)", + game.data.pokemon.MAROWAK ~= nil) + + local opts = game.save.options or {} + U.log("sfxVol", tostring(opts.sfxVol), "musicVol", tostring(opts.musicVol)) + if opts.sfxVol == 0 then + U.log("sfxVol is 0: raise it in OPTION or the cry cannot be judged") + end + + -- ---- reach the trigger ------------------------------------------------- + -- pokered scripts/PokemonTower6F.asm PokemonTower6FMarowakCoords: + -- dbmapcoord 10, 16 (a coord array, not an object). Row 17 is solid wall + -- and (9, 16) is the stairwell warp, so the approach is from (10, 15) + -- facing down, same as tests/drivers/ghost_unveil_bug492_test.lua. + local MAP = "POKEMON_TOWER_6F" + local TRIGGER = { x = 10, y = 16 } + local STAND = { x = 10, y = 15, facing = "down", step = "down" } + + -- one mon, one damaging move: the A-mash below always picks FIGHT slot 1, + -- and a stat move there stalls the run (route.lua learned this the hard way) + local mon = Pokemon.new(game.data, "MEWTWO", 100) + if game.data.moves.PSYCHIC_M then + mon.moves = { { id = "PSYCHIC_M", pp = 99 } } + end + game.save.party = { mon } + game.save.player.name = "RED" + -- the scope buys the unveil so the ghost can be damaged at all (#492) + game.save.inventory.SILPH_SCOPE = 1 + game.save.flags.EVENT_BEAT_GHOST_MAROWAK = nil + + U.teleport(game, MAP, STAND.x, STAND.y, STAND.facing) + U.wait(10) + local ow = game.overworld + check("the overworld is up on " .. MAP, ow ~= nil and ow.map ~= nil) + + if ow and ow.map and not ow.map:isWalkableCell(STAND.x, STAND.y) then + -- a map edit moved the approach: stand on any walkable neighbour of the + -- trigger that is not the stairwell warp and step back onto it. + -- {dx, dy, facing} is the trigger-to-stand offset plus the direction + -- that walks back onto the trigger cell. + local sides = { { 0, -1, "down" }, { 1, 0, "left" }, + { -1, 0, "right" }, { 0, 1, "up" } } + for _, s in ipairs(sides) do + local cx, cy = TRIGGER.x + s[1], TRIGGER.y + s[2] + if ow.map:isWalkableCell(cx, cy) and not ow.map:warpAtCell(cx, cy) then + U.log(("(%d, %d) is blocked, approaching from"):format(STAND.x, STAND.y), + cx, cy, "stepping", s[3]) + STAND = { x = cx, y = cy, facing = s[3], step = s[3] } + U.teleport(game, MAP, cx, cy, s[3]) + U.wait(10) + ow = game.overworld + break + end + end + end + + -- walk onto the trigger; MapScripts onStep fires on the completed step + U.hold(game, STAND.step, 20) + U.wait(20) + check("the Be-gone text opened", game.stack:top() ~= game.overworld) + + -- ---- win the battle ---------------------------------------------------- + local sawBattle = false + for _ = 1, 1500 do + -- onFinish sets the flag and queues the departed rows in the same call, + -- so break on the flag BEFORE tapping: a stray A here would eat the + -- CUBONE's-mother box before it is recorded + if game.save.flags.EVENT_BEAT_GHOST_MAROWAK then break end + if getmetatable(game.stack:top()) == BattleState then sawBattle = true end + U.tap(game, "a") + U.wait(4) + end + check("the MAROWAK battle opened", sawBattle) + check("the battle was won (EVENT_BEAT_GHOST_MAROWAK set)", + game.save.flags.EVENT_BEAT_GHOST_MAROWAK == true) + + -- ---- record the send-off boxes ----------------------------------------- + -- Each box is sampled the frame it is first seen: the calmed box carries + -- the armed cry as opts.auto {sound, wait} (src/script/Commands.lua), and + -- TextBox clears .auto once the cry has played, so a late read looks like + -- no cry at all. + local boxes = {} + local lastBox = nil + local budget = 1200 + while budget > 0 do + local top = game.stack:top() + if getmetatable(top) == TextBox then + if top ~= lastBox then + lastBox = top + local shown = {} + for _, page in ipairs(top.pages or {}) do + for _, line in ipairs(page) do shown[#shown + 1] = line end + end + boxes[#boxes + 1] = { + text = table.concat(shown, " / "), + cry = top.auto ~= nil and top.auto.sound ~= nil, + } + U.log(("box %d reads:"):format(#boxes), boxes[#boxes].text) + -- let the typewriter finish before the shot so the capture shows the + -- line; the auto sample above already happened on the open frame + for _ = 1, 240 do + if top.waiting or top.done or game.stack:top() ~= top then break end + U.wait(1) + budget = budget - 1 + end + U.shot(game, DIR .. ("/bug867_box%d.png"):format(#boxes)) + end + U.tap(game, "a") + U.wait(3) + budget = budget - 4 + else + if #boxes >= 2 then break end + U.wait(1) + budget = budget - 1 + end + end + + check("the send-off is two boxes, not one (#867)", #boxes == 2) + check("box 1 is the CUBONE's-mother line", + boxes[1] ~= nil and boxes[1].text:find("CUBONE", 1, true) ~= nil) + check("box 1 opens silent (the asm plays no cry before it)", + boxes[1] ~= nil and not boxes[1].cry) + check("box 2 is the calmed line", + boxes[2] ~= nil and boxes[2].text:find("calmed", 1, true) ~= nil) + check("box 2 opens with the MAROWAK cry armed", + boxes[2] ~= nil and boxes[2].cry == true) + + -- ---- the trigger is spent ---------------------------------------------- + -- step off and back onto (10, 16): with the flag set, onStep must pass + local back = ({ down = "up", up = "down", left = "right", right = "left" }) + [STAND.step] + U.hold(game, back, 20) + U.wait(10) + U.hold(game, STAND.step, 20) + U.wait(20) + check("re-stepping the trigger cell stays quiet", + getmetatable(game.stack:top()) ~= TextBox) + U.shot(game, DIR .. "/bug867_after.png") + U.log(("machine checks: %d passed, %d failed"):format(pass, fail)) + + -- ---- hand off ---------------------------------------------------------- + U.log("The pad is yours on 6F with the ghost already sent off. What should") + U.log("have happened: after the win, \"The GHOST was the / restless soul of /") + U.log("CUBONE's mother!\" first, then the MAROWAK cry sounds as the second box") + U.log("opens with \"The mother's soul / was calmed.\" The old bug jumped") + U.log("straight to the calmed line, no mother line and no cry at all.") + U.log("Screenshots: " .. DIR .. "/bug867_*.png") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/pikachu_warp_spawn_bug863_test.lua b/tests/drivers/pikachu_warp_spawn_bug863_test.lua new file mode 100644 index 00000000..2e2bd903 --- /dev/null +++ b/tests/drivers/pikachu_warp_spawn_bug863_test.lua @@ -0,0 +1,164 @@ +-- Manual check that a warp arrival hides Pikachu under the player (#863): +-- pokeyellow spawns on the player's own coords and the follow buffer walks +-- it out, but before the fix it popped in already beside him. +-- Warp cells: pokered data/maps/objects/RedsHouse1F.asm / RedsHouse2F.asm. +-- POKEPORT_DRIVER=tests/drivers/pikachu_warp_spawn_bug863_test.lua POKEPORT_IDENTITY=bug863 POKEPORT_TOUCH=0 POKEPORT_VERSION=yellow love . +-- Never add POKEPORT_SPEED; the identity needs an imported Yellow cache. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pokemon = require("src.pokemon.Pokemon") + local GameVersion = require("src.core.GameVersion") + local PF = require("src.world.PikachuFollower") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + return ok + end + + check("running as Yellow (needs POKEPORT_VERSION=yellow)", + GameVersion.isYellow()) + + -- the follower only spawns behind EVENT_GOT_STARTER with a healthy + -- PIKACHU in the party (PikachuFollower shouldSpawn) + game.save.flags = game.save.flags or {} + game.save.flags.EVENT_GOT_STARTER = true + game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) } + game.save.onBike = false + game.save.player.name = "bryan" + + -- pokered RedsHouse1F.asm: warp_event 7, 1 -> REDS_HOUSE_2F, and + -- RedsHouse2F.asm: warp_event 7, 1 back down. Read the live map data + -- so a hack or mod that moved the stairs still points us at them. + local function warpTo(fromMap, destMap) + local def = game.data.maps[fromMap] + for _, w in ipairs(def and def.warps or {}) do + if w.destMap == destMap then return w.x, w.y end + end + return nil + end + local wx, wy = warpTo("REDS_HOUSE_1F", "REDS_HOUSE_2F") + check("REDS_HOUSE_1F has a warp to REDS_HOUSE_2F", wx ~= nil) + wx, wy = wx or 7, wy or 1 + + local follower = function() return PF.current(game.overworld) end + + local function overlapsPlayer() + local npc = follower() + local p = game.overworld and game.overworld.player + return npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY + end + + -- press-and-hold dir until the map flips, releasing the instant it + -- does so no queued step drags the player off the arrival warp cell + local function walkUntilMap(dir, targetMap, maxFrames) + for _ = 1, maxFrames do + local ow = game.overworld + if ow and ow.map and ow.map.id == targetMap then break end + table.insert(game.input.pressQueue, dir) + game.input.state[dir] = true + coroutine.yield() + end + game.input.state[dir] = false + U.wait(45) -- warp fade + arrival settle + local ow = game.overworld + return ow and ow.map and ow.map.id == targetMap + end + + -- stand two below the stairs facing up; fall back to any walkable cell + -- below the warp if a mod reshaped the room + local sx, sy = wx, wy + 2 + U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up") + U.wait(10) + local ow = game.overworld + if not ow.map:isWalkableCell(sx, sy) then + for dy = 1, 3 do + if ow.map:isWalkableCell(wx, wy + dy) then + sx, sy = wx, wy + dy + U.teleport(game, "REDS_HOUSE_1F", sx, sy, "up") + U.wait(10) + break + end + end + end + -- U.teleport is itself a fresh map load, so the fixed spawn already + -- parks Pikachu on the player's cell here + check("follower spawned on map load", follower() ~= nil) + check("map-load spawn is on the player's own cell", overlapsPlayer()) + + -- climb the stairs + check("walked up into REDS_HOUSE_2F", + walkUntilMap("up", "REDS_HOUSE_2F", 240)) + check("warp arrival upstairs: Pikachu hidden under the player", + overlapsPlayer()) + U.shot(game, SHOT_DIR .. "/bug863_2f_arrival.png") + + -- the draw sort tie-break (#863): sharing the player's py must list the + -- follower first so he draws over it. The sort runs in draw, and the + -- shot above rendered a frame, so entities order is post-sort here. + do + local npc = follower() + local p = game.overworld.player + if npc and p and npc.py == p.py then + local ni, pi + for i, e in ipairs(game.overworld.entities) do + if e == npc then ni = i elseif e == p then pi = i end + end + check("draw sort puts the hidden follower under the player", + ni ~= nil and pi ~= nil and ni < pi) + end + end + + -- walk off the stairs: the trail should pull Pikachu out one behind + U.hold(game, "down", 20) + U.wait(20) + U.hold(game, "down", 20) + U.wait(30) + do + local npc = follower() + local p = game.overworld.player + check("two steps later Pikachu trails one cell behind", + npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY - 1) + check("and it faces down, walking out of the stairwell", + npc and npc.facing == "down") + end + U.shot(game, SHOT_DIR .. "/bug863_2f_trailing.png") + + -- back down the same stairs: descent must hide it the same way + check("walked back down into REDS_HOUSE_1F", + walkUntilMap("up", "REDS_HOUSE_1F", 240)) + check("warp arrival downstairs: Pikachu hidden under the player", + overlapsPlayer()) + U.shot(game, SHOT_DIR .. "/bug863_1f_return.png") + + -- regression: a connection seam is the keepPikachu path (#427), not a + -- respawn, so the follower must ride across it, not vanish or repark + U.teleport(game, "PALLET_TOWN", 10, 2, "up") + U.wait(10) + check("crossed the Pallet Town north seam into ROUTE_1", + walkUntilMap("up", "ROUTE_1", 300)) + do + local npc = follower() + local p = game.overworld.player + check("follower survived the connection crossing", npc ~= nil) + check("and stayed within trailing range of the player", + npc and p and math.abs(npc.cellX - p.cellX) + + math.abs(npc.cellY - p.cellY) <= 2) + end + U.shot(game, SHOT_DIR .. "/bug863_route1_seam.png") + + local sfx = game.save.options and game.save.options.sfxVol + if sfx == 0 then + U.log("note: sfxVol is 0, Pikachu's steps and voice will be silent") + end + + U.log("The shots above tell the story: on both stair arrivals only the") + U.log("player should be visible, Pikachu is tucked under him until he") + U.log("steps away, then it follows one cell behind facing his way.") + U.log("If a Pikachu sits beside him the moment a warp lands, that is") + U.log("the old bug. The pad is yours; warp around and watch spawns.") + + while true do + coroutine.yield() + end +end diff --git a/tests/drivers/title_menu_palette_bug870_test.lua b/tests/drivers/title_menu_palette_bug870_test.lua new file mode 100644 index 00000000..2575c2a7 --- /dev/null +++ b/tests/drivers/title_menu_palette_bug870_test.lua @@ -0,0 +1,149 @@ +-- Manual check of the title main menu + CONTINUE info box colors (#870): +-- both must follow the COLORS display mode (CLASSIC pea greens) instead of +-- staying a raw white trueColor hole, while gbc keeps #133's white paper / +-- black ink (pokered engine/menus/main_menu.asm RunDefaultPaletteCommand). +-- Palette shading is the moment under test, so POKEPORT_SPEED stays unset. +-- POKEPORT_DRIVER=tests/drivers/title_menu_palette_bug870_test.lua POKEPORT_IDENTITY=bug870 POKEPORT_TOUCH=0 POKEPORT_VERSION=red love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local P = require("src.render.PaletteFX") + local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots" + + local fails = 0 + local function check(label, ok) + U.log(ok and "PASS" or "FAIL", label) + if not ok then fails = fails + 1 end + return ok + end + + -- Count near-white pixels in a captured frame. CLASSIC's lightest shade + -- is (155,188,15) and no blend of the four pea greens (or the letterbox) + -- reaches 250+, so any white here can only be an unshaded region -- the + -- exact white hole #870 is about. Reads the PNG back through + -- love.image so the check sees what actually hit the window. + local function whiteCount(path) + local f = io.open(path, "rb") + if not f then return nil end + local bytes = f:read("*a") + f:close() + local ok, img = pcall(function() + return love.image.newImageData( + love.filesystem.newFileData(bytes, "shot.png")) + end) + if not ok or not img then return nil end + local n = 0 + for y = 0, img:getHeight() - 1 do + for x = 0, img:getWidth() - 1 do + local r, g, b = img:getPixel(x, y) + if r > 0.98 and g > 0.98 and b > 0.98 then n = n + 1 end + end + end + return n + end + + -- A real save on disk first: hasSave in TitleState:openMenu checks the + -- save FILE, not the in-memory table, and only then lists CONTINUE. + -- No map coordinates anywhere in this test -- the bug lives on the title + -- screen, before any map. + U.newGame(game) + check("reached the overworld", game.overworld ~= nil) + check("save written so the menu lists CONTINUE", + require("src.core.SaveData").save(game.save)) + + -- flip COLORS the way the options screen does, then power-cycle to the + -- title (returnToTitle skips the intro movie, unlike a cold boot) + game.save.options.colors = "classic" + P.applyOptions(game.save.options) + game:returnToTitle() + U.wait(30) + + local TitleState = require("src.ui.TitleState") + local title = game.stack:top() + check("back on the title screen", getmetatable(title) == TitleState) + + U.tap(game, "start") + U.wait(10) + local menu = game.stack:top() + check("main menu opened and set a titleUiBox", + menu ~= title and menu ~= nil and menu.titleUiBox ~= nil) + + -- The fix itself: the box overlay must be a GRAYS palette zone the shade + -- shader runs on. A colors == false zone would make Renderer:blitCanvas + -- re-blit the rect with NO shader, so effectiveColors never substitutes + -- the mono/inverted modes there -- the pre-#870 white hole. + local zones = title.sgbPalettes and title:sgbPalettes(game) + local boxZone, bare = nil, false + for _, z in ipairs(zones or {}) do + if z.colors == false then bare = true end + if z.colors == P.GRAYS then boxZone = z end + end + check("no trueColor (colors == false) zone over the menu box", not bare) + check("the titleUiBox rides a GRAYS palette zone", boxZone ~= nil) + -- effectiveColors under classic substitutes CLASSIC; the trailing + -- permute is the identity while no shade map is armed, so == holds + check("CLASSIC substitutes the GRAYS box zone", + P.effectiveColors(P.GRAYS) == P.CLASSIC) + + local shot1 = SHOT_DIR .. "/bug870_menu_classic.png" + if U.shot(game, shot1) then + local n = whiteCount(shot1) + U.log("white pixels in the menu shot:", tostring(n)) + check("CLASSIC main menu shot has zero raw-white pixels", + n ~= nil and n == 0) + end + + -- with a save present CONTINUE is first, so the cursor is already on it + U.tap(game, "a") + U.wait(10) + local info = game.stack:top() + check("CONTINUE info box open with its titleUiBox", + info ~= nil and info ~= menu and info.titleUiBox ~= nil + and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7) + + local shot2 = SHOT_DIR .. "/bug870_info_classic.png" + if U.shot(game, shot2) then + local n = whiteCount(shot2) + U.log("white pixels in the info shot:", tostring(n)) + check("CLASSIC CONTINUE info shot has zero raw-white pixels", + n ~= nil and n == 0) + end + + -- #133 regression gate: under gbc the GRAYS zone must pass through the + -- shader unchanged, so the box comes back white paper / black ink while + -- the LOGO zones keep the title colored around it + P.applyOptions({ colors = "gbc" }) + U.wait(5) + check("gbc leaves the GRAYS box zone alone (the #133 white box)", + P.effectiveColors(P.GRAYS) == P.GRAYS) + local shot3 = SHOT_DIR .. "/bug870_info_gbc.png" + if U.shot(game, shot3) then + local n = whiteCount(shot3) + U.log("white pixels in the gbc shot:", tostring(n)) + check("gbc info box paper is white again", n ~= nil and n > 0) + end + + -- the inverted modes must now invert the box with the screen too + P.applyOptions({ colors = "og_inv" }) + U.wait(5) + local inv = P.effectiveColors(P.GRAYS) + check("OG INV inverts the box paper to black", + inv ~= nil and inv[1] ~= nil and inv[1][1] == 0) + U.shot(game, SHOT_DIR .. "/bug870_info_oginv.png") + + -- hand over in the bug's own mode + game.save.options.colors = "classic" + P.applyOptions(game.save.options) + U.wait(2) + + U.log(fails == 0 and "PASS all machine checks" + or ("FAIL " .. fails .. " machine check(s), see above")) + U.log("The CONTINUE info box on screen is in CLASSIC now; its paper should") + U.log("be the same pea green as the title behind it, with dark green ink,") + U.log("just like the in-game START menu. Before #870 this box and the") + U.log("main menu were a pure white rectangle over the green title.") + U.log("B backs out to the menu; OPTION flips COLORS to eyeball the rest.") + + while true do + coroutine.yield() + end +end diff --git a/tests/engine/launcher_one_column_reach_bug852.lua b/tests/engine/launcher_one_column_reach_bug852.lua new file mode 100644 index 00000000..00e85560 --- /dev/null +++ b/tests/engine/launcher_one_column_reach_bug852.lua @@ -0,0 +1,112 @@ +-- One-column launcher reach (#852) and the safe-area launcher anchor (#810). +-- No pokered cite: the launcher is port-only chrome. +-- +-- #852: minPanelHeight in src/import/LauncherView.lua was a flat 460*s tuned +-- for the two-column layout. A one-column window (portrait phone, squat 4:3 +-- device) stacks title + actions card + slot card + the pinned +-- Play/Reset-rebinds/Touch-Controls block, which needs more room; the flat +-- threshold read "tall enough", so the short-window page scroll never +-- engaged, buildSlotCard was cut by Kit.pushClip against the pinned block, +-- and Kit's clip-bounded hit-testing (src/ui/kit/Kit.lua) left every slot +-- row, the pager and "+ New save slot" drawn-but-inert. The fix makes the +-- threshold column-aware, so those windows scroll instead of clipping. +-- +-- The seam is LauncherView.draw itself: it publishes the page-scroll extent +-- on the importer (imp._pageScroll / imp._pageScrollMax, the values the +-- touch-drag and wheel paths feed), so a headless draw shows whether the +-- scroll engaged without reading any file-local constant. The 480x900 +-- window below is the discriminator: its natural panel space satisfies the +-- old flat threshold (inert, slot card clipped) but not the one-column one. +-- +-- #810 gets its unit-conversion pin in tests/engine/safe_area_units_test.lua; +-- here the complementary end-to-end anchor: Layout.metrics must place the +-- launcher at the corrected safe-area origin, not a DPI-inflated band down +-- the screen. +-- luajit tests/engine/launcher_one_column_reach_bug852.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +-- The launcher touches two graphics calls the shared stub does not carry +-- (focus-ring joins, the footer's BCG invert shader); both are draw-only, so +-- inert fills are enough for the layout arithmetic under test. +love.graphics.setLineJoin = love.graphics.setLineJoin or function() end +love.graphics.newShader = love.graphics.newShader or function() return {} end + +local Layout = require("src.ui.kit.Layout") +local RomImporter = require("src.import.RomImporter") +local LauncherView = require("src.import.LauncherView") + +local function window(w, h) + love.graphics.getDimensions = function() return w, h end + love.graphics.getPixelDimensions = function() return w, h end +end + +-- A fresh launcher on the Red tab; no cache exists headless, so every +-- version sits in its "ROM required" state, which still lays out the full +-- one-column pile (title, actions card, slot card, pinned block). +local function freshLauncher() + return RomImporter.new(function() end, { launcher = true }) +end + +-- ------------------------------------------------ #852: the scroll engages +-- 480x900 one column: enough room for the old flat 460*s threshold, not for +-- the one-column stack. Before the fix draw() left _pageScrollMax at 0 here +-- and the slot card sat clipped inert against the pinned buttons. +window(480, 900) +local m = Layout.metrics(1200) +eq(m.twoCol, false, "480-wide window lays out one column") +local imp = freshLauncher() +LauncherView.draw(imp) +check((imp._pageScrollMax or 0) > 0, + "one-column window short of the stack engages the page scroll") +eq(imp._pageScroll, 0, "a fresh page starts at the top") + +-- The wheel moves the page (the same offset the touch drag feeds), and the +-- offset clamps to the extent, so the whole stack down to "+ New save slot" +-- and the footer is reachable rather than clipped away. +local extent = imp._pageScrollMax +imp._wheelY = -1 +LauncherView.draw(imp) +eq(imp._pageScroll, math.min(math.floor(48 * m.s), extent), + "one wheel notch scrolls the page down by its step") +imp._pageScroll = 1e6 +LauncherView.draw(imp) +eq(imp._pageScroll, imp._pageScrollMax, + "an offset past the end clamps to the extent, so the bottom is reachable") + +-- The reporter's portrait phone (360x780 units) is shorter still and must +-- also scroll; before the fix its slot list was unreachable. +window(360, 780) +local phone = freshLauncher() +LauncherView.draw(phone) +check((phone._pageScrollMax or 0) > 0, + "portrait-phone one-column window engages the page scroll") + +-- A one-column window tall enough for the whole stack stays inert: the +-- column-aware minimum is a floor, not a permanent scroll. +window(480, 1200) +local tall = freshLauncher() +LauncherView.draw(tall) +eq(tall._pageScrollMax, 0, + "a tall one-column window does not scroll for nothing") + +-- --------------------------------------- #810: launcher anchored in units +-- Layout.metrics anchors the launcher at SafeArea.rect's origin. Feed it +-- the iOS 16 portrait frame that reported the safe rect in framebuffer +-- pixels (3x DPI): the launcher must start at the 44-unit notch inset, not +-- 132 units down with the top of the window black (the #810 report). The +-- rescale itself is pinned in tests/engine/safe_area_units_test.lua. +love.graphics.getDimensions = function() return 375, 812 end +love.graphics.getPixelDimensions = function() return 1125, 2436 end +local oldSafe = love.window.getSafeArea +love.window.getSafeArea = function() return 0, 132, 1125, 2232 end +local ios = Layout.metrics(1200) +eq(ios.top, 44, "launcher anchors at the unit-space notch inset") +eq(ios.h, 744, "launcher gets the full unit-space safe height") +love.window.getSafeArea = oldSafe + +T.finish("launcher one-column reach") diff --git a/tests/engine/options_backup_rollforward_bug828.lua b/tests/engine/options_backup_rollforward_bug828.lua new file mode 100644 index 00000000..87168190 --- /dev/null +++ b/tests/engine/options_backup_rollforward_bug828.lua @@ -0,0 +1,109 @@ +-- #828, the revert half: after the launcher's OG -> WIDE toggle, a play +-- session rewrites options.lua with byte-identical content (play() re-stamps +-- an unchanged lastVersion, SaveData.save flushes the attached table, and +-- SaveSerializer's key-sorted encode makes equal tables equal bytes), so +-- saveOptions' conditional pre-write roll skips and options.lua.bak kept the +-- PRE-change file all session. Android and Steam Deck end sessions with a +-- hard teardown (HostShell.restart restartApp kill / AppImage execv) that can +-- eat the main file, and loadOptions then promoted that stale backup: the +-- reported "launcher-only persists, going in-game reverts". The fix rolls +-- the backup forward to the just-verified bytes after every landed write; +-- this suite pins that at-rest invariant. ROM-free (T2 engine tier), same +-- injected-fs shape as tests/engine/options_write_readback_bug828.lua, where +-- these checks should eventually fold in. +-- luajit tests/engine/options_backup_rollforward_bug828.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local SaveData = require("src.core.SaveData") + +local OPTIONS = "options.lua" +local BAK = OPTIONS .. ".bak" +local TMP = OPTIONS .. ".tmp" + +-- In-memory love.filesystem stub, the { getInfo, read, write, remove } shape +-- SaveData.persistFs accepts. `dropping` is mutable so one fs can serve a +-- healthy session and then a write that reports success without landing. +local function memfs() + local files = {} + local fs + fs = { + files = files, + dropping = false, + write = function(path, content) + if fs.dropping then return true end + files[path] = content + return true + end, + read = function(path) return files[path] end, + remove = function(path) files[path] = nil return true end, + getInfo = function(path) + if files[path] ~= nil then return { type = "file" } end + return nil + end, + } + return fs +end + +-- ---- at rest, the backup holds the newest verified bytes + +local fs = memfs() +SaveData.saveOptions({ battleLayout = "wide" }, fs) +check(fs.files[BAK] ~= nil, "the very first verified write already leaves a backup") +eq(fs.files[BAK], fs.files[OPTIONS], + "after a verified write the backup equals the main file (#828 roll-forward)") +check(fs.files[TMP] == nil, "the staged witness is still dropped after verification") + +-- ---- the reported session, write for write +-- Launcher toggles OG -> WIDE, then two rewrites whose bytes match the file +-- on disk: RomImporter:play re-stamping the same lastVersion (#835) and the +-- in-game SaveData.save flush of the attached, unchanged table. Both go +-- through loadOptions first, exactly as the shipping callers do, so the +-- encoder sees identical tables and the conditional pre-roll skips. +local live = memfs() +SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, live) + +local toggled = SaveData.loadOptions(live) +toggled.battleLayout = "wide" +SaveData.saveOptions(toggled, live) +local wideBytes = live.files[OPTIONS] + +local stamped = SaveData.loadOptions(live) +stamped.lastVersion = "red" +SaveData.saveOptions(stamped, live) +eq(live.files[OPTIONS], wideBytes, + "the play() lastVersion re-stamp is a byte-identical rewrite (sorted encode)") +SaveData.saveOptions(SaveData.loadOptions(live), live) +eq(live.files[OPTIONS], wideBytes, "the in-game flush is byte-identical too") + +eq(live.files[BAK], wideBytes, + "identical rewrites still carry the backup forward past the skipped pre-roll") + +-- the hard teardown eats the main file; recovery must answer the toggle +live.files[OPTIONS] = nil +eq(SaveData.loadOptions(live).battleLayout, "wide", + "a lost main file recovers to WIDE, not the pre-toggle OG backup (#828)") +check(live.files[OPTIONS] ~= nil, "and the main file is healed from that copy") + +-- ---- a write that does not land must not poison the backup +-- The roll-forward has to sit AFTER the readback verification: if the bytes +-- never reached disk (the #828 external-storage failure mode) the backup +-- keeps the last state that verifiably did. +local flaky = memfs() +SaveData.saveOptions({ battleLayout = "wide" }, flaky) +local verified = flaky.files[BAK] +flaky.dropping = true +eq(SaveData.saveOptions({ battleLayout = "og" }, flaky), nil, + "the vanished write still reports failure") +flaky.dropping = false +eq(flaky.files[BAK], verified, + "a write that never landed leaves the backup at the last verified bytes") +flaky.files[OPTIONS] = nil +eq(SaveData.loadOptions(flaky).battleLayout, "wide", + "so recovery after the failed write still answers the verified state") + +T.finish("options_backup_rollforward_bug828") diff --git a/tests/engine/options_write_readback_bug828.lua b/tests/engine/options_write_readback_bug828.lua index ccd8508c..59b9b0b7 100644 --- a/tests/engine/options_write_readback_bug828.lua +++ b/tests/engine/options_write_readback_bug828.lua @@ -131,6 +131,31 @@ eq(healed and healed.battleLayout, "wide", check(live.files[OPTIONS] ~= "return { battleLayout = ", "the main options file is healed from the copy that parsed") +-- ---- a lost main file must recover to the NEWEST verified write +-- The platforms that lose options.lua do it on the hard teardown out of a +-- game session (HostShell.restart's restartApp kill on Android, execv on a +-- SteamOS AppImage), after rewrites whose bytes matched the file already on +-- disk: play()'s lastVersion stamp and the in-game save flush re-encode the +-- same table, and the key-sorted encoder makes those byte-identical, so the +-- conditional pre-write roll skips them. The backup is therefore rolled +-- forward after every verified write; otherwise recovery handed back the +-- file from BEFORE the launcher's change, which is exactly the reported +-- "set BATTLE LAYOUT to WIDE, go in game, close, and it is OG again" (#828). +local lost = memfs("honest") +SaveData.saveOptions({ battleLayout = "og", lastVersion = "red" }, lost) +local editedOpts = SaveData.loadOptions(lost) +editedOpts.battleLayout = "wide" +SaveData.saveOptions(editedOpts, lost) -- the launcher's toggle +local replay = SaveData.loadOptions(lost) +replay.lastVersion = "red" -- play() re-stamps the same value +SaveData.saveOptions(replay, lost) -- byte-identical rewrite +SaveData.saveOptions(SaveData.loadOptions(lost), lost) -- in-game save flush, identical too +lost.files[OPTIONS] = nil -- the platform ate the main file +local promoted = SaveData.loadOptions(lost) +eq(promoted.battleLayout, "wide", + "a lost main file recovers to the newest verified write, not the " + .. "pre-change backup (#828)") + local gone = memfs("honest") SaveData.saveOptions({ battleLayout = "wide" }, gone) gone.files[OPTIONS] = nil diff --git a/tests/engine/safe_area_units_test.lua b/tests/engine/safe_area_units_test.lua new file mode 100644 index 00000000..a4387b99 --- /dev/null +++ b/tests/engine/safe_area_units_test.lua @@ -0,0 +1,51 @@ +-- SafeArea.rect unit sanity (#810): the iOS build reported the portrait +-- safe rect in framebuffer PIXELS while love.graphics works in DPI-scaled +-- units, and the old clamp kept the inflated top inset -- the launcher +-- started a band down the screen and left the top of it black. A rect +-- that cannot fit the unit window is converted back to units with +-- per-axis ratios (the axes can disagree, #208). No pokered cite: the +-- launcher is port-only chrome. +-- luajit tests/engine/safe_area_units_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local eq = T.eq +love = love or require("tests.love_stub") + +local SafeArea = require("src.core.SafeArea") + +local oldDims = love.graphics.getDimensions +local oldPix = love.graphics.getPixelDimensions +local oldSafe = love.window.getSafeArea + +local function frame(uw, uh, pw, ph, sx, sy, sw, sh) + love.graphics.getDimensions = function() return uw, uh end + love.graphics.getPixelDimensions = function() return pw, ph end + love.window.getSafeArea = function() return sx, sy, sw, sh end + return SafeArea.rect() +end + +-- a pixel-based rect on a 3x portrait phone comes back in units (#810) +local x, y, w, h = frame(375, 812, 1125, 2436, 0, 132, 1125, 2232) +eq(x, 0, "pixel-unit safe x rescales") +eq(y, 44, "pixel-unit top inset rescales to the real notch") +eq(w, 375, "pixel-unit safe width rescales") +eq(h, 744, "pixel-unit safe height rescales") + +-- a correct unit rect passes through untouched +x, y, w, h = frame(375, 812, 1125, 2436, 0, 44, 375, 734) +eq(y, 44, "a unit rect keeps its top inset") +eq(h, 734, "a unit rect keeps its height") + +-- dpi 1: no rescale, the oversized rect still clamps to the window +x, y, w, h = frame(640, 576, 640, 576, 0, 100, 900, 900) +eq(y, 100, "no rescale when units are pixels") +eq(w, 640, "width clamps to the drawable window") +eq(h, 476, "height clamps to the drawable window") + +love.graphics.getDimensions = oldDims +love.graphics.getPixelDimensions = oldPix +love.window.getSafeArea = oldSafe + +T.finish("safe area units") diff --git a/tests/engine/save_convert_toggle_objects.lua b/tests/engine/save_convert_toggle_objects.lua new file mode 100644 index 00000000..219dc43b --- /dev/null +++ b/tests/engine/save_convert_toggle_objects.lua @@ -0,0 +1,187 @@ +-- Gen1 save codec (src/save_convert/GenSave.lua) for wToggleableObjectFlags +-- (ram/wram.asm, flag_array $100): the ShowObject/HideObject persistence the +-- codec used to skip entirely, so an import resurrected both Mt Moon fossils +-- (#857) and reverted Cerulean's GUARD1/GUARD2/ROCKET swap so the officer +-- blocked the robbed-house door again (#763). Bit numbering comes from +-- ../pokered/data/maps/toggleable_objects.asm entry order (bit set = hidden, +-- engine/overworld/toggleable_objects.asm IsObjectHidden), and the offset is +-- re-derived here from the wram walk rather than read out of GenSave.OFFSETS. +-- Also covers the Yellow-only wPikachuHappiness byte (#763, #838): the +-- 0x271C offset is pinned from the pokeyellow symbol file, not a local +-- pokeyellow checkout, so it still wants a confirmation against a real +-- emulator-written Yellow .sav. +-- luajit tests/engine/save_convert_toggle_objects.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq +love = love or require("tests.love_stub") + +local bit = require("bit") +local GenSave = require("src.save_convert.GenSave") +local SaveData = require("src.core.SaveData") + +-- the codec crosswalks need the real dataset; CI has no ROM +local loadPokemon = loadfile("data/generated/pokemon.lua") +if not loadPokemon then + print("save_convert_toggle_objects skipped (needs data/generated/ for the Gen1 save codec)") + os.exit(0) +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) +local toggles = loadfile("src/save_convert/data/toggle_objects.lua")() +local data = { + pokemon = loadPokemon(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), + eventFlags = loadfile("src/save_convert/data/event_flags.lua")(), + toggleObjects = toggles, +} + +-- ------------------------------------------------------------------ +-- offset pins, independent of the codec's own arithmetic: wram.asm places +-- wToggleableObjectFlags 2 bytes (wPlayerCoins) past wPlayerCoins' label, +-- i.e. sav absolute 0x2852; the pokeyellow symbol file places +-- wPikachuHappiness at d46f - wMainDataStart d2f6 = 377, absolute 0x271C +-- ------------------------------------------------------------------ + +local OFF = GenSave.OFFSETS +eq(OFF.toggleObjectFlags, OFF.coins + 2, + "wToggleableObjectFlags sits 2 bytes (wPlayerCoins) past O.coins") +eq(OFF.toggleObjectFlags, 0x2852, "wToggleableObjectFlags is sav byte 0x2852") +eq(OFF.pikachuHappiness, 0x271C, "wPikachuHappiness is sav byte 0x271C") + +-- independent flag_array read (byte = index / 8, bit = index % 8), so nothing +-- below trusts the writer it is checking +local function flagGet(bytes, base, index) + local byte = bytes:byte(base + math.floor(index / 8) + 1) + return bit.band(bit.rshift(byte, index % 8), 1) == 1 +end + +-- ------------------------------------------------------------------ +-- crosswalk <-> maps.lua contract: every named toggle entry must resolve +-- to a real object_event, or encode's itemsTaken/defeatedTrainers fold +-- (which looks the object up by name) silently misses it +-- ------------------------------------------------------------------ + +local entries = 0 +for _, e in pairs(toggles.byBit) do + entries = entries + 1 + local found + for _, obj in ipairs((data.maps[e[1]] or {}).objects or {}) do + if obj.name == e[2] then found = obj break end + end + check(found ~= nil, e[1] .. " has an object_event named " .. e[2]) +end +-- toggleable_objects.asm has 228 rows; two are placeholders with no +-- object_event in this port (SILPHCO7F_UNUSED, the UNUSED_MAP_F4 entry) +eq(entries, 226, "the crosswalk carries every real toggle bit and no more") + +-- the bits under test, straight from the crosswalk's own numbering +eq(toggles.byBit[109][2], "MTMOONB2F_DOME_FOSSIL", "bit 109 is the dome fossil") +eq(toggles.byBit[110][2], "MTMOONB2F_HELIX_FOSSIL", "bit 110 is the helix fossil") +eq(toggles.byBit[7][2], "CERULEANCITY_GUARD1", "bit 7 is the door guard") +eq(toggles.byBit[9][2], "CERULEANCITY_GUARD2", "bit 9 is the roof guard") +eq(toggles.byBit[104][2], "MTMOON1F_MOON_STONE", "bit 104 is the moon stone") + +-- ------------------------------------------------------------------ +-- round trip: templateless export of a save past the fossil pickup +-- (data/scripts/story2.lua hides both balls) and the Cerulean robbery +-- resolution (data/scripts/story5.lua rocketRows shows GUARD1, hides +-- GUARD2/ROCKET), plus a taken overworld item, which vanilla folds into +-- these same bits (engine/events/pick_up_item.asm) +-- ------------------------------------------------------------------ + +local function seedSave() + local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) + save.party = { { + species = "SQUIRTLE", level = 6, exp = 200, + dvs = { hp = 1, attack = 2, defense = 3, speed = 4, special = 5 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 }, + stats = { hp = 22, attack = 12, defense = 13, speed = 11, special = 12 }, + hp = 22, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, + nickname = "SQ", ot = "RED", otId = save.player.id, catchRate = 45, + } } + return save +end + +local set = seedSave() +set.objectToggles = { + MT_MOON_B2F = { + MTMOONB2F_DOME_FOSSIL = false, + MTMOONB2F_HELIX_FOSSIL = false, + }, + CERULEAN_CITY = { + CERULEANCITY_GUARD1 = true, + CERULEANCITY_GUARD2 = false, + }, +} +-- the moon stone rides itemsTaken (src/world/OverworldController.lua +-- force-hides picked items), never objectToggles, so encode must fold it in +set.itemsTaken = { MT_MOON_1F_obj_9 = true } + +local setBytes = GenSave.encode(set, data, nil) +eq(#setBytes, GenSave.SAVE_SIZE, "the export is a 32768-byte save") + +local TOG = OFF.toggleObjectFlags +check(flagGet(setBytes, TOG, 109), "the taken dome fossil is hidden (bit 109)") +check(flagGet(setBytes, TOG, 110), "the taken helix fossil is hidden (bit 110)") +check(flagGet(setBytes, TOG, 9), "the swapped-out roof guard is hidden (bit 9)") +check(not flagGet(setBytes, TOG, 7), + "the officer now beside the door stays visible (bit 7 clear)") +check(flagGet(setBytes, TOG, 104), + "the taken moon stone folds from itemsTaken into bit 104") + +-- untouched entries fall back to their compiled-in defaults, not to zero +check(flagGet(setBytes, TOG, 0), "PALLETTOWN_OAK defaults hidden (bit 0 set)") +check(not flagGet(setBytes, TOG, 1), + "VIRIDIANCITY_OLD_MAN_SLEEPY defaults visible (bit 1 clear)") + +local back = GenSave.decode(setBytes, data) +eq(#(back.warnings or {}), 0, "the export decodes with no warnings") +local reTog = back.objectToggles +check(type(reTog) == "table", "an import populates save.objectToggles") +eq(reTog.MT_MOON_B2F.MTMOONB2F_DOME_FOSSIL, false, + "the dome fossil stays taken across export -> import") +eq(reTog.MT_MOON_B2F.MTMOONB2F_HELIX_FOSSIL, false, + "the helix fossil stays taken across export -> import") +eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD1, true, + "the officer stays beside the door across export -> import") +eq(reTog.CERULEAN_CITY.CERULEANCITY_GUARD2, false, + "the roof guard stays gone across export -> import") +eq(reTog.PALLET_TOWN.PALLETTOWN_OAK, false, + "Oak's roaming sprite imports at its hidden default") +eq(reTog.VIRIDIAN_CITY.VIRIDIANCITY_OLD_MAN_SLEEPY, true, + "the sleepy old man imports at his visible default") +eq(reTog.MT_MOON_1F.MTMOON1F_MOON_STONE, false, + "the folded moon stone imports hidden too") + +-- ------------------------------------------------------------------ +-- Yellow starter friendship: gated on the data set's game because the +-- byte is current-map scratch in Red/Blue (see O.pikachuHappiness) +-- ------------------------------------------------------------------ + +check(not flagGet(setBytes, OFF.pikachuHappiness, 0) + and setBytes:byte(OFF.pikachuHappiness + 1) == 0, + "a Red/Blue export leaves the scratch byte at 0x271C zeroed") +eq(back.pikachuHappiness, nil, "a Red/Blue import never invents a happiness") + +local dataYellow = { + pokemon = data.pokemon, moves = data.moves, items = data.items, + maps = data.maps, toggleObjects = toggles, + eventFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")(), + gameVersion = "yellow", +} +local ySave = seedSave() +ySave.pikachuHappiness = 200 +local yBytes = GenSave.encode(ySave, dataYellow, nil) +eq(yBytes:byte(OFF.pikachuHappiness + 1), 200, + "pikachuHappiness = 200 reaches sav byte 0x271C") +local yBack = GenSave.decode(yBytes, dataYellow) +eq(yBack.pikachuHappiness, 200, + "the follower's happiness survives export -> import on Yellow") + +T.finish("save_convert_toggle_objects") diff --git a/tests/launcher_mods_install_zip_test.lua b/tests/launcher_mods_install_zip_test.lua index 0fb9d1d0..e7b117f9 100644 --- a/tests/launcher_mods_install_zip_test.lua +++ b/tests/launcher_mods_install_zip_test.lua @@ -188,6 +188,43 @@ local leftover = 0 for _ in pairs(stagedTemps) do leftover = leftover + 1 end eq(leftover, 0, "fallback cleans staged temp after install") +-- #801: a same-id copy under a different folder name is replaced too, so the +-- update cannot leave a shadow copy for discover()'s first-id-wins race +resetFs() +files["mods/WildsOfKanto-1.5.0/manifest.json"] = + ('{"id":"%s","name":"Old Copy","version":"0.9.0","entry":"main.lua"}') + :format(MOD_ID) +files["mods/WildsOfKanto-1.5.0/main.lua"] = "return function() end\n" +files["imports/mods/update.zip"] = "PK\3\4update" +ok, err = LauncherMods.installZip("imports/mods/update.zip", + { replace = true, expectId = MOD_ID }) +check(ok == true, "replace install succeeds over an odd-named copy (" + .. tostring(err) .. ")") +check(files["mods/WildsOfKanto-1.5.0/manifest.json"] == nil, + "odd-named same-id folder is removed by the replace") +check(files["mods/" .. MOD_ID .. "/manifest.json"] ~= nil, + "replace still lands in mods/") + +-- #834: a manifest-less mods/ tree (interrupted copy debris) must not +-- block a plain re-import as "already installed" +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +files["imports/mods/again.zip"] = "PK\3\4again" +ok, err = LauncherMods.installZip("imports/mods/again.zip") +check(ok == true, "debris tree does not block re-import (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "debris is cleared by the re-import") + +-- a real installed copy still refuses a plain duplicate import +resetFs() +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["imports/mods/dup.zip"] = "PK\3\4dup" +ok, err = LauncherMods.installZip("imports/mods/dup.zip") +check(not ok, "a listed install still refuses a plain duplicate import") +check(tostring(err):find("already installed", 1, true), + "duplicate refusal still names already installed") + -- Restore love.filesystem = savedFs SaveData.portableBaseDir = savedSaveDataPortable diff --git a/tests/launcher_mods_shadow_copy_bug801_834_test.lua b/tests/launcher_mods_shadow_copy_bug801_834_test.lua new file mode 100644 index 00000000..65ee4ba7 --- /dev/null +++ b/tests/launcher_mods_shadow_copy_bug801_834_test.lua @@ -0,0 +1,225 @@ +-- #801 / #834: what the PLAYER sees after an update over a shadow copy. +-- The tier file (launcher_mods_install_zip_test.lua) asserts which folders +-- survive installZip; this one asserts the panel-facing contracts built on +-- top of them: LauncherMods.list() must report the NEW version after a +-- replace even when a same-id copy sits under an archive-named folder that +-- enumerates first (#801, "Updated ... to X" yet the old version kept +-- loading), and uninstall()/re-import must both recover a manifest-less +-- mods/ debris tree left by an interrupted copy (#834, "already +-- installed" with nothing showing in the panel). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("launcher mods shadow copy #801/#834") +local eq = S.eq +local check = S.check + +-- The real-world shape from the #801 report: a hand-unzipped copy kept the +-- archive's folder name. "W" sorts before "o" in the stub's sorted +-- enumeration, so the shadow folder enumerates first -- the exact ordering +-- that made discover()'s first-id-wins dedupe resolve the stale copy. +local MOD_ID = "overworld_wild_spawns" +local SHADOW = "mods/WildsOfKanto-1.5.0" +local NEW_VERSION = "1.7.1" + +local ARCHIVE = { + [MOD_ID .. "/manifest.json"] = + ('{"id":"%s","name":"Wilds of Kanto","version":"%s","entry":"main.lua"}') + :format(MOD_ID, NEW_VERSION), + [MOD_ID .. "/main.lua"] = "return function() end\n", +} + +local files, dirs, arch = {}, {}, {} + +local function resetFs() + for k in pairs(files) do files[k] = nil end + for k in pairs(dirs) do dirs[k] = nil end + for k in pairs(arch) do arch[k] = nil end +end + +local function dirChild(key, name) + if name == nil or name == "" then return key:match("^[^/]+") end + local prefix = name .. "/" + if key:sub(1, #prefix) ~= prefix then return nil end + return key:sub(#prefix + 1):match("^[^/]+") +end + +local function mapInfo(map, name, kind) + if map[name] ~= nil then return { type = kind or "file" } end + for key in pairs(map) do + if dirChild(key, name) then return { type = "directory" } end + end + return nil +end + +local vfs = {} + +function vfs.write(name, data) + files[name] = data + return true +end + +function vfs.read(name) + if arch[name] ~= nil then return arch[name] end + return files[name] +end + +function vfs.remove(name) + files[name] = nil + dirs[name] = nil + return true +end + +function vfs.createDirectory(name) + dirs[name] = true + return true +end + +function vfs.getInfo(name, kind) + local info = mapInfo(arch, name) + or mapInfo(files, name) + or mapInfo(dirs, name, "directory") + if info and kind and info.type ~= kind then return nil end + return info +end + +function vfs.getDirectoryItems(name) + local seen, items = {}, {} + local function add(child) + if child and not seen[child] then + seen[child] = true + items[#items + 1] = child + end + end + for key in pairs(arch) do add(dirChild(key, name)) end + for key in pairs(files) do add(dirChild(key, name)) end + for key in pairs(dirs) do add(dirChild(key, name)) end + table.sort(items) + return items +end + +function vfs.mount(_, point) + for rel, body in pairs(ARCHIVE) do + arch[point .. "/" .. rel] = body + end + return true +end + +function vfs.unmount() + for k in pairs(arch) do arch[k] = nil end + return true +end + +function vfs.newFileData(data, name) + return { __filedata = true, data = data, name = name } +end + +function vfs.getSaveDirectory() + return "/tmp/pokeport-shadow-copy-test" +end + +function vfs.getSource() + return nil +end + +local savedFs = love.filesystem +local savedCacheFs = package.loaded["src.import.CacheFs"] +local savedLauncherMods = package.loaded["src.mods.LauncherMods"] + +local SaveData = require("src.core.SaveData") +local savedPortableBase = SaveData.portableBaseDir +local savedPortableFs = SaveData.portableFs + +love.filesystem = vfs +package.loaded["src.import.CacheFs"] = nil +package.loaded["src.mods.LauncherMods"] = nil +-- Portable mode off: loadOptions/uninstall must stay on the stub vfs, or the +-- checkout's real save directory would leak into the test. +SaveData.portableBaseDir = function() return nil end +SaveData.portableFs = function() return nil end +local LauncherMods = require("src.mods.LauncherMods") + +local function versionOf(id) + for _, row in ipairs(LauncherMods.list()) do + if row.id == id then return row.version end + end + return nil +end + +-- #801: the shadow copy alone resolves as the mod (sanity for the setup), +-- and after a replace-install the panel row flips to the zip's version. +-- Pre-fix, installZip returned success but only rewrote mods/; the +-- shadow folder enumerated first and list() kept answering 1.5.0 forever. +resetFs() +files[SHADOW .. "/manifest.json"] = + ('{"id":"%s","name":"Wilds of Kanto","version":"1.5.0","entry":"main.lua"}') + :format(MOD_ID) +files[SHADOW .. "/main.lua"] = "return function() end\n" +eq(versionOf(MOD_ID), "1.5.0", "shadow copy resolves before the update") + +files["imports/mods/update.zip"] = "PK\3\4update" +local ok, err = LauncherMods.installZip("imports/mods/update.zip", + { replace = true, expectId = MOD_ID }) +check(ok == true, "replace over a shadow copy succeeds (" .. tostring(err) .. ")") +eq(err, MOD_ID, "replace reports the manifest id") +eq(versionOf(MOD_ID), NEW_VERSION, + "list() reports the zip's version after the replace") +check(files[SHADOW .. "/manifest.json"] == nil, + "shadow folder is gone, so no stale copy can win first-id-wins later") +eq(#LauncherMods.list(), 1, "the update leaves exactly one panel row") + +-- #801: Delete from the panel must take the shadow copy with it, or the +-- next boot resurrects the mod from the odd-named folder. +resetFs() +files[SHADOW .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +ok, err = LauncherMods.uninstall(MOD_ID) +check(ok == true, "uninstall succeeds with a shadow copy present (" + .. tostring(err) .. ")") +check(files[SHADOW .. "/manifest.json"] == nil, + "uninstall removes the same-id shadow folder too") +check(files["mods/" .. MOD_ID .. "/manifest.json"] == nil, + "uninstall removes mods/") +eq(#LauncherMods.list(), 0, "nothing is left for the panel to show") + +-- #834: interrupted-copy debris (mods/ with no manifest.json) is +-- invisible to list() yet used to hard-block every plain re-import with +-- "a mod named '' is already installed". Both recovery paths the +-- player can reach must work: plain re-import, and Delete. +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +eq(#LauncherMods.list(), 0, "debris tree shows no panel row") +files["imports/mods/again.zip"] = "PK\3\4again" +ok, err = LauncherMods.installZip("imports/mods/again.zip") +check(ok == true, "plain re-import over debris succeeds (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "re-import clears the debris file") +eq(versionOf(MOD_ID), NEW_VERSION, "re-import yields a listable mod") + +resetFs() +files["mods/" .. MOD_ID .. "/gfx/a.bin"] = "x" +ok, err = LauncherMods.uninstall(MOD_ID) +check(ok == true, "uninstall clears a debris-only tree (" + .. tostring(err) .. ")") +check(files["mods/" .. MOD_ID .. "/gfx/a.bin"] == nil, + "debris file is gone after uninstall") + +-- guard rail: with a healthy install and NO debris, the duplicate gate +-- still refuses a plain import with the same wording the panel shows +resetFs() +files["mods/" .. MOD_ID .. "/manifest.json"] = ARCHIVE[MOD_ID .. "/manifest.json"] +files["imports/mods/dup.zip"] = "PK\3\4dup" +ok, err = LauncherMods.installZip("imports/mods/dup.zip") +check(not ok, "healthy duplicate import is still refused") +check(tostring(err):find("already installed", 1, true), + "refusal keeps the already installed wording") + +-- Restore +love.filesystem = savedFs +SaveData.portableBaseDir = savedPortableBase +SaveData.portableFs = savedPortableFs +package.loaded["src.import.CacheFs"] = savedCacheFs +package.loaded["src.mods.LauncherMods"] = savedLauncherMods + +S.finish() diff --git a/tests/mod_ui_tests.lua b/tests/mod_ui_tests.lua index 71f99cda..1ef1c640 100644 --- a/tests/mod_ui_tests.lua +++ b/tests/mod_ui_tests.lua @@ -700,8 +700,14 @@ do end -- issue #133: title menu / continue overlays must not inherit LOGO2/LOGO1 --- (blue/red UI ink). A trailing trueColor zone covers the overlay box. +-- (blue/red UI ink). A trailing GRAYS zone covers the overlay box: through +-- the shade-remap shader it is the identity for the box's DMG shades, so +-- pass-through modes keep #133's white paper / black ink, while the mono +-- and inverted display modes still recolor it with the rest of the screen +-- (a trueColor rect skipped the shader and left a raw white hole over a +-- CLASSIC pea-green title, #870). do + local PaletteFX = require("src.render.PaletteFX") local logo2 = { { 255, 255, 255 }, { 230, 197, 0 }, { 148, 156, 148 }, { 41, 99, 181 }, } @@ -730,8 +736,8 @@ do menu.titleUiBox = { 0, 0, 12, 3 } game.stack:push(menu) local withMenu = TitleState.sgbPalettes(title, game) - check(withMenu and #withMenu == 4 and withMenu[4].colors == false, - "title menu adds a trueColor overlay zone") + check(withMenu and #withMenu == 4 and withMenu[4].colors == PaletteFX.GRAYS, + "title menu adds a DMG-grays overlay zone (#870)") check(withMenu[4].x == 0 and withMenu[4].y == 0 and withMenu[4].w == 13 * 8 and withMenu[4].h == 4 * 8, "menu overlay covers the CONTINUE/NEW GAME box") @@ -739,8 +745,8 @@ do game.stack:pop() game.stack:push({ titleUiBox = { 4, 7, 19, 16 } }) local withCont = TitleState.sgbPalettes(title, game) - check(withCont and #withCont == 4 and withCont[4].colors == false, - "continue-info overlay adds a trueColor zone") + check(withCont and #withCont == 4 and withCont[4].colors == PaletteFX.GRAYS, + "continue-info overlay adds a DMG-grays zone (#870)") check(withCont[4].x == 4 * 8 and withCont[4].y == 7 * 8 and withCont[4].w == 16 * 8 and withCont[4].h == 10 * 8, "continue overlay matches DisplayContinueGameInfo's box") diff --git a/tests/rom_importer_cursor_bug781_test.lua b/tests/rom_importer_cursor_bug781_test.lua new file mode 100644 index 00000000..8768b627 --- /dev/null +++ b/tests/rom_importer_cursor_bug781_test.lua @@ -0,0 +1,71 @@ +-- #781: Linux launcher mouse-dead behind the pad cursor. Reproduces the +-- X11 multi-monitor failure mode (polled love.mouse.getPosition frozen on +-- desktop-virtual coords, so the motion yield in _updatePadCursor never +-- fires) and asserts a host-forwarded mousepressed reclaims the pointer. +-- Self-contained: `luajit tests/rom_importer_cursor_bug781_test.lua`. +-- Should eventually merge into tests/rom_importer_cursor_test.lua (dofile'd +-- by tests/run_tests.lua). +package.path = "./?.lua;./?/init.lua;" .. package.path +if not _G.love then _G.love = require("tests.love_stub") end + +local S = require("tests.harness").suite("rom importer pad cursor #781") +local eq = S.eq + +local RomImporter = require("src.import.RomImporter") + +-- Bare importer with just the pad-cursor state new() would build; isNX +-- false keeps _updatePadCursor on the desktop path (polled motion yield), +-- _flex nil keeps the right-stick branch out of LauncherView. +local function makeImporter() + return setmetatable({ + android = false, + isNX = false, + _flex = nil, + _padCursor = { x = 320, y = 260 }, + _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, + _padInited = true, + }, RomImporter) +end + +-- Failure mode: SDL's polled mouse state stuck on coordinates outside the +-- window (primary display away from desktop 0,0). Successive samples are +-- identical, so the motion yield sees zero delta and never releases the +-- pad cursor no matter how much the real mouse moves. +local ri = makeImporter() +love.mouse.getPosition = function() return 2960, 4130 end +ri._padCursorActive = true +ri:_updatePadCursor(1 / 60) -- seeds _lastMouseX/_lastMouseY +ri:_updatePadCursor(1 / 60) +ri:_updatePadCursor(1 / 60) +eq(ri._padCursorActive, true, + "frozen polled coords starve the motion yield (the #781 trap)") + +-- The fix: the host-forwarded real press must win the pointer back, same +-- contract as PadCursor.yieldToPointer in the overlay hosts. This is the +-- half that un-gates LauncherView.update's click minting. +ri:mousepressed(10, 10, 1) +eq(ri._padCursorActive, false, + "mousepressed reclaims the pointer even when the yield is starved (#781)") + +-- A reclaimed pointer must stay reclaimed: the next pad-cursor tick with +-- still-frozen polled coords may not re-arm it by itself. +ri:_updatePadCursor(1 / 60) +eq(ri._padCursorActive, false, + "an idle pad tick does not re-steal the pointer after reclaim") + +-- Regression guard for the healthy desktop path: when polled coords do +-- move (window-relative, single monitor), the existing motion yield still +-- releases the pad cursor without needing a click. +local ri2 = makeImporter() +local px = 100 +love.mouse.getPosition = function() return px, 100 end +ri2._padCursorActive = true +ri2:_updatePadCursor(1 / 60) +px = 140 +ri2:_updatePadCursor(1 / 60) +eq(ri2._padCursorActive, false, + "real mouse motion still yields the pad cursor on sane polled coords") + +S.finish() diff --git a/tests/rom_importer_cursor_test.lua b/tests/rom_importer_cursor_test.lua index 2f8a41f3..47eee9ea 100644 --- a/tests/rom_importer_cursor_test.lua +++ b/tests/rom_importer_cursor_test.lua @@ -45,4 +45,12 @@ ri:play("red") eq(booted, "red", "unsupported system cursors still allow boot") eq(currentCursor, "hand", "unsupported system cursors leave the existing cursor alone") +-- #781: a host-forwarded real mouse press must win the pointer back from +-- the pad cursor. While it is active LauncherView.update refuses to mint +-- mouse clicks, so a stuck motion yield (X11 multi-monitor polled coords) +-- left the Linux launcher mouse-dead until this reclaim existed. +ri._padCursorActive = true +ri:mousepressed(10, 10, 1) +eq(ri._padCursorActive, false, "mouse press yields the pad cursor (#781)") + S.finish() diff --git a/tests/save_convert_yellow_bug838_test.lua b/tests/save_convert_yellow_bug838_test.lua new file mode 100644 index 00000000..3e96eb1c --- /dev/null +++ b/tests/save_convert_yellow_bug838_test.lua @@ -0,0 +1,208 @@ +-- Yellow save export/import checks for #838: the codec used to run the +-- Red/Blue tables unmodified for Yellow, so (1) event flags went through +-- pokered's bit numbering even though pokeyellow renumbers wEventFlags, +-- and (2) wPikachuHappiness (pokeyellow d46f, absolute 0x271C in SRAM) +-- was never encoded or decoded. Yellow offsets are verified against the +-- pokeyellow symbol file -- no local pokeyellow checkout exists, so +-- ../pokered can only vouch for the shared R/B layout, which pokeyellow's +-- sram.asm matches byte for byte. Needs data/generated/, same as +-- tests/save_convert_tests.lua (its natural eventual home). +-- +-- Run: luajit tests/save_convert_yellow_bug838_test.lua + +package.path = "./?.lua;" .. package.path +_G.love = require("tests.love_stub") + +local GenSave = require("src.save_convert.GenSave") +local SaveConvert = require("src.save_convert.SaveConvert") +local SaveData = require("src.core.SaveData") + +local checks, failures = 0, 0 +local function check(cond, msg) + checks = checks + 1 + if not cond then + failures = failures + 1 + print("FAIL: " .. msg) + end +end + +GenSave.setCharmap(loadfile("src/save_convert/data/charmap.lua")()) + +local redFlags = loadfile("src/save_convert/data/event_flags.lua")() +local yellowFlags = loadfile("src/save_convert/data/event_flags_yellow.lua")() + +-- Red/Blue and Yellow crosswalk sets over the same generated tables; the +-- only differences the codec keys off are the event-flag numbering and the +-- gameVersion tag (SaveConvert.ensureData stamps the same shape, #838). +local shared = { + pokemon = loadfile("data/generated/pokemon.lua")(), + moves = loadfile("data/generated/moves.lua")(), + items = loadfile("data/generated/items.lua")(), + maps = loadfile("data/generated/maps.lua")(), +} +local redData = { + pokemon = shared.pokemon, moves = shared.moves, items = shared.items, + maps = shared.maps, eventFlags = redFlags, +} +local yellowData = { + pokemon = shared.pokemon, moves = shared.moves, items = shared.items, + maps = shared.maps, eventFlags = yellowFlags, gameVersion = "yellow", +} + +local OFF = GenSave.OFFSETS + +-- ------------------------------------------------------------------ +-- the Yellow event-flag table itself: pokeyellow's renumbering, not a +-- copy of the Red table under a new filename +-- ------------------------------------------------------------------ + +check(yellowFlags.count == 2560, + "yellow table covers the full 2560-bit wEventFlags array") +check(type(yellowFlags.byName) == "table" and type(yellowFlags.byBit) == "table", + "yellow table has the byName/byBit shape the codec reads") + +-- shared names on DIFFERENT bits: Yellow inserts events ahead of them +check(redFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406 + and yellowFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400, + "EVENT_GOT_DOME_FOSSIL sits on red bit 1406 vs yellow bit 1400") +check(redFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1402 + and yellowFlags.byName.EVENT_BEAT_MT_MOON_3_TRAINER_0 == 1403, + "the Mt Moon 3 trainer block shifts +1 in yellow (Jessie & James insert)") +check(redFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1924 + and yellowFlags.byName.EVENT_BEAT_SILPH_CO_11F_TRAINER_0 == 1925, + "the Silph Co 11F trainer block shifts +1 in yellow") + +-- yellow-only names the port's Yellow scripts set (data/scripts/ +-- yellow_jessie_james.lua and the catch-training tutorial): absent from +-- the Red table, so exporting through it silently dropped them +check(yellowFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402 + and redFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == nil, + "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES is yellow bit 1402, unknown to red") +check(yellowFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == 45 + and redFlags.byName.EVENT_COMPLETED_CATCH_TRAINING == nil, + "EVENT_COMPLETED_CATCH_TRAINING is yellow bit 45, unknown to red") +check(yellowFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY ~= nil + and redFlags.byName.EVENT_GOT_SQUIRTLE_FROM_OFFICER_JENNY == nil, + "the Officer Jenny Squirtle event exists only in the yellow table") + +-- byBit/byName agree on the renumbered entries +check(yellowFlags.byBit[1400] == "EVENT_GOT_DOME_FOSSIL" + and yellowFlags.byBit[1402] == "EVENT_BEAT_MT_MOON_3_JESSIE_JAMES", + "yellow byBit resolves the renumbered bits back to their names") + +-- ------------------------------------------------------------------ +-- wPikachuHappiness offset: d46f - wMainDataStart d2f6 = 377 past +-- sMainData, absolute 0x271C (per the pokeyellow symbol file) +-- ------------------------------------------------------------------ + +check(OFF.pikachuHappiness == 10012, + "OFFSETS.pikachuHappiness is absolute 0x271C (got " + .. tostring(OFF.pikachuHappiness) .. ")") +check(OFF.pikachuHappiness == OFF.mainData + 377, + "pikachuHappiness sits 377 bytes past sMainData (wram d46f - d2f6)") +-- the byte is INSIDE the checksummed main-data window, so writing it +-- without recomputing the checksum would brick the save on a cartridge +check(OFF.pikachuHappiness >= OFF.checksumStart + and OFF.pikachuHappiness < OFF.checksumEnd, + "pikachuHappiness lies inside the main checksum window") + +-- ------------------------------------------------------------------ +-- encode/decode gate: yellow data writes and reads the byte, R/B data +-- leaves it alone (in Red/Blue it is current-map scratch) +-- ------------------------------------------------------------------ + +local save = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +save.pikachuHappiness = 200 +-- the follower seeds happiness at 90 (src/world/PikachuFollower.lua), so +-- 200 can only come from this table -- no default could fake the check + +local yBytes = GenSave.encode(save, yellowData, nil) +check(#yBytes == GenSave.SAVE_SIZE, "yellow encode produces exactly 32768 bytes") +check(yBytes:byte(OFF.pikachuHappiness + 1) == 200, + "yellow encode writes pikachuHappiness to 0x271C (got " + .. yBytes:byte(OFF.pikachuHappiness + 1) .. ")") +check(GenSave.mainChecksumValid(yBytes), + "yellow encode still emits a valid main-data checksum") +local yDec = GenSave.decode(yBytes, yellowData) +check(yDec.pikachuHappiness == 200, + "yellow decode reads pikachuHappiness back (got " + .. tostring(yDec.pikachuHappiness) .. ")") + +-- a yellow save whose table never held the field falls back to the +-- follower's seed value instead of exporting friendship 0 +local noHap = SaveData.newGame({ playerName = "RED", rivalName = "BLUE" }) +noHap.pikachuHappiness = nil +local seedBytes = GenSave.encode(noHap, yellowData, nil) +check(seedBytes:byte(OFF.pikachuHappiness + 1) == 90, + "a missing pikachuHappiness exports as the follower seed 90, not 0") + +-- R/B output unchanged: same save through the red data set leaves the +-- scratch byte zero-filled and decode never invents the field +local rBytes = GenSave.encode(save, redData, nil) +check(rBytes:byte(OFF.pikachuHappiness + 1) == 0, + "red/blue encode leaves the 0x271C scratch byte zero-filled") +check(GenSave.decode(rBytes, redData).pikachuHappiness == nil, + "red/blue decode does not fabricate a pikachuHappiness field") + +-- ------------------------------------------------------------------ +-- event flags land on pokeyellow bits. Independent LSB-first flag_array +-- read (pokered home FlagAction convention: byte N/8, bit N%8) so the +-- assertions cannot inherit a codec bit-order bug. +-- ------------------------------------------------------------------ + +local bit = require("bit") +local function flagBit(bytes, index) + local b = bytes:byte(OFF.eventFlags + math.floor(index / 8) + 1) + return bit.band(bit.rshift(b, index % 8), 1) == 1 +end + +local fsave = SaveData.newGame({ playerName = "ASH", rivalName = "GARY" }) +fsave.flags = { + EVENT_GOT_DOME_FOSSIL = true, + EVENT_BEAT_MT_MOON_3_JESSIE_JAMES = true, + EVENT_COMPLETED_CATCH_TRAINING = true, +} + +local yfBytes = GenSave.encode(fsave, yellowData, nil) +check(flagBit(yfBytes, 1400) and not flagBit(yfBytes, 1406), + "yellow export puts EVENT_GOT_DOME_FOSSIL on bit 1400, not red's 1406") +check(flagBit(yfBytes, 1402), + "yellow export carries EVENT_BEAT_MT_MOON_3_JESSIE_JAMES on bit 1402") +check(flagBit(yfBytes, 45), + "yellow export carries EVENT_COMPLETED_CATCH_TRAINING on bit 45") +local yfDec = GenSave.decode(yfBytes, yellowData) +check(yfDec.flags.EVENT_GOT_DOME_FOSSIL + and yfDec.flags.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES + and yfDec.flags.EVENT_COMPLETED_CATCH_TRAINING, + "yellow-numbered flags round-trip through decode") + +-- the pre-fix failure mode, pinned so it can never quietly return: the +-- red table lands the fossil on the wrong yellow bit and drops the +-- yellow-only names entirely +local rfBytes = GenSave.encode(fsave, redData, nil) +check(flagBit(rfBytes, 1406) and not flagBit(rfBytes, 1400), + "the red table writes the fossil on 1406, which yellow reads as another event") +check(not flagBit(rfBytes, 1402) and not flagBit(rfBytes, 45), + "the red table silently drops both yellow-only flags") + +-- ------------------------------------------------------------------ +-- SaveConvert.ensureData substitutes the yellow flag table (and stamps +-- gameVersion) when the caller names yellow; the versionless set still +-- resolves red numbering, per-version cached separately (#420 pattern) +-- ------------------------------------------------------------------ + +local yData, yErr = SaveConvert.loadData("yellow") +check(yData ~= nil, "SaveConvert.loadData('yellow') resolves (" .. tostring(yErr) .. ")") +check(yData and yData.gameVersion == "yellow", + "loadData('yellow') stamps gameVersion for the codec's byte gate") +check(yData and yData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1400 + and yData.eventFlags.byName.EVENT_BEAT_MT_MOON_3_JESSIE_JAMES == 1402, + "loadData('yellow') serves the pokeyellow flag numbering") +local dData = SaveConvert.loadData() +check(dData and dData.eventFlags.byName.EVENT_GOT_DOME_FOSSIL == 1406 + and dData.gameVersion == nil, + "versionless loadData still serves the red numbering, untagged") + +print(string.format("save convert yellow #838: %d/%d checks passed", + checks - failures, checks)) +if failures > 0 then os.exit(1) end